diff --git a/apps/memos-local-plugin/core/config/defaults-pr.ts b/apps/memos-local-plugin/core/config/defaults-pr.ts new file mode 100644 index 000000000..52ba2a430 --- /dev/null +++ b/apps/memos-local-plugin/core/config/defaults-pr.ts @@ -0,0 +1,371 @@ +/** + * 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"; + +const FIXED_VIEWER_PORTS: Readonly> = Object.freeze({ + openclaw: 18799, + hermes: 18800, +}); + +/** Runtime adapters own these well-known ports even for legacy YAML files. */ +export function effectiveViewerPort(agent?: string): number | undefined { + return agent ? FIXED_VIEWER_PORTS[agent] : undefined; +} + +export const DEFAULT_CONFIG: ResolvedConfig = { + version: 1, + viewer: { + // Per-agent default lives in `templates/config..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: "", + providerIgnore: [], + providerOrder: [], + openRouter: false, + cache: { + enabled: true, + maxItems: 20_000, + }, + }, + llm: { + provider: "", + endpoint: "", + model: "", + temperature: 0, + fallbackToHost: true, + apiKey: "", + timeoutMs: 45_000, + maxRetries: 3, + providerIgnore: [], + providerOrder: [], + openRouter: false, + maxTokens: 1024, + headers: {}, + }, + l3Llm: { + // Empty by default — falls back to the shared `llm` settings. + // Operators set this when they want a stronger model for L3 + // abstraction. L3 runs off the turn-response path, so a slower + // but more reliable model improves world-model quality without + // affecting companion latency. + provider: "", + endpoint: "", + model: "", + apiKey: "", + temperature: 0, + timeoutMs: 60_000, + providerIgnore: [], + providerOrder: [], + openRouter: false, + maxTokens: 1024, + }, + 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, + providerIgnore: [], + providerOrder: [], + openRouter: false, + maxTokens: 1024, + }, + storage: { + ftsTokenizer: "trigram", + }, + 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, + // Bound topic-end reflect work so dirty startup recovery cannot replay + // a huge historical episode into thousands of paid LLM calls. + maxReflectLlmCalls: 128, + // Recovered episodes are reconstructed from persisted traces; replay + // orphans are usually matching drift, so do not insert duplicate rows. + maxRecoveryOrphanInserts: 0, + // 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, + maxTurnsPerEpisode: 30, + classifyTimeoutMs: 5000, + bgLlmConcurrency: 2, + }, + 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, + timezone: "UTC", + 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", + "l3Llm.apiKey", + "hub.teamToken", + "hub.userToken", +]); diff --git a/apps/memos-local-plugin/core/config/index-pr.ts b/apps/memos-local-plugin/core/config/index-pr.ts new file mode 100644 index 000000000..64370f5ad --- /dev/null +++ b/apps/memos-local-plugin/core/config/index-pr.ts @@ -0,0 +1,210 @@ +/** + * Public entry point for `core/config/`. + * + * loadConfig(home) → reads home.configFile, deep-merges over defaults, + * validates with the schema, returns a frozen object. + * resolveConfig(raw)→ same merge + validate, but starting from an arbitrary + * raw object (used by adapters that build config in code). + * + * Anything that needs to *write* config goes through `writer.ts`. + */ + +import { promises as fs } from "node:fs"; + +import { Type } from "@sinclair/typebox"; +import { Value, type ValueError } from "@sinclair/typebox/value"; + +import { MemosError } from "../../agent-contract/errors.js"; +import type { ResolvedHome } from "./paths.js"; +import { resolveHome } from "./paths.js"; +import { ConfigSchema, type ResolvedConfig } from "./schema.js"; +import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { migrateHermesViewerPort } from "./migrations.js"; +import { parseYaml } from "./yaml.js"; + +export type { ResolvedConfig } from "./schema.js"; +export type { ResolvedHome } from "./paths.js"; +export { resolveHome } from "./paths.js"; +export { DEFAULT_CONFIG, SECRET_FIELD_PATHS } from "./defaults.js"; + +export interface LoadConfigResult { + config: ResolvedConfig; + /** Whether the config file existed; when false, defaults are returned. */ + fromDisk: boolean; + /** Validation warnings (extra unknown keys, removed fields, …). */ + warnings: string[]; + /** Path that was read (or the path we *would* read on next save). */ + source: string; +} + +export async function loadConfig(home: ResolvedHome, agent?: string): Promise { + let raw: unknown = {}; + let fromDisk = false; + const warnings: string[] = []; + + if (agent === "hermes") await migrateHermesViewerPort(home); + + try { + const text = await fs.readFile(home.configFile, "utf8"); + raw = parseYaml(text, home.configFile); + fromDisk = true; + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === "ENOENT") { + warnings.push( + `config file not found at ${home.configFile}; using defaults. ` + + `To fix: set MEMOS_HOME or MEMOS_CONFIG_FILE env var, or use --home CLI flag. ` + + `See: https://github.com/MemTensor/MemOS/tree/main/apps/memos-local-plugin#configuration` + ); + } else if (MemosError.is(err)) { + throw err; + } else { + throw new MemosError("config_invalid", `cannot read ${home.configFile}: ${e.message}`, { + source: home.configFile, + }); + } + } + + const config = resolveConfig(raw, warnings, agent); + return { config, fromDisk, warnings, source: home.configFile }; +} + +/** + * Merge an arbitrary raw object over `DEFAULT_CONFIG` and validate. Used in + * tests and by `writer.ts`. `warnings` is mutated in place if provided. + */ +export function resolveConfig(raw: unknown, warnings?: string[], agent?: string): ResolvedConfig { + const cleaned = pruneUnknown(raw, DEFAULT_CONFIG, "", warnings); + const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); + stripUnsupportedEmbeddingDimensions(merged); + const viewerPort = effectiveViewerPort(agent); + if (viewerPort !== undefined && isPlainObject(merged.viewer)) { + merged.viewer.port = viewerPort; + } + + // Apply Typebox defaults + coerce types as much as possible. + const completed = Value.Default(ConfigSchema, merged) as ResolvedConfig; + const errors = Array.from(Value.Errors(ConfigSchema, completed)); + if (errors.length > 0) { + const head = errors.slice(0, 5).map(formatErr).join("; "); + throw new MemosError("config_invalid", `config failed schema validation: ${head}`, { + errorCount: errors.length, + first: errors.slice(0, 5).map((e) => ({ path: e.path, message: e.message })), + }); + } + + try { + new Intl.DateTimeFormat("en-US", { timeZone: completed.logging.timezone }).format(0); + } catch { + throw new MemosError("config_invalid", `invalid logging.timezone: ${completed.logging.timezone}`); + } + + return Object.freeze(completed) as ResolvedConfig; +} + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function formatErr(e: ValueError): string { + return `${e.path || ""}: ${e.message}`; +} + +/** + * Recursively deep-merge `b` over `a`. Plain objects merge; arrays + scalars + * get replaced wholesale. (We don't try to be clever about array merging — + * surprises everyone.) + * + * Non-object tolerance at object-valued slots: when the default (`a[k]`) is + * a plain object but the user value (`b[k]`) is null, undefined, empty + * string, or any other non-object scalar, we keep the default object tree + * intact. This handles half-written / legacy configs like: + * + * skillEvolver: # bare null + * skillEvolver: "" # empty scalar + * + * Without this tolerance, Typebox's schema check explodes with + * "Expected object" at load time and the whole daemon fails to start. + * The writer will later re-hydrate these keys into proper maps when the + * user patches nested fields via the Settings page. + */ +function deepMerge>(a: T, b: Record): T { + const out: Record = { ...a }; + for (const [k, v] of Object.entries(b ?? {})) { + const av = out[k]; + if (isPlainObject(av) && isPlainObject(v)) { + out[k] = deepMerge(av as Record, v as Record); + } else if (isPlainObject(av) && !isPlainObject(v)) { + // Default is an object-valued slot; user put a scalar (null, "", + // number, …). Ignore the scalar and keep the default tree so + // schema validation passes. A warning was already emitted upstream + // by `pruneUnknown` callers that care. + } else { + out[k] = v; + } + } + return out as T; +} + +/** + * Walk `raw` against `defaults` shape; record warnings for any keys that + * have no counterpart (likely from a removed schema). We pass them through + * anyway so older configs keep working. + */ +function pruneUnknown( + raw: unknown, + defaults: unknown, + prefix: string, + warnings?: string[], +): Record { + if (!isPlainObject(raw)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + const path = prefix ? `${prefix}.${k}` : k; + if (isPlainObject(defaults) && !(k in (defaults as Record))) { + warnings?.push(`unknown config key '${path}' (kept as-is for forward compatibility)`); + out[k] = v; + continue; + } + if (isPlainObject(v) && isPlainObject((defaults as Record)[k])) { + if (Object.keys((defaults as Record)[k] as Record).length === 0) { + // Empty-object default slot = free-form map (e.g. llm.headers, a + // Record). Keep the whole user object as-is; recursing + // would warn on every user key. + out[k] = v; + continue; + } + out[k] = pruneUnknown(v, (defaults as Record)[k], path, warnings); + } else { + out[k] = v; + } + } + return out; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function stripUnsupportedEmbeddingDimensions(merged: Record): void { + const embedding = merged.embedding; + if (!isPlainObject(embedding)) return; + // Vector dimensionality is derived from the model/provider at runtime, + // not a user-facing config field. Ignore legacy/manual YAML values so + // a stale `dimensions: 384` cannot truncate bge-m3's 1024-dim vectors. + delete embedding.dimensions; +} + +/** + * One-shot helper for adapters that just want a fully resolved config for an + * agent (handles both `MEMOS_HOME` overrides and the per-agent default). + */ +export async function loadConfigForAgent( + agent: string, + defaultHome?: string, +): Promise<{ home: ResolvedHome } & LoadConfigResult> { + const home = resolveHome(agent, defaultHome); + const result = await loadConfig(home, agent); + return { home, ...result }; +} + +// Re-export for external value-level use +export { Type }; diff --git a/apps/memos-local-plugin/core/config/index.ts b/apps/memos-local-plugin/core/config/index.ts index 6d529d960..53c9ab378 100644 --- a/apps/memos-local-plugin/core/config/index.ts +++ b/apps/memos-local-plugin/core/config/index.ts @@ -18,7 +18,7 @@ import { MemosError } from "../../agent-contract/errors.js"; import type { ResolvedHome } from "./paths.js"; import { resolveHome } from "./paths.js"; import { ConfigSchema, type ResolvedConfig } from "./schema.js"; -import { DEFAULT_CONFIG, effectiveViewerPort } from "./defaults.js"; +import { DEFAULT_CONFIG, SECRET_FIELD_PATHS, effectiveViewerPort } from "./defaults.js"; import { migrateHermesViewerPort } from "./migrations.js"; import { parseYaml } from "./yaml.js"; @@ -72,9 +72,40 @@ export async function loadConfig(home: ResolvedHome, agent?: string): Promise use process.env[NAME]. Only allowlisted + // names are expanded (`^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$`); + // anything else emits a warning and is left untouched. + // 2. Value is the mask sentinel `__memos_secret__` or empty string + // -> use the env var inferred from the field path + // (llm.apiKey -> LLM_API_KEY, then OPENCODE_GO_API_KEY / + // OPENCODE_ZEN_API_KEY fallbacks for the opencode-go/zen + // providers). The generic fallbacks only apply to LLM-class + // fields — embedding.apiKey is never handed an LLM provider's key. + // 3. Otherwise leave the value untouched. + // + // The mask itself is never used as a credential, and the on-disk write + // stays masked (security preserved); this is read-side only. + resolveSecretEnv(cleaned, warnings); const merged = deepMerge(DEFAULT_CONFIG as Record, cleaned); stripUnsupportedEmbeddingDimensions(merged); const viewerPort = effectiveViewerPort(agent); @@ -104,6 +135,64 @@ export function resolveConfig(raw: unknown, warnings?: string[], agent?: string) // ─── helpers ──────────────────────────────────────────────────────────────── +/** Env var names accepted in `${NAME}` config references. */ +const ENV_REF_ALLOWLIST = /^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$/; + +/** + * Replace masked / empty / `${VAR}` secret leaves in `cleaned` (a freshly + * built, non-shared object — see `pruneUnknown`) with values from the + * environment. The caller's raw config object is never written to. + */ +function resolveSecretEnv(cleaned: Record, warnings?: string[]): void { + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cleaned; + for (let i = 0; i < keys.length - 1; i++) { + if (!isPlainObject(cursor)) break; + cursor = (cursor as Record)[keys[i]!]; + } + if (!isPlainObject(cursor)) continue; + const leaf = keys[keys.length - 1]!; + const val = (cursor as Record)[leaf]; + if (typeof val !== "string") continue; + + let envName: string | null = null; + let genericFallbacks = false; + if (val.startsWith("${") && val.endsWith("}")) { + // Explicit ${VAR} reference — resolve exactly that variable and + // nothing else. + const name = val.slice(2, -1); + if (!ENV_REF_ALLOWLIST.test(name)) { + warnings?.push( + `config: leaving '${dotted}' as '${val}' — env name '${name}' is not allowlisted ` + + `(expected ^[A-Z][A-Z0-9_]*_(API_KEY|TOKEN)$)` + ); + continue; + } + envName = name; + } else if (val === "__memos_secret__" || val === "") { + // Masked/empty API key — infer the env var from the field path. + // Only apiKey fields have a convention (OPENAI_API_KEY, etc.); + // hub tokens (teamToken/userToken) have no env convention, so + // they must be set explicitly via ${VAR} or the UI. + if (leaf !== "apiKey") continue; + const isEmbedding = keys[keys.length - 2] === "embedding"; + envName = isEmbedding ? "EMBEDDING_API_KEY" : "LLM_API_KEY"; + // Generic fallbacks exist for LLM-class keys only; an embedding + // key must never be silently populated with an LLM provider's key. + genericFallbacks = !isEmbedding; + } + if (!envName) continue; + + const envVal = + process.env[envName] ?? + (genericFallbacks + ? (process.env.OPENCODE_GO_API_KEY ?? process.env.OPENCODE_ZEN_API_KEY) + : undefined); + if (envVal) (cursor as Record)[leaf] = envVal; + } +} + function formatErr(e: ValueError): string { return `${e.path || ""}: ${e.message}`; } diff --git a/apps/memos-local-plugin/core/config/schema-pr.ts b/apps/memos-local-plugin/core/config/schema-pr.ts new file mode 100644 index 000000000..5fdadc740 --- /dev/null +++ b/apps/memos-local-plugin/core/config/schema-pr.ts @@ -0,0 +1,676 @@ +/** + * The single schema for `config.yaml`. Used to: + * 1. Validate user files at load time (`loadConfig`). + * 2. Provide JSON Schema for editor autocomplete (writer can emit it). + * 3. Generate the `templates/config..yaml` defaults during code review. + * + * Adding fields: provide a default in `defaults.ts` (so old configs upgrade). + * Removing fields: log a warning at load time; don't crash. + */ + +import { Type, type Static } from "@sinclair/typebox"; + +// ─── Reusable building blocks ─────────────────────────────────────────────── + +const StringWithDefault = (def = "") => Type.String({ default: def }); +const Bool = (def: boolean) => Type.Boolean({ default: def }); +const NumberInRange = (def: number, min?: number, max?: number) => + Type.Number({ default: def, ...(min != null ? { minimum: min } : {}), ...(max != null ? { maximum: max } : {}) }); + +// ─── Sub-schemas ──────────────────────────────────────────────────────────── + +const ViewerSchema = Type.Object({ + port: NumberInRange(18799, 1, 65535), + bindHost: StringWithDefault("127.0.0.1"), + openOnFirstTurn: Bool(false), +}, { default: {} }); + +const BridgeSchema = Type.Object({ + port: NumberInRange(18911, 1, 65535), + mode: Type.Union([Type.Literal("stdio"), Type.Literal("tcp")], { default: "stdio" }), +}, { default: {} }); + +const EmbeddingSchema = Type.Object({ + provider: Type.Union([ + Type.Literal("local"), + Type.Literal("openai_compatible"), + Type.Literal("gemini"), + ], { default: "local" }), + endpoint: StringWithDefault(""), + model: StringWithDefault("Xenova/all-MiniLM-L6-v2"), + apiKey: StringWithDefault(""), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), + cache: Type.Object({ + enabled: Bool(true), + maxItems: NumberInRange(20_000, 0), + }, { default: {} }), +}, { default: {} }); + +const ReasoningSchema = Type.Object({ + /** + * OpenRouter-compatible reasoning toggle. Omit the whole block to keep + * the provider/model default. + */ + enabled: Type.Optional(Type.Boolean()), + /** Optional provider effort hint for reasoning-capable models. */ + effort: Type.Optional(Type.Union([ + Type.Literal("minimal"), + Type.Literal("none"), + Type.Literal("low"), + Type.Literal("medium"), + Type.Literal("high"), + Type.Literal("xhigh"), + Type.Literal("max"), + ])), + /** Optional token budget for reasoning-capable providers. */ + maxTokens: Type.Optional(Type.Number({ minimum: 1 })), +}, { default: {} }); + +const LlmSchema = Type.Object({ + provider: Type.Union([ + Type.Literal(""), + Type.Literal("local_only"), + Type.Literal("openai_compatible"), + Type.Literal("gemini"), + Type.Literal("anthropic"), + Type.Literal("bedrock"), + Type.Literal("host"), + ], { default: "" }), + endpoint: StringWithDefault(""), + model: StringWithDefault(""), + temperature: NumberInRange(0, 0, 2), + /** When true, fall back to the agent host's LLM if `provider` fails. */ + fallbackToHost: Bool(true), + apiKey: StringWithDefault(""), + /** Per-call timeout in ms. */ + timeoutMs: NumberInRange(45_000, 1_000), + /** Max retries on transient errors. */ + maxRetries: NumberInRange(3, 0, 10), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion (deepseek-v4-flash needs >= 100). */ + maxTokens: NumberInRange(1024, 16, 131072), + /** Extra HTTP headers for the provider request. */ + headers: Type.Optional(Type.Record(Type.String(), Type.String(), { default: {} })), +}, { default: {} }); + +/** + * Dedicated model slot for the **skill evolver** (V7 Phase 11 skill + * crystallisation + Phase 10 L2 induction). Often the operator wants + * a more capable model here than for the per-turn summarizer because + * skill generation writes code that will be invoked by the agent. + * + * All fields are optional. When `model` is empty we fall back to the + * main `llm.*` settings — this matches the legacy plugin's "使用 + * Summarizer" button and keeps fresh installs zero-config. + */ +const SkillEvolverSchema = Type.Object({ + provider: Type.Union([ + Type.Literal(""), + Type.Literal("openai_compatible"), + Type.Literal("gemini"), + Type.Literal("anthropic"), + ], { default: "" }), + endpoint: StringWithDefault(""), + model: StringWithDefault(""), + apiKey: StringWithDefault(""), + temperature: NumberInRange(0, 0, 2), + timeoutMs: NumberInRange(60_000, 1_000), + /** OpenRouter provider routing — providers to skip. */ + providerIgnore: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** OpenRouter provider routing — preferred order. */ + providerOrder: Type.Optional(Type.Array(Type.String(), { default: [] })), + /** Explicitly enable OpenRouter fields for a reverse proxy or CNAME. */ + openRouter: Type.Optional(Bool(false)), + /** Optional reasoning control (see ReasoningSchema). Omit = model default. */ + reasoning: Type.Optional(ReasoningSchema), + /** Max output tokens per completion. */ + maxTokens: NumberInRange(1024, 16, 131072), +}, { default: {} }); + +const StorageSchema = Type.Object({ + /** + * Keyword tokenizer mode used when compiling FTS5 MATCH expressions. + * `trigram` preserves the historical SQLite trigram behavior; `cjk` + * keeps short Chinese words and mixed ASCII+CJK tokens searchable. + */ + ftsTokenizer: Type.Union([ + Type.Literal("trigram"), + Type.Literal("cjk"), + ], { default: "trigram" }), +}, { default: {} }); + +const AlgorithmSchema = Type.Object({ + lightweightMemory: Type.Object({ + /** + * Low-cost mode for users who only want raw conversation memory + + * recall. When enabled, the runtime skips task/reward/L2/L3/skill + * evolution and keeps only summarize + embedding + retrieval filter. + * The viewer exposes the inverse as "memory self-evolution". + */ + enabled: Bool(true), + }, { default: {} }), + capture: Type.Object({ + /** Cap on agent/user text length (chars). Longer content is summarized. */ + maxTextChars: NumberInRange(4_000, 200, 64_000), + /** Maximum tool outputs we keep verbatim per step. Extras are truncated. */ + maxToolOutputChars: NumberInRange(2_000, 200, 32_000), + /** Embed state+action vectors when writing traces. Default on. */ + embedTraces: Bool(true), + /** When true, ask the LLM to score α for each reflection. Default on. */ + alphaScoring: Bool(true), + /** Synthesize reflections with the LLM if extractor found none. Default off. */ + synthReflections: Bool(false), + /** Concurrency for α scoring + synth LLM calls (per_step mode only). */ + llmConcurrency: NumberInRange(4, 1, 32), + /** Hard cap for one topic-end reflect pass, including recovery replay. */ + maxReflectLlmCalls: NumberInRange(128, 0, 10_000), + /** Max orphan trace inserts allowed during startup-recovered replay. */ + maxRecoveryOrphanInserts: NumberInRange(0, 0, 10_000), + /** + * V7 §3.2 batched variant. When/how to fold per-step reflection synth + + * α scoring into one episode-level LLM call: + * - "per_step" : legacy path, N per-step LLM calls + * - "per_episode" : always batch + * - "auto" : batch when stepCount ≤ batchThreshold, else per-step + */ + batchMode: Type.Union( + [Type.Literal("per_step"), Type.Literal("per_episode"), Type.Literal("auto")], + { default: "auto" }, + ), + /** + * Step-count cap for "auto" mode. Episodes above this limit fall back + * to per-step calls so the batched prompt cannot overflow context. + */ + batchThreshold: NumberInRange(12, 1, 64), + /** + * Optional context blocks for per-step reflection and α prompts. + * Defaults to "task" to preserve the current task-summary enrichment; + * downstream preview remains opt-in. + */ + reflectionContextMode: Type.Union( + [ + Type.Literal("none"), + Type.Literal("task"), + Type.Literal("downstream"), + Type.Literal("task_downstream"), + ], + { default: "task" }, + ), + /** + * Long-episode fallback mode after batch auto-threshold is exceeded. + * `per_step_downstream` keeps parallelism but adds step+1..step+3 preview. + */ + longEpisodeReflectMode: Type.Union( + [Type.Literal("per_step_parallel"), Type.Literal("per_step_downstream")], + { default: "per_step_parallel" }, + ), + /** Max downstream steps attached to a per-step prompt. */ + downstreamStepCount: NumberInRange(3, 0, 3), + /** Character cap for the task-context block. */ + taskContextMaxChars: NumberInRange(800, 100, 4_000), + /** Total character cap for all downstream preview blocks. */ + downstreamContextMaxChars: NumberInRange(1_200, 0, 8_000), + /** Character cap per downstream preview block. */ + downstreamPerStepMaxChars: NumberInRange(400, 100, 2_000), + /** Character cap for current-step tool outcome in synth / α prompts. */ + synthOutcomeMaxChars: NumberInRange(600, 100, 4_000), + }, { default: {} }), + reward: Type.Object({ + /** V7 §0.6 eq. 4/5: discount factor γ for reflection-weighted backprop. */ + gamma: NumberInRange(0.9, 0, 1), + /** V7 §2.4.5 eq. 3: temperature τ for softmax reweighting in L2 induction. */ + tauSoftmax: NumberInRange(0.5, 0.01, 10), + /** V7 §3.3: priority decay half-life in days. */ + decayHalfLifeDays: NumberInRange(30, 1, 365), + /** Ask LLM to score user feedback → R_human. Off falls back to polarity heuristics. */ + llmScoring: Bool(true), + /** Auto-trigger backprop when R_human ≥ this from implicit signals. */ + implicitThreshold: NumberInRange(0.2, 0, 1), + /** + * Seconds to wait for explicit user feedback after `capture.done` before + * falling back to implicit-signals scoring. 0 disables the timer. + */ + feedbackWindowSec: NumberInRange(600, 0, 86_400), + /** Max characters for the task summary fed into the human-scorer LLM. */ + summaryMaxChars: NumberInRange(2_000, 200, 16_000), + /** Concurrency for human-scoring LLM calls. */ + llmConcurrency: NumberInRange(2, 1, 16), + /** + * Min user↔assistant *exchanges* before an episode is scored. + * Shorter episodes are closed as abandoned. Default 1 — admits + * single-shot CLI patterns (`hermes chat -q "..."`, + * `openclaw run --once`) which always have exactly one + * user-assistant pair. Set 2 for the strict legacy behaviour + * (skip episodes that aren't a real back-and-forth). + */ + minExchangesForCompletion: NumberInRange(1, 1, 20), + /** + * Min combined user+assistant content characters before scoring. + * Filters trivial turns ("hi"/"ok"). Default 40 — pairs with the + * relaxed exchanges floor; raise to 80+ if your workflow always + * sends long prompts and you want stronger triviality gating. + */ + minContentCharsForCompletion: NumberInRange(40, 0, 4_000), + /** + * Fraction of turns that are tool calls above which an episode is + * considered "tool-heavy". When combined with low assistant text + * the episode is skipped as noise. Default 0.7 (70%). + */ + toolHeavyRatio: NumberInRange(0.7, 0, 1), + /** + * Minimum total assistant content chars to keep an episode that + * would otherwise be flagged by the tool-heavy heuristic. If the + * assistant wrote at least this many characters the episode is + * scored normally even if tool calls dominate. Default 80. + */ + minAssistantCharsForToolHeavy: NumberInRange(80, 0, 10_000), + }, { default: {} }), + l2Induction: Type.Object({ + /** Cosine ≥ this to associate a new trace with an existing L2 policy. */ + minSimilarity: NumberInRange(0.72, 0, 1), + /** TTL (days) for unpromoted rows in `l2_candidate_pool`. */ + candidateTtlDays: NumberInRange(30, 1), + /** Min distinct episodes in a candidate bucket before we run induction. */ + minEpisodesForInduction: NumberInRange(1, 1, 20), + /** Ignore traces whose V is below this floor (prevents noise-driven L2). */ + minTraceValue: NumberInRange(0.01, -1, 1), + /** When true, call the LLM to induce policies; else collect candidates only. */ + useLlm: Bool(true), + /** Character cap for traces handed into the `l2.induction` prompt. */ + traceCharCap: NumberInRange(3_000, 600, 16_000), + /** EMA alpha for gain updates. 1 means overwrite, lower values preserve history. */ + gainEmaAlpha: NumberInRange(0.4, 0, 1), + /** Archive active policies whose gain dips below this value. */ + archiveGain: NumberInRange(-0.05, -1, 1), + }, { default: {} }), + l3Abstraction: Type.Object({ + /** Minimum number of compatible active L2 policies to trigger an L3 abstraction. */ + minPolicies: NumberInRange(1, 1, 50), + /** Hard minimum gain for an L2 to be eligible as abstraction evidence. */ + minPolicyGain: NumberInRange(0.02, -1, 1), + /** Hard minimum support for an L2 to be eligible as abstraction evidence. */ + minPolicySupport: NumberInRange(1, 1), + /** + * Cosine ≥ this between two L2 vectors → same bucket. Buckets below this + * are ignored (policies too disparate to share a world model). + */ + clusterMinSimilarity: NumberInRange(0.6, 0, 1), + /** Chars of L2 body handed to `l3.abstraction`. */ + policyCharCap: NumberInRange(800, 200, 4_000), + /** Chars of trace body handed per evidence trace. */ + traceCharCap: NumberInRange(500, 100, 4_000), + /** Max evidence traces in the prompt — one per policy. */ + traceEvidencePerPolicy: NumberInRange(1, 0, 4), + /** + * When true, call `l3.abstraction` to generate/update world models. + * When false, buckets are logged but no LLM call fires — useful for + * cost-sensitive deployments. + */ + useLlm: Bool(true), + /** Cooldown in days between L3 runs for the same domain tag. */ + cooldownDays: NumberInRange(1, 0, 365), + /** Confidence delta per positive/negative user feedback. */ + confidenceDelta: NumberInRange(0.05, 0, 1), + /** Below this confidence, a world model is hidden from Tier-3 retrieval. */ + minConfidenceForRetrieval: NumberInRange(0.2, 0, 1), + }, { default: {} }), + skill: Type.Object({ + minSupport: NumberInRange(2, 1), + // V7 §2.5 graduation floor. The schema allows negative values so + // demo / single-success-line scenarios (where with-without ≈ 0 by + // construction even after Bayesian shrinkage) can still force- + // graduate candidate policies into active. Production default is + // 0.02 — see `core/config/defaults.ts` for rationale and + // `core/memory/l2/gain.ts` for how gain is now anchored to a + // neutral 0.5 baseline so this floor is reachable on real data. + minGain: NumberInRange(0.02, -1, 1), + /** Trials a skill must accumulate in `candidate` before it can graduate. */ + candidateTrials: NumberInRange(3, 1), + /** Back-off before we retry a failed-to-verify policy. */ + cooldownMs: NumberInRange(6 * 60 * 60 * 1000, 0, 30 * 24 * 60 * 60 * 1000), + /** Chars per evidence trace fed into the crystallize prompt. */ + traceCharCap: NumberInRange(500, 100, 4_000), + /** Max evidence traces per policy given to the LLM. */ + evidenceLimit: NumberInRange(6, 1, 20), + /** Turn the LLM crystallization off (collect candidates only). */ + useLlm: Bool(true), + /** η delta applied per user thumbs up/down. */ + etaDelta: NumberInRange(0.1, 0, 1), + /** Archive an active skill whose η drops below this. */ + archiveEta: NumberInRange(0.1, 0, 1), + /** Hide Tier-1 skills whose η is below this. Mirrors retrieval.minSkillEta. */ + minEtaForRetrieval: NumberInRange(0.1, 0, 1), + }, { default: {} }), + feedback: Type.Object({ + /** Raise a burst after this many failures of the same tool in-window. */ + failureThreshold: NumberInRange(3, 2, 20), + /** Rolling window (number of steps) for the burst counter. */ + failureWindow: NumberInRange(5, 2, 50), + /** Min |mean(high) - mean(low)| to fire without an explicit user signal. */ + valueDelta: NumberInRange(0.5, 0, 2), + /** + * Minimum absolute value threshold for lowValue traces. Only traces with + * value < -minLowValueThreshold will be collected as failure evidence + * (unless they match isFailureLike patterns). This filters out trivial + * negative feedback (e.g., value = -0.001) and focuses on genuine failures. + * Default 0.01 — adjust higher (e.g., 0.1) to be more conservative. + */ + minLowValueThreshold: NumberInRange(0.01, 0, 1), + /** Let the LLM rewrite the preference / anti-pattern lines. */ + useLlm: Bool(true), + /** Tag the L2 policies referenced by the evidence with the guidance. */ + attachToPolicy: Bool(true), + /** Debounce (ms) for repeat repairs on the same context hash. */ + cooldownMs: NumberInRange(60_000, 0, 24 * 60 * 60 * 1000), + /** Char cap per trace handed to the repair prompt. */ + traceCharCap: NumberInRange(500, 100, 4_000), + /** Max evidence traces per class (high-value / low-value). */ + evidenceLimit: NumberInRange(4, 1, 20), + }, { default: {} }), + session: Type.Object({ + /** + * How a user's next message should relate to the previously closed + * episode. Mirrors V7 §0.1 but softens the default so same-topic + * follow-ups stay in one "task" from the user's POV. + * + * - "merge_follow_ups" (default) — both `revision` and `follow_up` + * reopen the previous episode and append the new turn. Only + * `new_task` opens a fresh episode/session. This matches the + * legacy `memos-local-openclaw` behaviour where one "task" + * aggregates many related turns and skills crystallise from a + * coherent transcript. + * - "episode_per_turn" — follow-ups open a NEW episode in + * the same session (V7 §0.1 strict). Each user query gets its + * own R_human + V backprop pass. Useful when you want fine-grained + * credit assignment per sub-task. + */ + followUpMode: Type.Union([ + Type.Literal("merge_follow_ups"), + Type.Literal("episode_per_turn"), + ], { default: "merge_follow_ups" }), + /** + * Hard cap on how long a single merged episode can grow before we + * force a new episode boundary even if relation says "follow_up". + * Prevents infinite growth and keeps reward scoring tractable. + * 0 disables the cap. Default: 2 hours — matches the legacy + * `taskIdleTimeoutMs`. + */ + mergeMaxGapMs: NumberInRange(2 * 60 * 60 * 1000, 0, 24 * 60 * 60 * 1000), + /** + * Hard cap on turns in a merged episode. Once reached, the next + * turn forces a topic boundary even if relation classification says + * follow-up/revision. Keeps task-end processing bounded. + */ + maxTurnsPerEpisode: NumberInRange(30, 5, 200), + /** + * Max time to wait for relation classification before defaulting + * to a conservative new-task boundary so foreground prompt + * construction cannot stall indefinitely. + */ + classifyTimeoutMs: NumberInRange(5000, 1000, 30000), + /** + * Shared LLM concurrency budget for asynchronous background + * capture/reward/L2/L3/skill-evolution processing. + */ + bgLlmConcurrency: NumberInRange(2, 1, 8), + }, { default: {} }), + retrieval: Type.Object({ + /** How many Skill snippets to inject at turn start. */ + tier1TopK: NumberInRange(3, 0, 100), + /** How many trace/episode snippets to inject. */ + tier2TopK: NumberInRange(5, 0, 100), + /** How many world-model snippets to inject. */ + tier3TopK: NumberInRange(2, 0, 100), + /** Fetch K·factor candidates from SQLite before MMR/priority re-rank. */ + candidatePoolFactor: NumberInRange(4, 1, 50), + /** Tier 2 fusion weight for cosine similarity (vs. priority). */ + weightCosine: NumberInRange(0.6, 0, 1), + /** Tier 2 fusion weight for max(V,0)·decay(Δt) priority. */ + weightPriority: NumberInRange(0.4, 0, 1), + /** MMR λ — 1 = pure relevance, 0 = pure diversity. */ + mmrLambda: NumberInRange(0.7, 0, 1), + /** Hide V<0 traces by default (Decision Repair can override). */ + includeLowValue: Bool(false), + /** Classic Reciprocal Rank Fusion constant. */ + rrfConstant: NumberInRange(60, 1, 10_000), + /** Skip Tier-1 skills whose η is below this floor. */ + minSkillEta: NumberInRange(0.1, 0, 1), + /** Drop Tier-2 hits whose cosine is below this floor. */ + minTraceSim: NumberInRange(0.35, 0, 1), + /** + * V7 §2.6 Tier 2b — minimum goal-level cosine for "episode replay" + * to fire. Below this, we don't rollup episodes into a reference + * action sequence (individual trace hits still go through). + */ + episodeGoalMinSim: NumberInRange(0.45, 0, 1), + /** auto | off | strict — controls tag-based pre-filtering. */ + tagFilter: Type.Union([ + Type.Literal("auto"), + Type.Literal("off"), + Type.Literal("strict"), + ], { default: "auto" }), + /** + * Per-tier keyword (FTS5 + pattern) channel size. Each tier issues + * a vector channel + an FTS channel + a pattern channel; this is + * the K for the keyword channels (vector still uses + * `tier{1,2,3}TopK · candidatePoolFactor`). + */ + keywordTopK: NumberInRange(20, 0, 200), + /** + * Drop ranked candidates whose blended `relevance` is below + * `topRelevance * relativeThresholdFloor`. Adaptive cousin of + * `minTraceSim` — when the best hit is weak, we keep more (lower + * absolute floor); when there's a clear winner, we drop noise. + * Set to 0 to disable the relative cutoff entirely. + * + * Default lowered to 0.2 with the 2026 ranker overhaul: the new + * base formula already weighs channel-rank evidence (so a raw + * FTS-only hit lands in a comparable range to a cosine-0.8 hit), + * and the old 0.4 floor was over-pruning keyword matches with + * modest V·decay. + */ + relativeThresholdFloor: NumberInRange(0.2, 0, 1), + /** + * Tier-1 skill relevance blend weight for `η` (skill reliability). + * Old default `0.4` made well-trodden skills outrank obviously-more- + * relevant new ones. `0.15` keeps the η nudge but lets the query↔skill + * cosine dominate. + */ + skillEtaBlend: NumberInRange(0.15, 0, 1), + /** + * MMR Phase-A seed-by-tier policy. When `true`, only seed a tier + * if its best candidate's relevance ≥ `poolTopRelevance * + * smartSeedRatio` (see below). This prevents the ranker from + * force-injecting a stale Tier-1 skill / Tier-3 world-model just + * because it cleared the absolute floors. + */ + smartSeed: Bool(true), + /** + * Seed cutoff for smart-seed MMR — tier is seeded iff its best + * candidate's relevance ≥ `poolTopRelevance * smartSeedRatio`. + * Independent of `relativeThresholdFloor` so the seed gate can be + * stricter than the generic drop floor (0.7 is "within 30% of the + * best available candidate anywhere in the pool"). + */ + smartSeedRatio: NumberInRange(0.7, 0, 1), + /** + * When a candidate is surfaced by ≥ 2 retrieval channels (e.g. + * both vec and fts hit the same trace), bypass the relative + * threshold. Multi-channel agreement is a strong signal, and + * without this keyword-only matches with modest V·decay often + * get dropped by a noisy `topRelevance`. + */ + multiChannelBypass: Bool(true), + /** + * How Tier-1 skills are surfaced in the injected prompt: + * - "summary" (default): inject only `name + η + 1-line summary + + * a `memos_skill_get(id="…")` hint`. The agent decides whether to + * fetch the full procedure via the `memos_skill_get` tool. Keeps the + * prompt small and avoids paying for skills the agent never + * uses. + * - "full": inline the entire `invocationGuide` body (legacy + * behaviour — useful for hosts that don't support tool calls). + */ + skillInjectionMode: Type.Union( + [Type.Literal("summary"), Type.Literal("full")], + { default: "summary" }, + ), + /** + * Char cap for the per-skill summary body when `skillInjectionMode` + * is `summary`. We trim the first paragraph of `invocationGuide` + * and clamp to this many chars before appending the call-hint. + */ + skillSummaryChars: NumberInRange(200, 40, 800), + /** + * LLM-based relevance filter (`core/retrieval/llm-filter.ts`). + * Default on because cosine retrieval over-matches and a single + * small LLM call dramatically cuts down irrelevant injections. + */ + llmFilterEnabled: Bool(true), + /** Keep at most this many candidates after the LLM filter. */ + llmFilterMaxKeep: NumberInRange(5, 1, 30), + /** + * Skip the filter when the ranked list has fewer than this many + * items. Default 1 — even a single candidate gets a precision + * pass, matching `memos-local-openclaw`'s tool-level filter and + * preventing a lone off-topic memory from sneaking through + * unchecked. + */ + llmFilterMinCandidates: NumberInRange(1, 1, 50), + /** + * Body-text budget per candidate when building the LLM filter + * prompt. Higher = more context for precise judgement, at the + * cost of more tokens per round-trip. Default 500 (openclaw uses + * 300 without tags/channels; we include richer metadata, so a + * slightly larger window pays for itself). + */ + llmFilterCandidateBodyChars: NumberInRange(500, 120, 2000), + /** + * Tier-2 vector scan time-window bound (ms). When > 0, the + * vector scan path (`scanAndTopK` in `core/storage/vector.ts`) + * only considers traces written within the last + * `vectorScanMaxAgeMs` milliseconds. Set to `0` to disable the + * cap (legacy behaviour: full-table brute-force scan). + * + * Background: at 93K rows × 1536 dims the unbounded scan blocks + * the Node event loop for 5–30 s every `onTurnStart` + * (https://github.com/MemTensor/MemOS/issues/1929). A 24-hour + * window keeps onTurnStart latency under control without + * sacrificing recall for active-session memories. FTS keyword + * channels still cover older traces, so this bound only affects + * the cosine-only path. + * + * Hard cap is one year (31_536_000_000 ms) — anything larger is + * indistinguishable from "unbounded" at the corpus sizes where + * the bound starts to matter, and accepting absurdly large + * values lets misconfigured deployments silently revert to the + * old behaviour. + */ + vectorScanMaxAgeMs: NumberInRange(0, 0, 31_536_000_000), + }, { default: {} }), +}, { default: {} }); + +const HubSchema = Type.Object({ + enabled: Bool(false), + role: Type.Union([Type.Literal("hub"), Type.Literal("client")], { default: "client" }), + port: NumberInRange(18912, 1, 65535), + address: StringWithDefault(""), + teamName: StringWithDefault(""), + teamToken: StringWithDefault(""), + userToken: StringWithDefault(""), + nickname: StringWithDefault(""), +}, { default: {} }); + +const TelemetrySchema = Type.Object({ + enabled: Bool(true), +}, { default: {} }); + +const LoggingSchema = Type.Object({ + level: Type.Union([ + Type.Literal("trace"), + Type.Literal("debug"), + Type.Literal("info"), + Type.Literal("warn"), + Type.Literal("error"), + Type.Literal("fatal"), + ], { default: "info" }), + /** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */ + detailedView: Bool(false), + /** IANA timezone for log timestamp display. */ + timezone: StringWithDefault("UTC"), + console: Type.Object({ + enabled: Bool(true), + pretty: Bool(true), + channels: Type.Array(Type.String(), { default: ["*"] }), + }, { default: {} }), + file: Type.Object({ + enabled: Bool(true), + format: Type.Union([Type.Literal("json"), Type.Literal("compact")], { default: "json" }), + rotate: Type.Object({ + maxSizeMb: NumberInRange(50, 1), + maxFiles: NumberInRange(14, 1), + gzip: Bool(true), + }, { default: {} }), + /** Days to keep regular app/error/perf/llm/events files. */ + retentionDays: NumberInRange(30, 1), + }, { default: {} }), + audit: Type.Object({ + enabled: Bool(true), + /** Audit retention is "forever": rotate by month, gzip; never delete. */ + rotate: Type.Object({ + monthly: Bool(true), + gzip: Bool(true), + }, { default: {} }), + }, { default: {} }), + llmLog: Type.Object({ + enabled: Bool(true), + redactPrompts: Bool(false), + redactCompletions: Bool(false), + }, { default: {} }), + perfLog: Type.Object({ + enabled: Bool(true), + sampleRate: NumberInRange(1.0, 0, 1), + }, { default: {} }), + eventsLog: Type.Object({ + enabled: Bool(true), + }, { default: {} }), + redact: Type.Object({ + extraKeys: Type.Array(Type.String(), { default: ["api_key", "secret", "token", "password", "authorization"] }), + extraPatterns: Type.Array(Type.String(), { default: [] }), + }, { default: {} }), + /** Per-channel level overrides, e.g. `{ "core.l2.cross-task": "debug" }`. */ + channels: Type.Record(Type.String(), Type.String(), { default: {} }), +}, { default: {} }); + +// ─── Top-level schema ─────────────────────────────────────────────────────── + +export const ConfigSchema = Type.Object({ + version: NumberInRange(1, 1), + viewer: ViewerSchema, + bridge: BridgeSchema, + embedding: EmbeddingSchema, + llm: LlmSchema, + /** Dedicated model slot for L3 abstraction. Same shape as skillEvolver. */ + l3Llm: SkillEvolverSchema, + skillEvolver: SkillEvolverSchema, + storage: StorageSchema, + algorithm: AlgorithmSchema, + hub: HubSchema, + telemetry: TelemetrySchema, + logging: LoggingSchema, +}, { default: {} }); + +export type ReasoningConfig = Static; +export type ResolvedConfig = Static; diff --git a/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts new file mode 100644 index 000000000..daae23de6 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/llm-max-tokens-headers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CONFIG, resolveConfig } from "../../../core/config/index.js"; + +describe("resolveConfig llm.maxTokens + llm.headers", () => { + it("accepts llm.maxTokens and llm.headers without unknown-key warnings", () => { + const warnings: string[] = []; + const cfg = resolveConfig( + { + llm: { + maxTokens: 2048, + headers: { "User-Agent": "hermes-test", "X-Custom": "v1" }, + }, + }, + warnings, + ); + expect(cfg.llm.maxTokens).toBe(2048); + expect(cfg.llm.headers).toEqual({ "User-Agent": "hermes-test", "X-Custom": "v1" }); + // The free-form-map special case must not warn per header key. + expect(warnings).toEqual([]); + }); + + it("declares llm.maxTokens with a sane default of 1024", () => { + expect(DEFAULT_CONFIG.llm.maxTokens).toBe(1024); + const cfg = resolveConfig({}); + expect(cfg.llm.maxTokens).toBe(1024); + }); + + it("declares llm.headers defaulting to an empty map", () => { + expect(DEFAULT_CONFIG.llm.headers).toEqual({}); + const cfg = resolveConfig({}); + expect(cfg.llm.headers).toEqual({}); + }); + + it("declares skillEvolver.maxTokens (default 1024) for the crystallizer LLM slot", () => { + expect(DEFAULT_CONFIG.skillEvolver.maxTokens).toBe(1024); + const cfg = resolveConfig({ skillEvolver: { maxTokens: 4096 } }); + expect(cfg.skillEvolver.maxTokens).toBe(4096); + }); + + it("rejects out-of-range maxTokens with config_invalid", () => { + expect(() => resolveConfig({ llm: { maxTokens: 8 } })).toThrow(/config failed schema validation/); + }); + + it("rejects non-string header values", () => { + expect(() => resolveConfig({ llm: { headers: { "X-Bad": 42 } } })).toThrow( + /config failed schema validation/, + ); + }); + + it("keeps unrelated llm fields untouched when maxTokens/headers are set", () => { + const cfg = resolveConfig({ + llm: { provider: "openai_compatible", model: "deepseek-v4-flash", maxTokens: 2048 }, + }); + expect(cfg.llm.provider).toBe("openai_compatible"); + expect(cfg.llm.model).toBe("deepseek-v4-flash"); + expect(cfg.llm.temperature).toBe(0); + expect(cfg.llm.fallbackToHost).toBe(true); + expect(cfg.llm.timeoutMs).toBe(45_000); + expect(cfg.llm.maxRetries).toBe(3); + }); +}); diff --git a/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts new file mode 100644 index 000000000..762138db3 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/config/resolve-secret-env.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { resolveConfig } from "../../../core/config/index.js"; +import { SECRET_FIELD_PATHS } from "../../../core/config/defaults.js"; + +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe("resolveConfig secret env fallback", () => { + it("expands allowlisted ${ENV_VAR} references in secret fields", () => { + process.env.MY_LLM_API_KEY = "sk-env-expanded"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("sk-env-expanded"); + }); + + it("resolves the __memos_secret__ mask sentinel from env", () => { + process.env.OPENCODE_GO_API_KEY = "sk-mask-resolved"; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }); + expect(cfg.llm.apiKey).toBe("sk-mask-resolved"); + }); + + it("resolves empty string secret fields from env", () => { + process.env.OPENCODE_ZEN_API_KEY = "sk-empty-resolved"; + const cfg = resolveConfig({ llm: { apiKey: "" } }); + expect(cfg.llm.apiKey).toBe("sk-empty-resolved"); + }); + + it("uses per-path env conventions — embedding gets EMBEDDING_API_KEY, never an LLM key", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.EMBEDDING_API_KEY = "sk-embed"; + const raw: Record = {}; + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor = raw; + for (let i = 0; i < keys.length - 1; i++) { + cursor[keys[i]!] = cursor[keys[i]!] ?? {}; + cursor = cursor[keys[i]!] as Record; + } + cursor[keys[keys.length - 1]!] = "__memos_secret__"; + } + const cfg = resolveConfig(raw); + for (const dotted of SECRET_FIELD_PATHS) { + const keys = dotted.split("."); + let cursor: unknown = cfg; + for (const k of keys) { + cursor = (cursor as Record)[k]; + } + if (dotted === "embedding.apiKey") { + // Embedding keys use their own convention and must not fall back + // to an LLM provider's key. + expect(cursor).toBe("sk-embed"); + } else if (dotted.endsWith("apiKey")) { + expect(cursor).toBe("sk-llm"); + } else { + // hub tokens have no env convention — they stay masked. + expect(cursor).toBe("__memos_secret__"); + } + } + }); + + it("resolves hub tokens via explicit ${VAR} references", () => { + process.env.HUB_TEAM_TOKEN = "sk-hub-token"; + const cfg = resolveConfig({ hub: { teamToken: "${HUB_TEAM_TOKEN}" } }); + expect(cfg.hub.teamToken).toBe("sk-hub-token"); + }); + + it("does not fall back to generic keys when an explicit ${VAR} is unset", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + process.env.OPENCODE_ZEN_API_KEY = "sk-zen"; + const cfg = resolveConfig({ llm: { apiKey: "${MY_LLM_API_KEY}" } }); + expect(cfg.llm.apiKey).toBe("${MY_LLM_API_KEY}"); + }); + + it("warns and skips expansion for non-allowlisted ${VAR} names", () => { + process.env.HOME = "/home/test"; + const warnings: string[] = []; + const cfg = resolveConfig({ llm: { apiKey: "${HOME}" } }, warnings); + expect(cfg.llm.apiKey).toBe("${HOME}"); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("not allowlisted"); + }); + + it("leaves real (non-placeholder) values untouched", () => { + const cfg = resolveConfig({ llm: { apiKey: "sk-real-value" } }); + expect(cfg.llm.apiKey).toBe("sk-real-value"); + }); + + it("leaves placeholders untouched when no env var is set", () => { + delete process.env.LLM_API_KEY; + delete process.env.OPENCODE_GO_API_KEY; + delete process.env.OPENCODE_ZEN_API_KEY; + const cfg = resolveConfig({ llm: { apiKey: "__memos_secret__" } }); + expect(cfg.llm.apiKey).toBe("__memos_secret__"); + }); + + it("never mutates the caller's raw config object", () => { + process.env.OPENCODE_GO_API_KEY = "sk-llm"; + const raw = { llm: { apiKey: "__memos_secret__" } }; + const cfg = resolveConfig(raw); + expect(cfg.llm.apiKey).toBe("sk-llm"); + expect(raw.llm.apiKey).toBe("__memos_secret__"); + }); +});