subagent thinking accordion; indicator fixes; script details & edit

This commit is contained in:
2026-04-20 15:09:37 -04:00
parent a188268906
commit b1d4459809
25 changed files with 2041 additions and 174 deletions

View File

@@ -0,0 +1,113 @@
# Details Edit Protocol
This document describes the WebSocket message protocol for reading and editing the `details` field of category items (indicators, strategies, research scripts) from the web client.
## Background
Every category item stored in the sandbox has a `details` field: a full markdown description of the implementation with enough detail that another coding agent could reproduce the code from it alone. The web client can display this field, allow the user to edit it in plain text, and submit the revised version — the gateway then diffs the old vs new details and instructs the appropriate subagent to update the Python code accordingly.
The `details` field is intentionally **filtered out of the workspace `_types` stores** (see `mcp-tool-wrapper.ts:filterTypeStoreState`) because it can be several kilobytes of markdown. The read/update protocol below provides direct, on-demand access.
---
## Message Flow
### 1. Read Details
**Client → Server**
```json
{
"type": "read_details",
"category": "indicator" | "strategy" | "research",
"name": "My Indicator Name"
}
```
**Server → Client (success)**
```json
{
"type": "details_data",
"category": "indicator",
"name": "My Indicator Name",
"details": "## My Indicator\n\nFull markdown description..."
}
```
**Server → Client (error)**
```json
{
"type": "details_error",
"category": "indicator",
"name": "My Indicator Name",
"error": "Item not found or has no details"
}
```
---
### 2. Submit Updated Details
**Client → Server**
```json
{
"type": "update_details",
"category": "indicator" | "strategy" | "research",
"name": "My Indicator Name",
"details": "## My Indicator\n\nRevised full markdown description..."
}
```
The gateway will:
1. Read the current `details` from the sandbox via `python_read`
2. Compute a unified diff between the old and new text
3. If no changes are detected, reply immediately with `details_updated` (success)
4. Otherwise, invoke the appropriate subagent (indicator / strategy / research) with instructions to update the Python code according to the diff, and also persist the new `details` text
**While the subagent is running**, the server streams progress events using the same event types as normal agent interactions:
```json
{ "type": "subagent_chunk", "agentName": "indicator", "content": "Reading current implementation..." }
{ "type": "subagent_tool_call", "agentName": "indicator", "toolName": "python_read", "label": "python_read" }
{ "type": "subagent_tool_call", "agentName": "indicator", "toolName": "python_edit", "label": "python_edit" }
{ "type": "subagent_chunk", "agentName": "indicator", "content": "Applied patch. Validation passed." }
```
**Server → Client (completion)**
```json
{
"type": "details_updated",
"category": "indicator",
"name": "My Indicator Name",
"success": true
}
```
or on failure:
```json
{
"type": "details_updated",
"category": "indicator",
"name": "My Indicator Name",
"success": false,
"error": "Failed to update details"
}
```
---
## Workspace Sync After Update
When the subagent calls `python_edit`, the sandbox returns a `_workspace_sync` payload in the MCP response. The gateway automatically applies this to the `{category}_types` workspace store and sends a WebSocket `patch` message to the client (the normal workspace sync path). The client should listen for these patches to refresh any UI that displays list metadata (name, description).
The `details` field itself is **not** in the workspace store — the client must call `read_details` again if it needs the refreshed details text after an update.
---
## Implementation Notes
| Component | File | Responsibility |
|---|---|---|
| WebSocket routing | `src/channels/websocket-handler.ts` | Parse `read_details` / `update_details`, stream subagent events, send `details_data` / `details_updated` |
| Harness methods | `src/harness/agent-harness.ts` | `readDetails()`, `streamDetailsUpdate()` |
| Diff utility | `src/harness/agent-harness.ts` | `buildUnifiedDiff()`, `computeLCS()` (module-level helpers) |
| Instruction builder | `src/harness/agent-harness.ts` | `buildDetailsUpdateInstruction()` |
| Details filter | `src/tools/mcp/mcp-tool-wrapper.ts` | `filterTypeStoreState()` — strips `details` before workspace sync |

View File

@@ -381,9 +381,75 @@ export class WebSocketHandler {
// Workspace sync: patch message
logger.debug({ store: payload.store, seq: payload.seq }, 'Handling workspace patch');
await workspace!.handlePatch(payload.store, payload.seq, payload.patch || []);
} else if (payload.type === 'client_log') {
const level: string = payload.level ?? 'log';
const msg = `[client:${authContext.sessionId}] ${payload.message ?? ''}`;
const logMeta = { source: 'client', sessionId: authContext.sessionId };
if (level === 'error') logger.error(logMeta, msg);
else if (level === 'warn') logger.warn(logMeta, msg);
else if (level === 'debug') logger.debug(logMeta, msg);
else logger.info(logMeta, msg);
} else if (payload.type === 'agent_stop') {
logger.info('Agent stop requested');
harness?.interrupt();
} else if (payload.type === 'read_details') {
// Read the details field for a category item
const { category, name } = payload;
if (!harness) {
socket.send(JSON.stringify({ type: 'details_error', category, name, error: 'Session not ready' }));
} else {
try {
const details = await harness.readDetails(category, name);
if (details === null) {
socket.send(JSON.stringify({ type: 'details_error', category, name, error: 'Item not found or has no details' }));
} else {
socket.send(JSON.stringify({ type: 'details_data', category, name, details }));
}
} catch (error) {
logger.error({ error, category, name }, 'Error reading details');
socket.send(JSON.stringify({ type: 'details_error', category, name, error: 'Failed to read details' }));
}
}
} else if (payload.type === 'update_details') {
// User submitted a revised details string — diff and invoke the appropriate subagent
const { category, name, details: newDetails } = payload;
if (!harness) {
socket.send(JSON.stringify({ type: 'details_updated', category, name, success: false, error: 'Session not ready' }));
} else {
try {
let hadError = false;
for await (const event of harness.streamDetailsUpdate(category, name, newDetails)) {
const e = event as HarnessEvent;
switch (e.type) {
case 'chunk':
socket.send(JSON.stringify({ type: 'subagent_chunk', agentName: category, content: e.content }));
break;
case 'subagent_chunk':
socket.send(JSON.stringify({ type: 'subagent_chunk', agentName: e.agentName, content: e.content }));
break;
case 'subagent_tool_call':
socket.send(JSON.stringify({ type: 'subagent_tool_call', agentName: e.agentName, toolName: e.toolName, label: e.label }));
break;
case 'tool_call':
socket.send(JSON.stringify({ type: 'agent_tool_call', toolName: e.toolName, label: e.label }));
break;
case 'image':
socket.send(JSON.stringify({ type: 'image', data: e.data, mimeType: e.mimeType, caption: e.caption }));
break;
case 'error':
hadError = true;
socket.send(JSON.stringify({ type: 'subagent_chunk', agentName: category, content: `Error in ${e.source}` }));
break;
case 'done':
break;
}
}
socket.send(JSON.stringify({ type: 'details_updated', category, name, success: !hadError }));
} catch (error) {
logger.error({ error, category, name }, 'Error updating details');
socket.send(JSON.stringify({ type: 'details_updated', category, name, success: false, error: 'Failed to update details' }));
}
}
} else if (this.isDatafeedMessage(payload)) {
// Historical data request - send to OHLC service
logger.info({ type: payload.type }, 'Routing to datafeed handler');

View File

@@ -733,6 +733,106 @@ export class AgentHarness {
return this.mcpClient.callTool(name, args);
}
/**
* Read the `details` field for a category item directly from the sandbox.
* Returns null if the item doesn't exist or has no details.
*/
async readDetails(category: string, name: string): Promise<string | null> {
try {
const raw = await this.mcpClient.callTool('python_read', { category, name });
const content = (raw as any)?.content;
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item.type === 'text' && item.text) {
try {
const parsed = JSON.parse(item.text);
if (parsed?.exists && parsed?.metadata?.details !== undefined) {
return parsed.metadata.details as string;
}
} catch { /* ignore */ }
}
}
return null;
} catch {
return null;
}
}
/**
* Stream a details-driven code update for a category item.
*
* Computes a unified diff between the stored details and `newDetails`,
* then instructs the appropriate subagent to update the Python code to
* match the revised specification. Yields HarnessEvents from the subagent
* so the WebSocket handler can stream progress to the web client.
*/
async *streamDetailsUpdate(
category: string,
name: string,
newDetails: string,
signal?: AbortSignal,
): AsyncGenerator<HarnessEvent> {
const logger = this.config.logger;
// 1. Read current details
const oldDetails = await this.readDetails(category, name) ?? '';
// 2. Compute a simple unified diff
const diff = buildUnifiedDiff(oldDetails, newDetails, `${category}/${name} details`);
if (!diff.trim()) {
// No change — nothing to do
yield { type: 'done', content: 'No changes detected in the details.' };
return;
}
// 3. Build instruction for the subagent
const instruction = buildDetailsUpdateInstruction(category, name, newDetails, diff);
// 4. Build a minimal subagent context
const context = {
userContext: createUserContext({
userId: this.config.userId,
sessionId: this.config.sessionId,
license: this.config.license,
channelType: this.config.channelType ?? ChannelType.WEBSOCKET,
channelUserId: this.config.channelUserId ?? this.config.userId,
}),
};
// 5. Ensure the right subagent is ready and invoke it
if (category === 'indicator') {
if (!this.indicatorSubagent) await this.initializeIndicatorSubagent();
if (!this.indicatorSubagent) {
yield { type: 'error', source: 'indicator', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming indicator details update');
yield* this.indicatorSubagent.streamEvents(context, instruction, signal);
} else if (category === 'strategy') {
if (!this.strategySubagent) await this.initializeStrategySubagent();
if (!this.strategySubagent) {
yield { type: 'error', source: 'strategy', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming strategy details update');
yield* this.strategySubagent.streamEvents(context, instruction, signal);
} else if (category === 'research') {
if (!this.researchSubagent) await this.initializeResearchSubagent();
if (!this.researchSubagent) {
yield { type: 'error', source: 'research', fatal: false };
return;
}
logger.info({ category, name }, 'Streaming research details update');
yield* this.researchSubagent.streamEvents(context, instruction, signal);
} else {
yield { type: 'error', source: 'harness', fatal: false };
}
}
/**
* Expose MCP client so channel handlers can wire ContainerSync after harness init.
*/
@@ -1138,3 +1238,95 @@ export class AgentHarness {
await this.mcpClient.disconnect();
}
}
// =============================================================================
// Details update helpers (module-level, no class dependency)
// =============================================================================
/**
* Produce a minimal unified diff between two strings, suitable for passing to
* an LLM as a change description. Returns an empty string when there is no diff.
*/
function buildUnifiedDiff(oldText: string, newText: string, label: string): string {
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
if (oldLines.join('\n') === newLines.join('\n')) return '';
const lines: string[] = [];
lines.push(`--- a/${label}`);
lines.push(`+++ b/${label}`);
// Simple LCS-based diff — sufficient for the LLM to understand structural changes
const lcs = computeLCS(oldLines, newLines);
let oi = 0, ni = 0, li = 0;
while (oi < oldLines.length || ni < newLines.length) {
if (li < lcs.length && oi < oldLines.length && ni < newLines.length &&
oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]) {
lines.push(` ${oldLines[oi]}`);
oi++; ni++; li++;
} else if (ni < newLines.length && (li >= lcs.length || newLines[ni] !== lcs[li])) {
lines.push(`+${newLines[ni]}`);
ni++;
} else {
lines.push(`-${oldLines[oi]}`);
oi++;
}
}
return lines.join('\n');
}
/** Compute longest common subsequence of two string arrays. */
function computeLCS(a: string[], b: string[]): string[] {
const m = a.length, n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
const result: string[] = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) { result.unshift(a[i - 1]); i--; j--; }
else if (dp[i - 1][j] > dp[i][j - 1]) { i--; } else { j--; }
}
return result;
}
/**
* Build the instruction string passed to the subagent when the user edits
* the details field of a category item.
*/
function buildDetailsUpdateInstruction(
category: string,
name: string,
newDetails: string,
diff: string,
): string {
const categoryLabel = category === 'indicator' ? 'custom indicator'
: category === 'strategy' ? 'trading strategy'
: 'research script';
return `The user has edited the specification (details) for the ${categoryLabel} named "${name}".
Your task: update the Python implementation to match the revised specification. Use \`python_edit\` with targeted patches — make only the changes implied by the diff below. Also update the \`details\` field via the \`details\` parameter on \`python_edit\` to store the new specification text.
## Revised specification
${newDetails}
## What changed (unified diff of the details text)
\`\`\`diff
${diff}
\`\`\`
Instructions:
- Read the current implementation first with \`python_read(category="${category}", name="${name}")\` to understand what exists.
- Apply only the changes described by the diff above — do not rewrite unrelated parts of the code.
- Pass \`details\` as the full revised specification text shown above.
- After editing, confirm the change was applied and validation passed.`;
}

View File

@@ -208,9 +208,13 @@ workspace_patch("indicators", [
Custom indicators are Python scripts in the `indicator` category. Use `python_write` / `python_edit` / `python_read` / `python_list` exactly as you would for research scripts, but with `category="indicator"`.
`python_write` requires `category`, `name`, `description`, `details`, and `code`. The `details` field must be a complete markdown description of the indicator formula, algorithm, all parameters and their semantics, input series, output columns, and any non-obvious implementation choices with enough detail that another agent could reproduce the code from it alone.
### Writing a Custom Indicator Script
A custom indicator must define a **top-level function whose name exactly matches the sanitized directory name** (the name you passed to `python_write`, after sanitization). It receives the OHLC columns it needs as positional arguments, matching `input_series` in the metadata. It must return a `pd.Series` (single output) or `pd.DataFrame` (multi-output, column names must match `output_columns`).
A custom indicator must define a **top-level function whose name is the lowercase, snake_case form of the `name` passed to `python_write`**: take `name`, lowercase it, replace spaces and hyphens with underscores. For example, `name="TrendFlex"` function `def trendflex(...)`, `name="VW RSI"` function `def vw_rsi(...)`.
The function receives the OHLC columns it needs as positional arguments, matching `input_series` in the metadata. It must return a `pd.Series` (single output) or `pd.DataFrame` (multi-output, column names must match `output_columns`).
```python
# Example: volume-weighted RSI (function name = "vw_rsi", directory name = "vw_rsi")
@@ -243,6 +247,15 @@ After writing a custom indicator with `python_write`, add it to the workspace us
When writing a custom indicator you **must** supply complete metadata so the web client can auto-construct the TradingView plotter. Pass these fields in the `metadata` argument to `python_write`:
**Top-level required fields** (not inside `metadata`):
| Field | Required | Description |
|---|---|---|
| `description` | yes | One-sentence summary |
| `details` | yes | Full markdown description formula, algorithm, all parameters and their semantics, input series, output columns, and any non-obvious choices. Enough detail for another agent to reproduce the code. |
**`metadata` fields:**
| Field | Type | Required | Description |
|---|---|---|---|
| `parameters` | dict | yes | Parameter schema: `{param_name: {type, default, description?, min?, max?}}` |
@@ -322,6 +335,15 @@ python_write(
category="indicator",
name="vw_rsi",
description="RSI weighted by relative volume.",
details="""## Volume-Weighted RSI
Computes RSI(length) on close prices, then scales it by relative volume (current volume divided by its rolling mean over the same period), and applies a 3-bar smoothing average.
**Formula:** `(rsi * (volume / volume.rolling(length).mean())).rolling(3).mean()`
**Inputs:** close (Series), volume (Series)
**Output:** single Series named "value" — the smoothed volume-weighted RSI, plotted in a separate pane.
**Parameters:** length (int, default 14, range 2200) — lookback period for both RSI and the volume mean.""",
code="""
import pandas as pd
import pandas_ta as ta
@@ -351,6 +373,15 @@ python_write(
category="indicator",
name="my_bbands",
description="Custom Bollinger Bands.",
details="""## Custom Bollinger Bands
Standard Bollinger Bands computed via pandas-ta on close prices.
**Formula:** upper = SMA(length) + std * σ(length); lower = SMA(length) - std * σ(length); mid = SMA(length)
**Inputs:** close (Series)
**Outputs:** upper, mid, lower — three Series plotted on the price pane with a shaded fill between upper and lower.
**Parameters:** length (int, default 20, range 5500), std (float, default 2.0, range 0.55.0)""",
code="""
import pandas as pd
import pandas_ta as ta
@@ -439,13 +470,15 @@ Use `evaluate_indicator` to test any indicator (standard or custom) before addin
evaluate_indicator(
symbol="BTC/USDT.BINANCE",
from_time="30 days ago",
to_time="now",
to_time="0 minutes ago",
period_seconds=3600,
pandas_ta_name="custom_vw_rsi",
parameters={"length": 14}
)
```
**Time format for `from_time`/`to_time`**: Use a relative string like `"30 days ago"` / `"1 minute ago"` (format: `"N unit(s) ago"` where unit is second/minute/hour/day/week/month/year), an ISO date string like `"2024-04-20"`, or a Unix timestamp integer. Do **not** use `"now"` it is not a valid value; use `"0 minutes ago"` instead.
Returns a structured array of `{timestamp, value}` (or multiple value columns for multi-output indicators like MACD, BBands). Use the results to confirm the indicator is computing as expected before patching the workspace.
---

View File

@@ -42,7 +42,7 @@ Quick reference — approximate bars per resolution at various windows:
You have direct access to these MCP tools:
- **python_write**: Create a new script (research, strategy, or indicator category)
- Required: category, name, description, code
- Required: category, name, description, details, code
- Optional: metadata (category-specific fields — see below)
- **For research**: fully executes the script and returns all output (stdout, stderr) and captured chart images. The response IS the execution result — **do not call `execute_research` afterward**.
- **For indicator/strategy**: runs against synthetic test data to catch compile/runtime errors; no chart images are generated.
@@ -50,7 +50,7 @@ You have direct access to these MCP tools:
- **python_edit**: Update an existing script
- Required: category, name
- Optional: code, description, metadata
- Optional: code, patches, description, details (full replacement), detail_patches (targeted text replacements in details), metadata
- **For research**: re-executes the script when code is changed and returns all output and images. **Do not call `execute_research` afterward**.
- **For indicator/strategy**: re-runs the validation test only.
- Returns validation results and execution output
@@ -100,6 +100,7 @@ When a user requests analysis:
- Write clean, well-commented Python code
- Include proper error handling
- Use appropriate ticker symbols, time ranges, and periods
- Always supply `details`: a complete markdown description of what the script does — algorithms, data sources, parameters, and any non-obvious implementation choices — with enough detail that another agent could reproduce the code from it alone
- The script will auto-execute after writing
4. **Check execution results**: The tool returns the execution result directly — this is the script's actual output:
@@ -164,6 +165,7 @@ You:
3. Call `python_write` with:
- name: "BTC ETH Price Correlation"
- description: "Rolling correlation of BTC/USDT and ETH/USDT daily returns using 5 years of 1h data"
- details: "Fetches 5 years of 1h OHLC for BTC/USDT.BINANCE and ETH/USDT.BINANCE. Computes log daily returns from close prices. Calculates a 30-day rolling Pearson correlation between the two return series. Plots the correlation over time with a horizontal zero line. Prints bar count and date range after each fetch."
- code: (Python script fetching 5yr of 1h OHLC for both tickers and plotting rolling correlation)
4. Check execution results
5. If successful, respond with a brief summary of what the script does

View File

@@ -103,6 +103,21 @@ python_write(
category="strategy",
name="RSI Mean Reversion",
description="Buy oversold, sell overbought based on RSI(14) on BTC/USDT 1h bars.",
details="""## RSI Mean Reversion
Trades BTC/USDT on 5-minute bars using RSI(14) as the signal.
**Entry logic:**
- Buy when RSI crosses below `oversold` (default 30) — mean-reversion long
- Sell when RSI crosses above `overbought` (default 70) — mean-reversion short
**Position sizing:** `trade_qty` (default 0.01 BTC) per trade, fixed quantity.
**Parameters:** rsi_length (14), oversold (30), overbought (70), trade_qty (0.01)
**Data:** BTC/USDT.BINANCE 5-minute OHLCV bars. Requires at least `rsi_length + 1` bars before trading.
**No stop-loss or take-profit** — exits only on the opposite RSI signal.""",
code="""...""",
metadata={
"data_feeds": [
@@ -113,12 +128,18 @@ python_write(
"oversold": {"default": 30, "description": "RSI oversold threshold"},
"overbought": {"default": 70, "description": "RSI overbought threshold"},
"trade_qty": {"default": 0.01, "description": "Trade quantity in BTC"}
},
"conda_packages": []
}
}
)
```
### Top-level fields
| Field | Required | Description |
|-------|----------|-------------|
| `description` | yes | One-sentence summary of the strategy |
| `details` | yes | Full markdown description — algorithm, entry/exit logic, parameters, data feeds, position sizing, and any non-obvious implementation choices. Must be detailed enough that another agent could reproduce the code from it alone. |
### Metadata fields
| Field | Required | Description |
@@ -297,8 +318,9 @@ class VolumeBreakout(PandasStrategy):
2. **Write the strategy**:
```
python_write(category="strategy", name="...", description="...", code="...", metadata={...})
python_write(category="strategy", name="...", description="...", details="...", code="...", metadata={...})
```
Always include `details`: a complete markdown description covering algorithm, entry/exit logic, all parameters, data feeds, and position sizing — enough detail for another agent to reproduce the code.
After writing, the system automatically runs the strategy against synthetic data. If validation fails, fix the reported error before proceeding.
3. **Run a backtest** — choose the window to target 100k200k bars at the strategy's resolution (max 5 years):

View File

@@ -20,6 +20,31 @@ export interface MCPToolInfo {
};
}
/**
* Strip the `details` field from all entries in a `_types` workspace store before
* syncing to clients. `details` is a long markdown blob intended for agent consumption
* only and should not be included in the compact workspace state sent to the web client.
*/
function filterTypeStoreState(storeName: string, state: unknown): unknown {
if (!storeName.endsWith('_types') || typeof state !== 'object' || state === null) {
return state;
}
const typed = state as Record<string, unknown>;
if (typeof typed['types'] !== 'object' || typed['types'] === null) {
return state;
}
const filteredTypes: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(typed['types'] as Record<string, unknown>)) {
if (typeof entry === 'object' && entry !== null) {
const { details: _details, ...rest } = entry as Record<string, unknown>;
filteredTypes[key] = rest;
} else {
filteredTypes[key] = entry;
}
}
return { ...typed, types: filteredTypes };
}
/**
* Create a LangChain tool from an MCP tool definition
*/
@@ -57,12 +82,14 @@ export function createMCPToolWrapper(
(toolInfo.name === 'workspace_patch' || toolInfo.name === 'workspace_write') &&
parsed?.success && parsed?.data !== undefined
) {
onWorkspaceMutation((input as any).store_name as string, parsed.data);
const storeName = (input as any).store_name as string;
onWorkspaceMutation(storeName, filterTypeStoreState(storeName, parsed.data));
}
// python_write / python_edit / python_delete / python_revert:
// {"_workspace_sync": {"store": <name>, "data": <state>}}
if (parsed?._workspace_sync?.store && parsed._workspace_sync.data !== undefined) {
onWorkspaceMutation(parsed._workspace_sync.store, parsed._workspace_sync.data);
const storeName = parsed._workspace_sync.store as string;
onWorkspaceMutation(storeName, filterTypeStoreState(storeName, parsed._workspace_sync.data));
}
} catch { /* ignore parse errors */ }
}

View File

@@ -101,17 +101,17 @@ export const DEFAULT_STORES: StoreConfig[] = [
{
name: 'indicator_types',
persistent: true,
initialState: () => ({}),
initialState: () => ({ types: {} }),
},
{
name: 'strategy_types',
persistent: true,
initialState: () => ({}),
initialState: () => ({ types: {} }),
},
{
name: 'research_types',
persistent: true,
initialState: () => ({}),
initialState: () => ({ types: {} }),
},
{
name: 'channelState',

View File

@@ -97,6 +97,15 @@ export class WorkspaceManager {
this.dirtyStores.add(storeName); // persist migrated format immediately
this.logger.info({ store: storeName }, 'Migrated shapes store to wrapped format');
}
// Migrate *_types stores from old flat format { key: {...} } to wrapped { types: { key: {...} } }
if (
(storeName === 'indicator_types' || storeName === 'strategy_types' || storeName === 'research_types') &&
state && typeof state === 'object' && !('types' in (state as object))
) {
migratedState = { types: state };
this.dirtyStores.add(storeName); // persist migrated format immediately
this.logger.info({ store: storeName }, 'Migrated types store to wrapped format');
}
this.registry.setState(storeName, migratedState);
this.logger.debug({ store: storeName }, 'Loaded persistent store');
}