Files
Cyrene_v2/internal/config/config.go
T
AskaEth d680390300 feat(memory): 整理频率走面板配置(面板 > env > 默认)+ 分时段动态间隔
主人 2026-10-07:「.env 只做兜底…优先从面板配置上取」+「频率必须调…或者动态
分配,比如白天间隔短一点 晚上到了静默时期稍微长一点」。

- memory-tidy.json 新增四键:intervalSec / quietIntervalSec / quietStartHour /
  quietEndHour(*int + 指针判定 => 显式 0 是合法值、缺位才回落)
- 优先级逐键三档:面板 > env(既有键,未增删)> 代码默认;坏值不覆盖 + WARN,
  且不启动失败
- 动态间隔由纯函数 intervalAt(at) 决定,调度侧每一拍现算 => 跑完按当时时段重算
- 🔴 语义变更(有意,代码注释已登记,待补设计修订):安静时段从「唯一可跑时段」
  变成「降频时段」—— 旧的「安静窗外一律 continue」被删,否则 intervalSec 永远
  是空壳(白天唤醒必被跳过);窗口仍决定档位与补跑判据
- 6 个变异(含「加回旧的那道门」=> 端到端用例红)
2026-10-07 19:21:32 +08:00

504 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config loads, defaults, and validates the Cyrene v2 configuration
// from environment variables and an optional .env file (development only).
//
// Every setting is sourced from an environment variable (no hard-coded values
// in code). Nested groups map to struct fields tagged with `env:"NAME"`.
// Missing required values cause Load/MustLoad to return an error so startup
// can fail fast.
package config
import (
"fmt"
"reflect"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/platform"
)
// DataDirPlatform 是 DATA_DIR 的保留字哨兵:显式配置它就走平台标准数据目录
// (design/13 §2,裁决 12.4.2 + 15.4.1);未配置时仍是标签里的默认 `./data`。
const DataDirPlatform = "platform"
// DefaultAdminPath 是 ADMIN_PATH 的默认值:面板挂在 /admin
// (design/07 §0.2,裁决 1.6 / 16.4.7)。
const DefaultAdminPath = "admin"
// Config is the root configuration tree.
type Config struct {
Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"`
// DataDir 是数据目录。两种取法都实现(design/13 §2,裁决 12.4.2/15.4.1):
// 未配置 → `./data`(标签默认,也是历史默认);显式配置保留字哨兵
// DataDirPlatform(`DATA_DIR=platform`)→ 平台标准数据目录,见
// resolveConfiguredDataDir。
DataDir string `env:"DATA_DIR" default:"./data"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
Server ServerConfig
Auth AuthConfig
DB DBConfig
LLM LLMConfig
Embedding EmbeddingConfig
IoT IoTConfig
Voice VoiceConfig
Background BackgroundConfig
Subagent SubagentConfig
PushOutlet PushOutletConfig
WSHub WSHubConfig
ExecSandbox ExecSandbox
MCP MCPConfig
Capacity CapacityConfig
Channels ChannelsConfig
Context ContextConfig
Guardrail GuardrailConfig
MemoryTidy MemoryTidyConfig
WebSearch WebSearchConfig
}
// ServerConfig holds the single gateway listener settings.
type ServerConfig struct {
Host string `env:"HOST" default:"0.0.0.0"`
Port int `env:"PORT" default:"8080"`
// AdminPath 是管理面板前端路由的**安全入口**路径前缀(裁决 1.6 / 16.4.7,
// design/07 §0.2):形如 `admin`(默认,面板挂在 `/admin`)或用户自定义的
// `xxx/admin`(面板挂在 `/xxx/admin`)。网关按它挂载面板入口,猜错的前缀
// 直接 404;该值只注入到面板入口页,用户端 bundle 不含它。
// REST 路径**不随它变化**——`/api/v1/admin/*` 照旧,安全靠 JWT + 角色门
// (design/07 §0.1「API 不藏」)。
AdminPath string `env:"ADMIN_PATH" default:"admin"`
// ChatImageMaxBytes 是 POST /api/v1/chat 的 image 允许的**解码后图像字节**
// 上限(协同文档 G3),与 POST /api/v1/upload 的 5 MiB 同口径。默认 5242880
// 与引入这项配置之前的硬编码常量逐字节一致;**≤0(含未配置的零值)视为
// 「用默认」**,不做「禁用上限」的解释——一张图能有多大只能有一个答案。
// 网关只在入口层用换算出的字符上限拦截,绝不把图片内容写进日志。
ChatImageMaxBytes int `env:"CHAT_IMAGE_MAX_BYTES" default:"5242880"`
// ChatDocumentMaxBytes 是 POST /api/v1/documents 允许的**单文件字节**上限,
// 默认 20971520(20 MiB)。它与图片上限(CHAT_IMAGE_MAX_BYTES)刻意解耦:
// 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定,
// 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
// **≤0(含未配置的零值)视为「用默认」**,不是「禁用上限」。
ChatDocumentMaxBytes int `env:"CHAT_DOCUMENT_MAX_BYTES" default:"20971520"`
// DocumentGCInterval 是文档回收(GC)的定时清扫间隔(Go duration string,
// 如 "1h"),默认 1h(design/24 §3.2 的配额与清理,切片 S7)。**≤0 = 关闭
// GC**(含启动期那一次清扫):关闭是显式选择,日志会写明。
DocumentGCInterval time.Duration `env:"DOCUMENT_GC_INTERVAL" default:"1h"`
// DocumentRetention 是「未被任何消息引用」的文档还要留多久才可回收(Go
// duration string),默认 168h = 7 天。**≤0 = 关闭 GC** —— 0 刻意不解释成
// 「立即回收」,一次笔误不该把整库未引用附件清空。判据与动作见
// internal/runtime/documents/gc.go。
DocumentRetention time.Duration `env:"DOCUMENT_RETENTION" default:"168h"`
// LLMUsageRetention 是 LLM 用量样本(`llm_usage` 表)的保留期(Go duration
// string),默认 720h = 30 天(切片 U1,主人裁决「2.4 落」)。**≤0 = 关闭
// 清理**:0 同样刻意不解释成「立即清空」,而是「不清理、全留」——一次笔误
// 不该把成本视图清空。清理在启动期跑一次,此后按固定间隔(见
// cmd/cyrene/wire_usage.go)继续。
LLMUsageRetention time.Duration `env:"LLM_USAGE_RETENTION" default:"720h"`
}
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
// 容忍配置里的首尾斜杠与空白;值非法(空、含 `.`/`..` 段,或含 `?`/`#`
// 这类会截断路径的字符)时回退到默认前缀,宁可挂回默认也不挂到不可预测的
// 路径上。
func (s ServerConfig) AdminPathPrefix() string {
raw := strings.Trim(strings.TrimSpace(s.AdminPath), "/")
if raw == "" {
return "/" + DefaultAdminPath
}
segs := strings.Split(raw, "/")
clean := make([]string, 0, len(segs))
for _, seg := range segs {
seg = strings.TrimSpace(seg)
if seg == "" || seg == "." || seg == ".." || strings.ContainsAny(seg, "?#") {
return "/" + DefaultAdminPath
}
clean = append(clean, seg)
}
return "/" + strings.Join(clean, "/")
}
// AuthConfig holds authentication and admin credential settings.
type AuthConfig struct {
JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"`
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
// RefreshTTL is how long an issued opaque refresh token stays valid. It is a
// Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h
// lifetime; the refresh token is the long-lived credential that renews them.
RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"`
// RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is
// on by default (the safe default): a successful /auth/refresh revokes the
// presented token and returns a fresh one, so a replayed token fails. Set
// AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a
// rotated token. The field is negative-named so its zero value (an
// unset/undecoded config) means rotation is enabled.
RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"`
}
// DBConfig holds database connection settings. SQLite is the default; PGURL is
// the optional PostgreSQL+pgvector enhancement.
type DBConfig struct {
SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"`
PGURL string `env:"DB_PG_URL"`
}
// EmbeddingConfig holds the optional semantic embedding API settings. When
// unset, the runtime falls back to built-in n-gram/FTS vectors.
type EmbeddingConfig struct {
APIURL string `env:"EMBEDDING_API_URL"`
APIKey string `env:"EMBEDDING_API_KEY"`
Model string `env:"EMBEDDING_MODEL"`
}
// IoTConfig selects the IoT device provider.
type IoTConfig struct {
Provider string `env:"IOT_PROVIDER" default:"mock"`
}
// VoiceConfig holds TTS/STT provider settings and the Alibaba Bailian DashScope
// credentials/models that back the cloud rung of the voice chain.
type VoiceConfig struct {
// TTSProvider selects the non-cloud local TTS rung: "edge-tts" or "espeak".
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
// STTProvider selects the cloud STT provider ("dashscope").
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
// DashscopeAPIKey is the Bailian API key backing STT + TTS. When empty the
// dashscope adapters return ErrNotConfigured and the chain degrades.
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
// DashscopeBaseURL is the Bailian API base; the STT/TTS service paths are
// appended to it.
DashscopeBaseURL string `env:"DASHSCOPE_BASE_URL" default:"https://dashscope.aliyuncs.com/api/v1"`
// STTModel is the DashScope speech-transcription model.
STTModel string `env:"VOICE_STT_MODEL" default:"qwen-audio-3.0-asr-flash"`
STTLanguage string `env:"VOICE_STT_LANGUAGE" default:"zh"`
// VoiceSTTTimeoutSec bounds one **IM inbound** voice transcription call
// (design/06 §1.1; slice IM-ASR). The channel bridge holds one of its inbound
// concurrency slots for the whole call, so a stuck upstream must not be able to
// drag the pipeline down. Zero or negative falls back to the bridge default
// (20s). It does not affect the Web client's voice_input path.
VoiceSTTTimeoutSec int `env:"VOICE_STT_TIMEOUT_SEC" default:"20"`
// TTSModel is the DashScope speech-synthesis model. cosyvoice-v3.5-plus and
// cosyvoice-v2 reject the HTTP SpeechSynthesizer call (engine 418); verified
// working is cosyvoice-v3-flash.
TTSModel string `env:"VOICE_TTS_MODEL" default:"cosyvoice-v3-flash"`
// TTSVoiceID selects the synthesized timbre. Voices are model-scoped (a v3
// model needs a _v3 voice). After cloning a custom voice in Bailian
// (音色复刻) fill its voice_id here.
TTSVoiceID string `env:"VOICE_TTS_VOICE_ID" default:"longxiaochun_v3"`
TTSFormat string `env:"VOICE_TTS_FORMAT" default:"wav"`
TTSSampleRate int `env:"VOICE_TTS_SAMPLE_RATE" default:"24000"`
// VoiceTTSInstruction is the emotion/tone instruction driving a cloned/designed
// TTS voice (e.g. "语气开朗俏皮,像女朋友撒娇"). cosyvoice v3 system voices (the
// long*_v3 family) require a fixed instruction format, so a free-form instruction
// is never sent for them — only for a cloned/designed voice (声音复刻).
VoiceTTSInstruction string `env:"VOICE_TTS_INSTRUCTION" default:""`
// VoiceClonePrefix is the name prefix used when creating a custom voice (声音复刻
// create_voice). It must be ≤10 alphanumeric chars; the empty value falls back
// to "cyrene". target_model stays TTSModel (must match the synthesis model).
VoiceClonePrefix string `env:"VOICE_CLONE_PREFIX" default:"cyrene"`
}
// BackgroundConfig holds the autonomous thinking settings. QuietStartHour and
// QuietEndHour bound the do-not-disturb window in local clock hours; they are the
// single source shared by the backthink service and the channel bridge's
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
// disable the window. The Push*/Kind fields tune the proactive-push frequency of
// design/11 §6 + design/12 §6 and are mapped into backthink.Config. QuietIntervalSec
// 是安静窗口内的心跳周期(design/12 §N A1):窗口只降低**周期性心跳**的频率,
// 事件驱动通路完全不受影响(§N A2)。
type BackgroundConfig struct {
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
// QuietIntervalSec 是**安静窗口内**的心跳周期(秒),默认 3600(1 小时):
// 当前时刻落在安静时段(QuietStartHour–QuietEndHour)内时,心跳改用该间隔降频,
// 窗外仍用 IntervalSec(design/12 §N A1)。**≤0 = 关闭降频**(回落 IntervalSec,
// 语义唯一真源 = internal/services/backthink/heartbeat.go 的 heartbeatInterval);
// 它不是「半夜完全不思考」的开关,见 design/12 §N A3。
QuietIntervalSec int `env:"THINK_QUIET_INTERVAL_SEC" default:"3600"`
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
}
// MemoryTidyConfig 是记忆整理(design/23-记忆整理与去重)的开关与配额旋钮。
// 它映射进 memory.TidyConfig;未暴露为 env 的项(更低频的调参)由该包取
// 设计稿默认值。所有键的默认值都取设计稿的默认/建议值(design/23 §13)。
type MemoryTidyConfig struct {
// Enabled 是总开关,默认 true(design/23 §4:可用 MEMORY_TIDY_ENABLED=false
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
// IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
// (design/23 §13 开放点 8 的建议值)。它是**兜底档**:`$DATA_DIR/config/
// memory-tidy.json` 的 `intervalSec` 可覆盖它(面板优先、`.env` 兜底。
// 2026-10-07 主人口径「`.env` 只做兜底用」),安静时段的另一档是同一文件里的
// `quietIntervalSec`(缺省 = 等于它,见 memory 侧的 intervalAt)。
IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"`
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
// backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口,
// 改为纯周期)。2026-10-07 起窗口同时是**降频档位**(安静时段用
// quietIntervalSec)与**补跑判据**(窗口内没跑过就补一轮),不再表示
// 「只有低峰才跑」。整理**从不推送**(design/23 §4)。
QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
// T0-2 与 §13 开放点 2)。它只决定「哪些对值得看一眼」,能否合并由包含
// 判定 + 规则否决决定 —— 绝不是「超过阈值就合」。
RecallThreshold float64 `env:"MEMORY_TIDY_RECALL_THRESHOLD" default:"0.80"`
// ScanLimit 是每分区每轮的扫描条数上限,默认 5000(design/23 §9)。
ScanLimit int `env:"MEMORY_TIDY_SCAN_LIMIT" default:"5000"`
// CandidateLimit 是**每分区**每轮的候选条数上限,默认 200(design/23 §9)。
// 2026-10-06 抬高:首轮真跑四个分区同时打满旧值 20 ⇒ 候选数(77)由上限而
// 不是规则决定,而命中上限的分区其候选总数(候选源)本就更多。
CandidateLimit int `env:"MEMORY_TIDY_CANDIDATE_LIMIT" default:"200"`
// CompareLimit 是**每分区**每轮真正参与两两比对的条目上限,默认 2000。
// 它是性能护栏(两两相似度 O(n²·dim)),旧实现把它写死为 400,导致
// 459 条的分区被截到 400 ⇒ compared(705) < scanned(784),观测口径被上限
// 决定。诚实的做法是抬高它并**如实**标记截断,而不是让「扫了 784 条」
// 这句话变成半真。
CompareLimit int `env:"MEMORY_TIDY_COMPARE_LIMIT" default:"2000"`
}
// SubagentConfig holds the sub-agent orchestration settings (design/05 §7).
type SubagentConfig struct {
// TimeoutSec bounds one sub-agent's whole run. It feeds the dispatch tool's
// default (orchestration.WithSpawnTimeout), overriding the scheduler's code
// default; a per-dispatch timeout_seconds argument still overrides it per call
// (裁决 02.4.4: agent_timeout 可配置,默认 10min,支持按调用覆盖). Zero or
// negative selects the code default (600s = 10 minutes).
TimeoutSec int `env:"SUBAGENT_TIMEOUT_SEC" default:"600"`
}
// PushOutletConfig configures the proactive push outlet (D1, design/12 §4/§6).
// Proactive messages are mirrored to the owner's self-hosted unified push
// service (primary); Getui (个推) is the deferred fallback for when that service
// is unavailable. The Getui* fields are reserved placeholders — the SDK is not
// integrated, so an unavailable primary logs the pending fallback rather than
// pretending to push.
type PushOutletConfig struct {
// Enabled turns the outlet hook on. When false no export is attempted.
Enabled bool `env:"PUSH_EXPORTER_ENABLED" default:"false"`
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
// Empty leaves the primary tier unconfigured.
URL string `env:"PUSH_EXPORTER_URL"`
// Token is sent as "Authorization: Bearer <token>"; it is never logged.
Token string `env:"PUSH_EXPORTER_TOKEN"`
// TimeoutSec bounds one export attempt. Zero uses the service default (5s).
TimeoutSec int `env:"PUSH_EXPORTER_TIMEOUT_SEC" default:"5"`
// Getui* are reserved for the fallback tier; the SDK is not integrated yet,
// so these values are read but unused (kept so the env contract is stable).
GetuiAppID string `env:"GETUI_APP_ID"`
GetuiAppKey string `env:"GETUI_APP_KEY"`
GetuiMasterSecret string `env:"GETUI_MASTER_SECRET"`
GetuiBaseURL string `env:"GETUI_BASE_URL"`
}
// WSHubConfig holds the WebSocket hub limits.
type WSHubConfig struct {
MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"`
SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"`
}
// MCPConfig holds Model Context Protocol server settings. The reflection-based
// loader only supports scalars and []string, so servers are supplied as a JSON
// array string (ServersJSON, env MCP_SERVERS_JSON) and parsed at startup by the
// MCP wiring (cmd/cyrene/mcp.go).
type MCPConfig struct {
ServersJSON string `env:"MCP_SERVERS_JSON"`
}
// CapacityConfig configures the awakened-capacity decision layer
// (DevDocs/design/14-唤醒式能力渐进.md §4). It is opt-in: the loop exposes every
// tool until Enabled is true.
type CapacityConfig struct {
// Enabled turns on the capacity decider. When false the loop advertises every
// registered tool (pre-existing behavior). Note: it is wired at the loop
// periphery, not back-end (Invoke stays unblocked).
Enabled bool `env:"CAPACITY_ENABLED" default:"false"`
// MinConfidence is the wake threshold below which the state is forced toward
// the persona default (设计 §2.3 默认保守). Zero selects the code default 0.6.
MinConfidence float64 `env:"CAPACITY_MIN_CONFIDENCE" default:"0.6"`
// ToolSetsJSON, when non-empty, is a JSON map of state→[]tool name, e.g.
// {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],
// "full":null}. A missing state selects the built-in default set; null ("full")
// means unrestricted. Parsed by the capacity wiring at startup (the
// reflection-based loader only supports scalars and []string), matching the
// MCP config pattern.
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
// 裁决 14.4.4, keys chosen as the landing suggestion the owner authorized).
// It costs one extra model call for an undecided turn, so it defaults to off:
// off means the layer never runs and L1/L2/L4 behave exactly as before.
L3Enabled bool `env:"CAPACITY_L3_ENABLED" default:"false"`
// L3CacheTTL is how long a model-assisted classification of the same turn text
// is reused (a Go duration string such as "5m"). Zero or unparsable disables
// reuse; empty selects the code default of 5m.
L3CacheTTL string `env:"CAPACITY_L3_CACHE_TTL" default:"5m"`
}
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
// an SSE endpoint (URL). Enabled gates activation at startup.
type MCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
}
// ChannelsConfig holds optional multi-account IM channel settings (design/16
// §4.6 §5). The single-account credentials are read from CHANNEL_<NAME>_* env
// vars in cmd/cyrene/channels.go; this block carries the optional multi-account
// registry plus the per-account allow/deny black-or-white lists via a JSON
// string (the reflection-based loader only supports scalars and []string, so a
// structured value is passed as JSON — matching the MCP and Capacity configs).
type ChannelsConfig struct {
// AccountsJSON is the JSON shape map[platform]→[]ChannelAccount, e.g.
// {"telegram":[{"id":"conf0","config":{"bot_token":"..."},
// "denyUsers":["12345"]}]}. Empty means the single-account env path applies.
AccountsJSON string `env:"CHANNELS_JSON"`
}
// ChannelAccount is one IM access config (one account) for a platform. It pairs
// an id (used to name the adapter instance and to build the platform:confId
// account key) with the platform-specific credential map and the allow/deny
// black-or-white lists applied at the channelbridge ingress (design/16 §4.6), so
// a blacklisted or non-whitelisted group/user never reaches the LLM.
type ChannelAccount struct {
// ID is the local config id, unique within its platform. It names the adapter
// instance and the platform:confId account key (design/16 §4.5 deviceID).
ID string `json:"id"`
// Config is the platform-specific credential/feature map decoded into the
// adapter's typed Config (bot_token, base_url, remote_url, ...). Missing keys
// fall back to the adapter's own zero values.
Config map[string]any `json:"config,omitempty"`
// AllowGroups, when non-empty, is a white list of group ids: a group message
// is dropped unless its GroupID is in this list.
AllowGroups []string `json:"allowGroups,omitempty"`
// DenyGroups is a black list of group ids: a group message whose GroupID is in
// this list is dropped at ingress.
DenyGroups []string `json:"denyGroups,omitempty"`
// AllowUsers, when non-empty, is a white list of user uids: a direct (private)
// message from a user not in this list is dropped.
AllowUsers []string `json:"allowUsers,omitempty"`
// DenyUsers is a black list of user uids: a direct message from a user in this
// list is dropped at ingress.
DenyUsers []string `json:"denyUsers,omitempty"`
// AdminIDs 是该账号声明的管理员 id 列表(裁决 06.4.2;design/08 §1.1、design/11 §0):
// 命中的 uid 才可能进 owner 车道,可声明多个账户。**适配器上报的角色字段不参与判主**
// ——群主/群管理员一律是 RoleMember,伪造 role=owner 无效。只有需要的渠道才声明;
// weixin-ilink 只允许与绑定微信号私聊,不需要该项。这些 id 会与 owner.yaml 的账号
// 白名单取并集,共同构成唯一的判主依据(见 runtimeconfig.AdminIDs)。
AdminIDs []string `json:"adminIds,omitempty"`
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
// value disables the group rate limit; a positive value pins it.
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
// DirectMsgPerMin 是**非主人私聊(陌生人)每分钟每发送者上限**(主人 2026-10-05
// 裁决③与裁决 13「流控时间要可配置」;design/12 §6 策略三)。零值或未配置一律走
// 保护性默认 6 条/分(陌生人必须有流控,绝不允许因漏配静默敞口);负值关闭该限制
// (主人的显式选择,桥会留一条带 source 的日志);正值固定为该值。它随 channels.json
// 的**账号顶层**字段读写,与 GroupMsgPerMin 同一口径。
DirectMsgPerMin int `json:"directMsgPerMin,omitempty"`
// MsgPerMin caps total inbound non-owner messages per minute for this account.
// Zero or negative disables it.
MsgPerMin int `json:"msgPerMin,omitempty"`
// MaxMessages is the total pending/active message cap for this account
// (design/12 §6 策略四). Zero follows the protective default (50); a negative
// value disables the cap; a positive value pins it. When the pending set nears
// this cap the review model prunes low-value messages.
MaxMessages int `json:"maxMessages,omitempty"`
// PersonaID is the role this account answers as (design/22 §2 方案 b: 渠道级绑定).
// It is a persona id from the persona library, not a display name. It only
// provides the role for the owner's private chat with this account and only when
// the main session binds no role of its own: a session binding wins over it, the
// global default (owner.yaml defaultPersona) is the final fallback, and a
// group/stranger conversation is always forced onto the default role
// (design/22 §7-4). Empty means "no channel-level binding".
PersonaID string `json:"personaId,omitempty"`
}
// Load reads configuration, merging an optional .env file (development only)
// with process environment variables. Process environment variables always take
// precedence over .env values, which take precedence over code defaults.
//
// Passing an empty envPath skips .env entirely. A missing .env file is not an
// error; only missing required values are.
func Load(envPath string) (*Config, error) {
fromEnv := map[string]string{}
if envPath != "" {
m, err := parseDotEnv(envPath)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", envPath, err)
}
fromEnv = m
}
cfg := &Config{}
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err
}
// 「未设置」与「显式 0」必须分开(见 ContextConfig.MaxTokensSet):反射解码只能
// 给出值本身,因此这里补上「这个键到底有没有被配过」。
cfg.Context.MaxTokensSet = envProvided(ContextMaxTokensEnv, fromEnv)
if cfg.LLM.ContextWindow < 0 {
return nil, fmt.Errorf("config: LLM_CONTEXT_WINDOW 不能为负(%d):0 = 未声明(按回落窗口推导)",
cfg.LLM.ContextWindow)
}
cfg.DataDir = resolveConfiguredDataDir(cfg.DataDir)
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
parts := make([]string, 0, len(errs))
for _, e := range errs {
parts = append(parts, e.Error())
}
return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; "))
}
return cfg, nil
}
// MustLoad is Load that panics on error. It is intended for startup, matching
// the "schema validation fails at boot" rule.
func MustLoad(envPath string) *Config {
cfg, err := Load(envPath)
if err != nil {
panic(err)
}
return cfg
}
// resolveConfiguredDataDir 在读取阶段把 DATA_DIR 的哨兵值换成平台标准数据
// 目录(design/13 §2,裁决 12.4.2 + 15.4.1),使「显式 `./data`」与「平台
// 标准路径」两条路都真实可达:
// - 未配置 → 标签默认 `./data`(调用方拿到的就是它,行为与历史一致);
// - `DATA_DIR=platform`(大小写不敏感)→ platform.DefaultDataDir();
// - 其余值 → 原样保留。
//
// 空串保持原样:它由组合根的兜底分支处理,语义不变。
func resolveConfiguredDataDir(raw string) string {
if strings.EqualFold(strings.TrimSpace(raw), DataDirPlatform) {
return platform.DefaultDataDir()
}
return raw
}