/**
 * The default config tree. Mirrors `schema.ts` exactly. When merging YAML,
 * we deep-merge over this tree so users only need to specify what they want
 * to change.
 */

import type { ResolvedConfig } from "./schema.js";

export const DEFAULT_CONFIG: ResolvedConfig = {
  version: 1,
  viewer: {
    // Per-agent default lives in `templates/config.<agent>.yaml`:
    //   - openclaw → 18799
    //   - hermes   → 18800
    // The fallback here only matters when neither config file exists
    // (early bootstrap, tests, etc.).
    port: 18799,
    bindHost: "127.0.0.1",
    openOnFirstTurn: false,
  },
  bridge: {
    port: 18911,
    mode: "stdio",
  },
  embedding: {
    provider: "local",
    endpoint: "",
    model: "Xenova/all-MiniLM-L6-v2",
    apiKey: "",
    cache: {
      enabled: true,
      maxItems: 20_000,
    },
  },
  llm: {
    provider: "",
    endpoint: "",
    model: "",
    temperature: 0,
    fallbackToHost: true,
    apiKey: "",
    timeoutMs: 45_000,
    maxRetries: 3,
  },
  skillEvolver: {
    // Empty by default — falls back to the shared `llm` settings.
    // Operators set this when they want a stronger model (e.g.
    // claude-sonnet / gpt-5-thinking) for skill crystallisation.
    provider: "",
    endpoint: "",
    model: "",
    apiKey: "",
    temperature: 0,
    timeoutMs: 60_000,
  },
  algorithm: {
    lightweightMemory: {
      enabled: true,
    },
    capture: {
      maxTextChars: 4_000,
      maxToolOutputChars: 2_000,
      embedTraces: true,
      alphaScoring: true,
      // OpenClaw's tool messages don't include explicit "reflection"
      // blocks; without synthesis the alpha scorer sees an empty
      // reflection and forces α = 0 (see `core/capture/alpha-scorer.ts`
      // line 97). That makes reflection-weighted backprop degenerate
      // into pure γ-discount and produces flat V distributions —
      // L2 association + skill crystallization both starve. Enable
      // synth by default so even turns without explicit reflections
      // still contribute useful α values.
      synthReflections: true,
      llmConcurrency: 4,
      // V7 §3.2 batched variant. With "auto" we issue a single LLM call
      // per episode for both reflection synth and α scoring as long as
      // the episode is short enough — this collapses 2N per-step calls
      // (N synth + N α) into 1 batched call. Long episodes (>12 steps)
      // automatically fall back to the per-step path so the prompt
      // never overflows the model's context window. R_human + backprop
      // remain task-end events handled by `core/reward`, unchanged.
      batchMode: "auto",
      batchThreshold: 12,
      // `reflectionContextMode` controls which extra prompt context blocks
      // topic-end reflection receives:
      //   - "none": no TASK CONTEXT and no DOWNSTREAM STEP PREVIEW
      //   - "task": inject TASK CONTEXT only
      //   - "downstream": inject DOWNSTREAM STEP PREVIEW only
      //   - "task_downstream": inject both blocks
      // `longEpisodeReflectMode` controls the fallback used when an episode is
      // too long for batch scoring:
      //   - "per_step_parallel": keep the current parallel per-step path. Each
      //     step is reflected independently, using only the context blocks
      //     enabled by `reflectionContextMode` that are available without
      //     downstream preview.
      //   - "per_step_downstream": still run per-step work in parallel, but
      //     prebuild a bounded DOWNSTREAM STEP PREVIEW for each step (step+1
      //     through step+N, capped by `downstreamStepCount`) and inject it when
      //     `reflectionContextMode` includes "downstream".
      reflectionContextMode: "task_downstream",
      longEpisodeReflectMode: "per_step_downstream",
      downstreamStepCount: 3,
      taskContextMaxChars: 800,
      downstreamContextMaxChars: 1_200,
      downstreamPerStepMaxChars: 400,
      synthOutcomeMaxChars: 600,
    },
    reward: {
      gamma: 0.9,
      tauSoftmax: 0.5,
      decayHalfLifeDays: 30,
      llmScoring: true,
      implicitThreshold: 0.2,
      // 10 minutes was too long for interactive chat — users moved on
      // to the next task before reward ever fired, so no R_human was
      // ever computed and V stayed 0 for every trace. 30 s gives the
      // user a short window to reply ("thanks", "no, try again") that
      // the scorer picks up as explicit feedback; when nothing
      // arrives, the implicit fallback fires promptly so downstream
      // L2/L3/Skill stages aren't starved of signal.
      feedbackWindowSec: 30,
      summaryMaxChars: 2_000,
      llmConcurrency: 2,
      // Default lowered 2→1 to support single-shot CLI patterns
      // (`hermes chat -q "..."`, `openclaw run --once`). With the old
      // floor every CLI single-query episode was abandoned with
      // "对话轮次不足", starving reward → L2 → Skill of any signal.
      // Multi-turn TUI flows still trigger reward as before because
      // they always satisfy the looser bound. Operators wanting the
      // strict pre-2026Q2 behaviour can set 2 in config.yaml.
      minExchangesForCompletion: 1,
      // Lowered 80→40 to match the relaxed exchanges floor. 40 chars
      // is "ok"/"thanks" + a real follow-up clause; below that we
      // still skip as a triviality gate.
      minContentCharsForCompletion: 40,
      toolHeavyRatio: 0.7,
      minAssistantCharsForToolHeavy: 80,
    },
    l2Induction: {
      minSimilarity: 0.65,
      candidateTtlDays: 30,
      minEpisodesForInduction: 1,
      // Lowered from 0.05 → 0.005. Reward backprop V values for typical
      // multi-step turns (5-15 steps) are clustered around 0.02-0.5 even
      // for successful episodes; the old 0.05 floor was throwing away
      // most of the signal before induction could see it. Negative-V
      // traces are still excluded (they'd never count as "with-set"
      // evidence anyway).
      minTraceValue: 0.005,
      useLlm: true,
      traceCharCap: 3_000,
      gainEmaAlpha: 0.4,
      archiveGain: -0.05,
    },
    l3Abstraction: {
      // Lowered from 3 → 2. The original threshold required THREE
      // distinct active policies in the same domain cluster before any
      // world model could form, which in real usage takes weeks to
      // accumulate even for a focused user. Two compatible active
      // policies is the smallest meaningful cluster.
      minPolicies: 1,
      // Lowered from 0.1 → 0.02. With the Bayesian-shrinkage gain
      // formula (see core/memory/l2/gain.ts), a genuinely useful policy
      // that fires on a single-success path now scores around 0.05-0.20
      // (proportional to V_with - 0.5). 0.02 is well below that floor
      // but still cleanly rejects net-neutral noise.
      minPolicyGain: 0.02,
      minPolicySupport: 1,
      // Lowered from 0.6 → 0.3 so the typical 2-3 active policies in
      // an early-life install can still cluster into a world model;
      // strict 0.6 starved L3 in real usage.
      clusterMinSimilarity: 0.3,
      policyCharCap: 800,
      traceCharCap: 500,
      traceEvidencePerPolicy: 1,
      useLlm: true,
      // Lowered from 1 → 0 so abstraction can run as soon as the
      // ingredients show up, not on a per-day cadence.
      cooldownDays: 0,
      confidenceDelta: 0.05,
      minConfidenceForRetrieval: 0.2,
    },
    skill: {
      // Lowered from 2 → 1: a single supporting episode is enough to
      // attempt skill crystallization. Quality is still gated by
      // minGain + candidate trials below.
      minSupport: 1,
      // Lowered from 0.1 → 0.02. Same rationale as l3.minPolicyGain:
      // the new shrinkage-anchored gain formula gives positive scores
      // proportional to V_with − 0.5, so 0.1 was unreachable for any
      // policy that didn't have an explicit failure-cohort contrast.
      // 0.02 is enough to filter neutral-noise policies while still
      // letting genuinely-useful patterns crystallize.
      minGain: 0.02,
      // Lowered from 5 → 1. Demanding multiple trials in `candidate`
      // before a skill can graduate meant skills rarely promoted in
      // real usage; 1 lets the candidate→active transition happen
      // immediately on first successful invocation.
      candidateTrials: 1,
      // Lowered from 6 hours → 0: no cooldown, skills can re-evolve
      // as soon as new evidence arrives.
      cooldownMs: 0,
      traceCharCap: 500,
      evidenceLimit: 6,
      useLlm: true,
      etaDelta: 0.1,
      archiveEta: 0.1,
      minEtaForRetrieval: 0.1,
    },
    feedback: {
      failureThreshold: 3,
      failureWindow: 5,
      valueDelta: 0.5,
      minLowValueThreshold: 0.01,
      useLlm: true,
      attachToPolicy: true,
      cooldownMs: 60_000,
      traceCharCap: 500,
      evidenceLimit: 4,
    },
    session: {
      followUpMode: "merge_follow_ups",
      mergeMaxGapMs: 2 * 60 * 60 * 1000,
    },
    retrieval: {
      tier1TopK: 3,
      tier2TopK: 5,
      tier3TopK: 2,
      candidatePoolFactor: 4,
      weightCosine: 0.6,
      weightPriority: 0.4,
      mmrLambda: 0.7,
      includeLowValue: false,
      rrfConstant: 60,
      minSkillEta: 0.1,
      // Lowered from 0.35 → 0.25 so partial-match traces still surface
      // for users with smaller corpora.
      minTraceSim: 0.25,
      episodeGoalMinSim: 0.45,
      tagFilter: "auto",
      keywordTopK: 20,
      // Lowered from 0.4 → 0.2 with the 2026 ranker overhaul: the new
      // base relevance already uses channel rank as a first-class
      // signal, so the old 0.4 floor was over-pruning keyword hits
      // with modest V·decay.
      relativeThresholdFloor: 0.2,
      skillEtaBlend: 0.15,
      smartSeed: true,
      smartSeedRatio: 0.7,
      multiChannelBypass: true,
      skillInjectionMode: "summary",
      skillSummaryChars: 200,
      llmFilterEnabled: true,
      // Tighter than the legacy default (5) so the LLM filter has a
      // small budget; combined with the richer prompt (v3) this keeps
      // packets concise without over-dropping.
      llmFilterMaxKeep: 4,
      // Set to 2: skip the LLM precision pass when there's only one
      // candidate (no point ranking a single item). Anything with 2+
      // candidates still goes through the filter to drop off-topic
      // hits before injection.
      llmFilterMinCandidates: 2,
      llmFilterCandidateBodyChars: 500,
      // Default 0 — no time-window bound, keeping the legacy
      // brute-force scan behaviour for fresh installs that haven't
      // grown past the threshold where the bound starts paying off.
      // Operators with >50K traces are expected to flip this on (we
      // suggest 86_400_000 = 24h, or 2_592_000_000 = 30 days).
      vectorScanMaxAgeMs: 0,
    },
  },
  hub: {
    enabled: false,
    role: "client",
    port: 18912,
    address: "",
    teamName: "",
    teamToken: "",
    userToken: "",
    nickname: "",
  },
  telemetry: { enabled: true },
  logging: {
    level: "info",
    detailedView: false,
    console: { enabled: true, pretty: true, channels: ["*"] },
    file: {
      enabled: true,
      format: "json",
      rotate: { maxSizeMb: 50, maxFiles: 14, gzip: true },
      retentionDays: 30,
    },
    audit: {
      enabled: true,
      rotate: { monthly: true, gzip: true },
    },
    llmLog: { enabled: true, redactPrompts: false, redactCompletions: false },
    perfLog: { enabled: true, sampleRate: 1.0 },
    eventsLog: { enabled: true },
    redact: {
      extraKeys: ["api_key", "secret", "token", "password", "authorization"],
      extraPatterns: [],
    },
    channels: {},
  },
};

/**
 * Set of dotted-path field names whose values must never be sent to the
 * viewer or any non-localhost surface. Used by `server/routes/config.ts`.
 */
export const SECRET_FIELD_PATHS: readonly string[] = Object.freeze([
  "embedding.apiKey",
  "llm.apiKey",
  "skillEvolver.apiKey",
  "hub.teamToken",
  "hub.userToken",
]);
