Skip to content
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Some tools are explicitly **never** sent to GENERAL unless the goal matches an i

- **System prompt is built at the start of every cycle** with: portfolio, positions, state summary, lessons (3-tier cap — pinned / role / recent), performance summary, decision summary, optional signal weights summary (SCREENER only), `lessons_for_prompt`.
- **Messages get pushed in OpenAI format** unless the provider rejects the `system` role — then we switch to `providerMode = "user_embedded"` and embed the system prompt inside a user message.
- **Per-step retry**: 3 attempts on transient errors. If the response is 502/503/529 the second attempt swaps to fallback model `stepfun/step-3.5-flash:free`. If `tool_choice=required` is rejected or the provider is in thinking mode, retry with `tool_choice=auto` / omitted.
- **Per-step retry**: 3 attempts on transient errors. If the response is 502/503/529 the second attempt swaps to fallback model `claude-haiku-4-5-20251001` (`agent.js:205`). If `tool_choice=required` is rejected or the provider is in thinking mode, retry with `tool_choice=auto` / omitted.
- **Tool args are JSON-validated** and run through `jsonrepair` if malformed; unrepairable args result in `blocked: true` returned to the LLM.
- **No-tool-loop guard**: if `mustUseRealTool` is true (action intents, `MUTATING_TOOL_INTENTS` regex) and the LLM responds with text only, we inject a reminder; second failure returns an error message.
- **Once-per-session tool locks**:
Expand Down Expand Up @@ -293,9 +293,11 @@ All persistent files are loaded/saved on each call — no in-memory caching laye
| `risk` | `maxPositions`, `maxDeployAmount` | 3, 50 |
| `screening` | `excludeHighSupplyConcentration`, `minFeeActiveTvlRatio`, `minTvl`, `maxTvl`, `minVolume`, `minOrganic`, `minQuoteOrganic`, `minHolders`, `minMcap`, `maxMcap`, `minBinStep`, `maxBinStep`, `timeframe`, `category`, `minTokenFeesSol`, `useDiscordSignals`, `discordSignalMode`, `avoidPvpSymbols`, `blockPvpSymbols`, `maxBotHoldersPct`, `maxTop10Pct`, `allowedLaunchpads`, `blockedLaunchpads`, `minTokenAgeHours`, `maxTokenAgeHours` | see `user-config.example.json` |
| `management` | `minClaimAmount`, `autoSwapAfterClaim`, `outOfRangeBinsToClose`, `outOfRangeWaitMinutes`, `oorCooldownTriggerCount`, `oorCooldownHours`, `repeatDeployCooldownEnabled`, `repeatDeployCooldownTriggerCount`, `repeatDeployCooldownHours`, `repeatDeployCooldownScope`, `repeatDeployCooldownMinFeeEarnedPct`, `minVolumeToRebalance`, `stopLossPct`, `takeProfitPct`, `minFeePerTvl24h`, `minAgeBeforeYieldCheck`, `minSolToOpen`, `deployAmountSol`, `gasReserve`, `positionSizePct`, `trailingTakeProfit`, `trailingTriggerPct`, `trailingDropPct`, `pnlSanityMaxDiffPct`, `solMode` | 5, false, 10, 30, 3, 12, true, 3, 12, "token", 0, 1000, -50, 5, 7, 60, 0.55, 0.5, 0.2, 0.35, true, 3, 1.5, 5, false |
| `strategy` | `strategy`, `minBinsBelow`, `maxBinsBelow`, `defaultBinsBelow` | bid_ask, 35, 69, 69 |
| `strategy` | `strategy`, `minBinsBelow`, `maxBinsBelow`, `defaultBinsBelow` | auto, 35, 69, 69 |

> `strategy: "auto"` = volatility-driven LP shape (`bid_ask` if volatility >= 4, else `spot`), resolved in `tools/dlmm.js#deployPosition`. Pin to `"bid_ask"`/`"spot"` to override.
| `schedule` | `managementIntervalMin`, `screeningIntervalMin`, `healthCheckIntervalMin` | 10, 30, 60 |
| `llm` | `temperature`, `maxTokens`, `maxSteps`, `managementModel`, `screeningModel`, `generalModel` | 0.373, 4096, 20, healer-alpha, hunter-alpha, healer-alpha |
| `llm` | `temperature`, `maxTokens`, `maxSteps`, `managementModel`, `screeningModel`, `generalModel` | 0.373, 4096, 20, claude-opus-4-7, claude-opus-4-7, claude-opus-4-7 |
| `darwin` | `enabled`, `windowDays`, `recalcEvery`, `boostFactor`, `decayFactor`, `weightFloor`, `weightCeiling`, `minSamples` | true, 60, 5, 1.05, 0.95, 0.3, 2.5, 10 |
| `tokens` | `SOL`, `USDC`, `USDT` (mint addresses) | canonical |
| `hiveMind` | `url`, `apiKey`, `agentId`, `pullMode` | `https://api.agentmeridian.xyz`, built-in key, auto-generated, "auto" |
Expand Down
21 changes: 17 additions & 4 deletions agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,12 @@ import { getDecisionSummary } from "./decision-log.js";
// Supports OpenRouter (default) or any OpenAI-compatible local server (e.g. LM Studio)
// To use LM Studio: set LLM_BASE_URL=http://localhost:1234/v1 and LLM_API_KEY=lm-studio in .env
const client = new OpenAI({
baseURL: process.env.LLM_BASE_URL || "https://openrouter.ai/api/v1",
baseURL: process.env.LLM_BASE_URL || "https://api.anthropic.com/v1",
apiKey: process.env.LLM_API_KEY || process.env.OPENROUTER_API_KEY,
timeout: 5 * 60 * 1000,
});

const DEFAULT_MODEL = process.env.LLM_MODEL || "openrouter/healer-alpha";
const DEFAULT_MODEL = process.env.LLM_MODEL || "claude-opus-4-7";

const MUTATING_TOOL_INTENTS = /\b(deploy|open position|add liquidity|lp into|invest in|close|exit|withdraw|remove liquidity|claim|harvest|collect|swap|convert|sell|exchange|block|unblock|blacklist|add smart wallet|remove smart wallet|add wallet|remove wallet|pin|unpin|clear lesson|add lesson|set active strategy|remove strategy|add strategy|set |change |update |self.?update|pull latest|git pull|update yourself)\b/i;
const LIVE_DATA_TOOL_INTENTS = /\b(balance|wallet|position|portfolio|pnl|yield|range|show positions|open positions|screen|candidate|find pool|search|research|analyze|check pool|token holders|narrative|study top|top lpers?|lp behavior|who.?s lping|performance|history|stats|report|list smart wallets|list blacklist|list blocked deployers|list lessons)\b/i;
Expand Down Expand Up @@ -147,6 +147,11 @@ function isThinkingModeToolChoiceError(error) {
return /thinking mode does not support/i.test(message) && /tool_choice/i.test(message);
}

function isTemperatureUnsupportedError(error) {
const message = String(error?.message || error?.error?.message || error || "");
return /temperature/i.test(message) && /(deprecated|unsupported|not supported)/i.test(message);
}

/**
* Core ReAct agent loop.
*
Expand Down Expand Up @@ -186,6 +191,8 @@ export async function agentLoop(goal, maxSteps = config.llm.maxSteps, sessionHis
let noToolRetryCount = 0;
// Stays true for the whole run once a thinking-mode provider rejects tool_choice
let omitToolChoice = false;
// Stays true once the model rejects the temperature param (deprecated on newer Opus)
let omitTemperature = false;

let emptyStreak = 0;
for (let step = 0; step < maxSteps; step++) {
Expand All @@ -195,7 +202,7 @@ export async function agentLoop(goal, maxSteps = config.llm.maxSteps, sessionHis
const activeModel = model || DEFAULT_MODEL;

// Retry up to 3 times on transient provider errors (502, 503, 529)
const FALLBACK_MODEL = "stepfun/step-3.5-flash:free";
const FALLBACK_MODEL = "claude-haiku-4-5-20251001";
let response;
let usedModel = activeModel;
// Force a tool call on step 0 for action intents — prevents the model from inventing deploy/close outcomes
Expand All @@ -208,9 +215,9 @@ export async function agentLoop(goal, maxSteps = config.llm.maxSteps, sessionHis
model: usedModel,
messages,
tools: getToolsForRole(agentType, goal),
temperature: config.llm.temperature,
max_tokens: maxOutputTokens ?? config.llm.maxTokens,
};
if (!omitTemperature && config.llm.temperature != null) reqParams.temperature = config.llm.temperature;
if (!omitToolChoice) reqParams.tool_choice = toolChoice;
response = await client.chat.completions.create(reqParams);
} catch (error) {
Expand All @@ -233,6 +240,12 @@ export async function agentLoop(goal, maxSteps = config.llm.maxSteps, sessionHis
attempt -= 1;
continue;
}
if (!omitTemperature && isTemperatureUnsupportedError(error)) {
omitTemperature = true;
log("agent", "Model rejected temperature param — retrying without it");
attempt -= 1;
continue;
}
throw error;
}
if (response.choices?.length) break;
Expand Down
8 changes: 4 additions & 4 deletions config.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const config = {

// ─── Strategy Mapping ───────────────────
strategy: {
strategy: u.strategy ?? "bid_ask",
strategy: u.strategy ?? "auto", // "auto" = volatility-driven (bid_ask if vol>=4, else spot); or pin "bid_ask"/"spot"
minBinsBelow: strategyMinBinsBelow,
maxBinsBelow: strategyMaxBinsBelow,
defaultBinsBelow: strategyDefaultBinsBelow,
Expand All @@ -141,9 +141,9 @@ export const config = {
temperature: u.temperature ?? 0.373,
maxTokens: u.maxTokens ?? 4096,
maxSteps: u.maxSteps ?? 20,
managementModel: u.managementModel ?? process.env.LLM_MODEL ?? "openrouter/healer-alpha",
screeningModel: u.screeningModel ?? process.env.LLM_MODEL ?? "openrouter/hunter-alpha",
generalModel: u.generalModel ?? process.env.LLM_MODEL ?? "openrouter/healer-alpha",
managementModel: u.managementModel ?? process.env.LLM_MODEL ?? "claude-opus-4-7",
screeningModel: u.screeningModel ?? process.env.LLM_MODEL ?? "claude-opus-4-7",
generalModel: u.generalModel ?? process.env.LLM_MODEL ?? "claude-opus-4-7",
},

// ─── Darwinian Signal Weighting ───────
Expand Down
39 changes: 36 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,8 +441,11 @@ export async function runScreeningCycle({ silent = false } = {}) {

// Load active strategy
const activeStrategy = getActiveStrategy();
const deployStrategy = config.strategy.strategy;
const strategyBlock = `DEPLOY STRATEGY: ${deployStrategy} (from config) | bins_above: 0 (FIXED — never change) | deposit: SOL only (amount_y, amount_x=0)`
const configStrategy = config.strategy.strategy;
const shapeBlock = configStrategy === "auto"
? `LP shape: volatility-driven (see STEPS) — 'bid_ask' if volatility >= 4, else 'spot'`
: `DEPLOY STRATEGY: ${configStrategy} (from config — use exactly, do not change by volatility)`;
const strategyBlock = `${shapeBlock} | bins_above: ${activeStrategy?.range?.bins_above ?? 0} (FIXED — never change) | deposit: SOL only (amount_y, amount_x=0)`
+ (activeStrategy ? `\nSTRATEGY CONTEXT: ${activeStrategy.name} — entry: ${activeStrategy.entry?.condition || "n/a"} | exit: ${activeStrategy.exit?.notes || "n/a"} | best for: ${activeStrategy.best_for}` : "");

// Fetch top candidates, then recon each sequentially with a small delay to avoid 429s
Expand Down Expand Up @@ -606,6 +609,7 @@ STEPS:
1. Decide if any candidate is actually worth deploying. One surviving candidate is not automatically good enough.
2. Pick the best candidate based on narrative quality, smart wallets, and pool metrics.
3. Call deploy_position (active_bin is pre-fetched above — no need to call get_active_bin).
strategy = "bid_ask" if candidate volatility >= ${VOLATILITY_STRATEGY_THRESHOLD}, else "spot" (volatility-driven, overrides the active strategy's shape).
bins_below = round(${config.strategy.minBinsBelow} + (candidate volatility/5)*(${config.strategy.maxBinsBelow - config.strategy.minBinsBelow})) clamped to [${config.strategy.minBinsBelow},${config.strategy.maxBinsBelow}].
pass deploy_position.volatility = the candidate volatility value.
For single-side SOL deploys, do not invent upside:
Expand Down Expand Up @@ -903,6 +907,24 @@ function getDeterministicCloseRule(position, managementConfig) {
) {
return { action: "CLOSE", rule: 4, reason: "OOR" };
}
// Downside protection — symmetric to rules 3 & 4. When price DUMPS below the
// whole range the position is 100% in the falling base token, earns zero fees,
// and bleeds until the (loose) stop loss. Cut it like an upside breakout.
if (
position.active_bin != null &&
position.lower_bin != null &&
position.active_bin < position.lower_bin - managementConfig.outOfRangeBinsToClose
) {
return { action: "CLOSE", rule: 6, reason: "dumped far below range" };
}
if (
position.active_bin != null &&
position.lower_bin != null &&
position.active_bin < position.lower_bin &&
(position.minutes_out_of_range ?? 0) >= managementConfig.outOfRangeWaitMinutes
) {
return { action: "CLOSE", rule: 7, reason: "OOR below" };
}
if (
position.fee_per_tvl_24h != null &&
position.fee_per_tvl_24h < managementConfig.minFeePerTvl24h &&
Expand Down Expand Up @@ -1318,7 +1340,7 @@ async function deployLatestCandidate(index) {
const result = await executeTool("deploy_position", {
pool_address: candidate.pool,
amount_y: deployAmount,
strategy: config.strategy.strategy,
strategy: computeStrategy(candidate.volatility),
bins_below: binsBelow,
bins_above: 0,
pool_name: candidate.name,
Expand Down Expand Up @@ -1681,6 +1703,17 @@ function computeBinsBelow(volatility) {
return Math.max(lo, Math.min(hi, Math.round(lo + (parsedVolatility / 5) * (hi - lo))));
}

// Volatility-driven LP shape: bid_ask for high volatility (>= threshold), spot for low.
// Single source of truth for both the programmatic deploy path and the screener prompt.
const VOLATILITY_STRATEGY_THRESHOLD = 4;
function computeStrategy(volatility) {
const parsedVolatility = Number(volatility);
if (!Number.isFinite(parsedVolatility) || parsedVolatility <= 0) {
throw new Error(`Invalid volatility ${volatility ?? "unknown"} — refusing volatility-scaled deploy.`);
}
return parsedVolatility >= VOLATILITY_STRATEGY_THRESHOLD ? "bid_ask" : "spot";
}

// Register restarter — when update_config changes intervals, running cron jobs get replaced
registerCronRestarter(() => { if (cronStarted) startCronJobs(); });

Expand Down
12 changes: 10 additions & 2 deletions lessons.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export async function recordPerformance(perf) {
*/
function derivLesson(perf) {
const tags = [];
const closeReasonText = String(perf.close_reason || "").toLowerCase();
const dumpedBelow =
closeReasonText.includes("below range") ||
closeReasonText.includes("dumped") ||
closeReasonText.includes("oor below");
const feeYieldPct = perf.initial_value_usd > 0
? ((perf.fees_earned_usd || 0) / perf.initial_value_usd) * 100
: 0;
Expand Down Expand Up @@ -253,7 +258,10 @@ function derivLesson(perf) {
let rule = "";

if (outcome === "good" || outcome === "bad") {
if (perf.range_efficiency < 30 && outcome === "bad") {
if (outcome === "bad" && dumpedBelow) {
rule = `AVOID: ${perf.pool_name}-type pools (volatility=${perf.volatility}, bin_step=${perf.bin_step}, organic=${perf.organic_score}) — price DUMPED through the entire entry range, bagging the falling base token at PnL ${perf.pnl_pct}%. Single-side SOL entry caught a falling knife. Require trend/momentum confirmation before deploy, or downsize for this volatility tier.`;
tags.push("downside_dump", "oor", perf.strategy, `volatility_${Math.round(perf.volatility)}`);
} else if (perf.range_efficiency < 30 && outcome === "bad") {
rule = `AVOID: ${perf.pool_name}-type pools (volatility=${perf.volatility}, bin_step=${perf.bin_step}) with strategy="${perf.strategy}" — went OOR ${100 - perf.range_efficiency}% of the time. Consider wider bin_range or bid_ask strategy.`;
tags.push("oor", perf.strategy, `volatility_${Math.round(perf.volatility)}`);
} else if (perf.range_efficiency > 80 && outcome === "good") {
Expand All @@ -274,14 +282,14 @@ function derivLesson(perf) {

if (!rule) return null;

const closeReasonText = String(perf.close_reason || "").toLowerCase();
const positiveEvidence =
feeYieldPct >= 1 ||
(perf.fees_earned_usd || 0) >= 3 ||
perf.pnl_pct >= 3;
const negativeEvidence =
perf.pnl_pct <= -5 ||
perf.range_efficiency <= 30 ||
dumpedBelow ||
closeReasonText.includes("out of range") ||
closeReasonText.includes("oor") ||
closeReasonText.includes("low yield") ||
Expand Down
13 changes: 12 additions & 1 deletion tools/agent-meridian.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ function isRetryableStatus(status) {
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
}

function isTimeoutError(error) {
// AbortError from fetchWithTimeout has no HTTP status; treat per-attempt
// timeouts as retryable so the maxElapsedMs budget is actually used.
const name = error?.name || "";
const message = String(error?.message || "");
return name === "AbortError"
|| name === "TimeoutError"
|| /aborted|timed? ?out/i.test(message);
}

function retryDelayMs(error, attempt) {
const retryAfter = Number(error?.retryAfter);
if (Number.isFinite(retryAfter) && retryAfter > 0) {
Expand Down Expand Up @@ -96,7 +106,8 @@ export async function agentMeridianJson(pathname, options = {}) {
} catch (error) {
lastError = error;
const status = Number(error?.status || 0);
if (!isRetryableStatus(status) || attempt >= maxAttempts - 1) {
const retryable = isRetryableStatus(status) || isTimeoutError(error);
if (!retryable || attempt >= maxAttempts - 1) {
throw error;
}
const waitMs = Math.min(retryDelayMs(error, attempt), Math.max(0, remainingMs - 1));
Expand Down
6 changes: 3 additions & 3 deletions tools/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Only call this if you need the current price to calculate a specific bin range (

PRIORITY ORDER for strategy and bins:
1. User explicitly specifies → always follow exactly (user override is absolute)
2. No user spec → use the configured strategy from config.strategy.strategy and choose bins based on volatility
2. No user spec → if config.strategy.strategy is "auto", choose shape by volatility ('bid_ask' if volatility >= 4, else 'spot'); otherwise use the configured strategy from config.strategy.strategy. Either way, choose bins based on volatility.

HARD RULES:
- Never use 'curve'.
Expand All @@ -140,7 +140,7 @@ HARD RULES:
use bins_below only, keep bins_above=0, and the upper bin will be pinned to the current active bin.

Guidelines (only when user hasn't specified):
- Strategy: omit the strategy field — the system will use the configured default from config.strategy.strategy
- Strategy: omit the strategy field — the system resolves it from config.strategy.strategy (volatility-driven when set to "auto": 'bid_ask' if volatility >= 4, else 'spot')
- Bins: choose from configured minBinsBelow/maxBinsBelow by positive volatility. The hard lower floor is 35 bins.
- Deposit: single-sided SOL only: set amount_y/amount_sol, keep amount_x=0.

Expand All @@ -167,7 +167,7 @@ WARNING: This executes a real on-chain transaction. Check DRY_RUN mode.`,
strategy: {
type: "string",
enum: ["bid_ask", "spot"],
description: "DLMM strategy type. If user specifies, use exactly what they said. Otherwise omit — the system default from config.strategy.strategy will be used automatically."
description: "DLMM strategy type. If user specifies, use exactly what they said. Otherwise omit — the system resolves config.strategy.strategy (volatility-driven when 'auto': 'bid_ask' if volatility >= 4, else 'spot')."
},
bins_below: {
type: "number",
Expand Down
Loading