first commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// Package config implements pigo's optional user config file at
|
||||
// ~/.config/pigo/config.toml (honoring $XDG_CONFIG_HOME when set) plus the
|
||||
// provider-agnostic base-url env-var name derivation. Values in the file
|
||||
// replace pigo's built-in defaults, but an explicit command-line flag always
|
||||
// wins over the file:
|
||||
//
|
||||
// command-line flag > config.toml > built-in default
|
||||
//
|
||||
// A missing file is not an error (defaults apply); a malformed file is surfaced
|
||||
// to the caller so it can warn rather than silently ignore user intent.
|
||||
//
|
||||
// The package is intentionally free of any cliOptions/run-assembly concern: it
|
||||
// only loads and decodes the file and derives env-var names. Overlaying a
|
||||
// FileConfig onto the parsed CLI options lives in cmd/pigo, alongside the
|
||||
// options struct it mutates.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// FileConfig is the on-disk shape of config.toml. Every field is optional; an
|
||||
// absent (zero-value) field leaves the corresponding default/flag untouched.
|
||||
// Keys are snake_case to read naturally in TOML.
|
||||
type FileConfig struct {
|
||||
Model string `toml:"model"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Protocol string `toml:"protocol"`
|
||||
Provider string `toml:"provider"`
|
||||
ThinkingLevel string `toml:"thinking_level"`
|
||||
OutputFormat string `toml:"output_format"`
|
||||
NoTools bool `toml:"no_tools"`
|
||||
NoSkills bool `toml:"no_skills"`
|
||||
Approve bool `toml:"approve"`
|
||||
SystemPrompt string `toml:"system_prompt"`
|
||||
// AllowedTools and DisallowedTools are the tool-level admission boundary:
|
||||
// the config-file tier of --allowed-tools / --disallowed-tools. Names match
|
||||
// case-insensitively and DisallowedTools wins when a name appears in both.
|
||||
// A CLI flag replaces the file value wholesale rather than merging with it,
|
||||
// so passing --allowed-tools can widen a boundary the file narrowed.
|
||||
AllowedTools []string `toml:"allowed_tools"`
|
||||
DisallowedTools []string `toml:"disallowed_tools"`
|
||||
// Prompts is the config.toml `prompts` array: paths (files or dirs) to load
|
||||
// prompt templates from at the settings tier (mirrors pi's settings prompts).
|
||||
Prompts []string `toml:"prompts"`
|
||||
// Memory, Checkpoint, and Compaction are nested TOML tables for the
|
||||
// persistent-memory / infinite-context feature. They are pure config
|
||||
// plumbing here; defaults/parsing live in memory.go (Resolve* helpers) and
|
||||
// the overlay into runtime options lives in cmd/pigo. See
|
||||
// tasks/spec-persistent-memory-infinite-context.md §3/§4/§5.2.
|
||||
Memory MemoryConfig `toml:"memory"`
|
||||
Checkpoint CheckpointConfig `toml:"checkpoint"`
|
||||
Compaction CompactionConfig `toml:"compaction"`
|
||||
// Dream is the [dream] TOML table for the /dream memory-consolidation
|
||||
// feature. Pure config plumbing here; defaults/normalization live in
|
||||
// internal/dream (Config). See tasks/spec-dream-memory-consolidation.md
|
||||
// §3.3.
|
||||
Dream DreamConfig `toml:"dream"`
|
||||
}
|
||||
|
||||
// DreamConfig is the [dream] TOML table for /dream memory consolidation.
|
||||
// Enabled is a pointer so an absent key (nil) is distinguishable from an
|
||||
// explicit false: nil is treated as true, only enabled = false disables
|
||||
// auto-trigger. IntervalDays and RecentSessions use zero as "apply default"
|
||||
// (7 and 20 respectively); normalization lives in dream.Config.
|
||||
type DreamConfig struct {
|
||||
Enabled *bool `toml:"enabled"`
|
||||
IntervalDays int `toml:"interval_days"`
|
||||
RecentSessions int `toml:"recent_sessions"`
|
||||
}
|
||||
|
||||
// FileConfigPath returns the path to the user config file:
|
||||
// $XDG_CONFIG_HOME/pigo/config.toml, or ~/.config/pigo/config.toml by default.
|
||||
// It returns "" when neither can be resolved, so the caller treats the file as
|
||||
// absent.
|
||||
func FileConfigPath() string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "pigo", "config.toml")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "pigo", "config.toml")
|
||||
}
|
||||
|
||||
// LoadFileConfig reads and decodes config.toml. A missing file (or an empty
|
||||
// path) returns a zero config with no error; a malformed file is an error.
|
||||
func LoadFileConfig(path string) (FileConfig, error) {
|
||||
if path == "" {
|
||||
return FileConfig{}, nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return FileConfig{}, nil
|
||||
}
|
||||
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
||||
}
|
||||
var cfg FileConfig
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return FileConfig{}, fmt.Errorf("parse config %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GenericBaseURLEnvVar derives the generic base-url override env var name for a
|
||||
// provider: the provider name uppercased with hyphens rewritten to underscores,
|
||||
// suffixed with _BASE_URL. For example "zai-coding-cn" → "ZAI_CODING_CN_BASE_URL"
|
||||
// and "deepseek" → "DEEPSEEK_BASE_URL". An empty provider name yields "".
|
||||
//
|
||||
// It lives here (not with ResolveBaseURL) because it is a pure name derivation
|
||||
// with no dependency on the provider registry — the provider-agnostic part of
|
||||
// base-url resolution. ResolveBaseURL itself lives in internal/provider.
|
||||
func GenericBaseURLEnvVar(providerName string) string {
|
||||
n := strings.TrimSpace(providerName)
|
||||
if n == "" {
|
||||
return ""
|
||||
}
|
||||
n = strings.ReplaceAll(n, "-", "_")
|
||||
return strings.ToUpper(n) + "_BASE_URL"
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileConfigPath_XDGOverride(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdgroot")
|
||||
got := FileConfigPath()
|
||||
want := filepath.Join("/tmp/xdgroot", "pigo", "config.toml")
|
||||
if got != want {
|
||||
t.Fatalf("FileConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigPath_DefaultHome(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
got := FileConfigPath()
|
||||
want := filepath.Join(home, ".config", "pigo", "config.toml")
|
||||
if got != want {
|
||||
t.Fatalf("FileConfigPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_Missing(t *testing.T) {
|
||||
cfg, err := LoadFileConfig(filepath.Join(t.TempDir(), "does-not-exist.toml"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing file should not error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg, FileConfig{}) {
|
||||
t.Fatalf("missing file should yield zero config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_EmptyPath(t *testing.T) {
|
||||
cfg, err := LoadFileConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("empty path should not error, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg, FileConfig{}) {
|
||||
t.Fatalf("empty path should yield zero config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_Valid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
content := `
|
||||
model = "claude-opus-4-8"
|
||||
base_url = "https://example.com"
|
||||
api_key = "sk-test"
|
||||
protocol = "anthropic"
|
||||
provider = "deepseek"
|
||||
thinking_level = "high"
|
||||
output_format = "stream-json"
|
||||
no_tools = true
|
||||
no_skills = true
|
||||
approve = true
|
||||
system_prompt = "be terse"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("valid file should parse, got %v", err)
|
||||
}
|
||||
want := FileConfig{
|
||||
Model: "claude-opus-4-8",
|
||||
BaseURL: "https://example.com",
|
||||
APIKey: "sk-test",
|
||||
Protocol: "anthropic",
|
||||
Provider: "deepseek",
|
||||
ThinkingLevel: "high",
|
||||
OutputFormat: "stream-json",
|
||||
NoTools: true,
|
||||
NoSkills: true,
|
||||
Approve: true,
|
||||
SystemPrompt: "be terse",
|
||||
}
|
||||
if !reflect.DeepEqual(cfg, want) {
|
||||
t.Fatalf("parsed config = %+v, want %+v", cfg, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_Malformed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.toml")
|
||||
if err := os.WriteFile(path, []byte("model = = ="), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadFileConfig(path); err == nil {
|
||||
t.Fatal("malformed file should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfigPromptsArray(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
content := "prompts = [\"./my-prompts\", \"/abs/x.md\"]\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
if len(cfg.Prompts) != 2 || cfg.Prompts[0] != "./my-prompts" || cfg.Prompts[1] != "/abs/x.md" {
|
||||
t.Errorf("Prompts = %v, want [./my-prompts /abs/x.md]", cfg.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenericBaseURLEnvVar verifies the <PROVIDER>_BASE_URL name derivation,
|
||||
// especially the hyphen→underscore conversion and uppercasing.
|
||||
func TestGenericBaseURLEnvVar(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"deepseek", "DEEPSEEK_BASE_URL"},
|
||||
{"zai-coding-cn", "ZAI_CODING_CN_BASE_URL"},
|
||||
{"vercel-ai-gateway", "VERCEL_AI_GATEWAY_BASE_URL"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := GenericBaseURLEnvVar(c.name); got != c.want {
|
||||
t.Errorf("GenericBaseURLEnvVar(%q) = %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_DreamTable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
content := `
|
||||
[dream]
|
||||
enabled = false
|
||||
interval_days = 14
|
||||
recent_sessions = 50
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
if cfg.Dream.Enabled == nil || *cfg.Dream.Enabled {
|
||||
t.Errorf("Dream.Enabled = %v, want explicit false", cfg.Dream.Enabled)
|
||||
}
|
||||
if cfg.Dream.IntervalDays != 14 {
|
||||
t.Errorf("Dream.IntervalDays = %d, want 14", cfg.Dream.IntervalDays)
|
||||
}
|
||||
if cfg.Dream.RecentSessions != 50 {
|
||||
t.Errorf("Dream.RecentSessions = %d, want 50", cfg.Dream.RecentSessions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_DreamTableAbsent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("model = \"foo\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
// Absent [dream] table: Enabled pointer nil (→ default true downstream),
|
||||
// ints zero (→ defaults downstream). Parsing must not error.
|
||||
if cfg.Dream.Enabled != nil || cfg.Dream.IntervalDays != 0 || cfg.Dream.RecentSessions != 0 {
|
||||
t.Errorf("absent dream table = %+v, want zero-value", cfg.Dream)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Memory/checkpoint/compaction config: the nested TOML tables [memory],
|
||||
// [checkpoint], and compaction.max_context (spec-persistent-memory-infinite-
|
||||
// context §3/§4/§5.2). This file is pure config plumbing — parse, defaults, and
|
||||
// resolve helpers only. The actual memory Store, checkpoint persistence, and
|
||||
// compaction trigger live in later layers (internal/memory, internal/runtime,
|
||||
// internal/compaction) and consume the resolved values overlaid in cmd/pigo.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Built-in defaults for the [memory] table. Exposed so overlay and tests share
|
||||
// one source of truth (mirrors the flat-config defaults documented in main.go).
|
||||
const (
|
||||
DefaultMemoryEnabled = true
|
||||
DefaultMemoryReconcileOnSearch = true
|
||||
DefaultMemorySearchScoreFloor = 0.15
|
||||
DefaultMemoryCCIndex = false
|
||||
)
|
||||
|
||||
// DefaultCheckpointThresholds is the built-in compaction trigger ladder as
|
||||
// percentage strings; ResolveThresholds parses them into fractions in (0,1].
|
||||
func DefaultCheckpointThresholds() []string {
|
||||
return []string{"40%", "60%", "80%"}
|
||||
}
|
||||
|
||||
// MemoryConfig is the [memory] TOML table. Bool fields whose default is true
|
||||
// (Enabled, ReconcileOnSearch) and the float ScoreFloor use pointers so an
|
||||
// absent key is distinguishable from an explicit false/0 — nil means "apply the
|
||||
// default", which is what makes memory.enabled=false representable and
|
||||
// default-safe. CCIndex defaults to false, so a plain bool suffices.
|
||||
type MemoryConfig struct {
|
||||
Enabled *bool `toml:"enabled"`
|
||||
ReconcileOnSearch *bool `toml:"reconcile_on_search"`
|
||||
SearchScoreFloor *float64 `toml:"search_score_floor"`
|
||||
CCIndex bool `toml:"cc_index"`
|
||||
}
|
||||
|
||||
// ResolvedMemory is MemoryConfig with defaults applied and the score floor
|
||||
// clamped to [0,1]. It is the shape downstream memory wiring consumes.
|
||||
type ResolvedMemory struct {
|
||||
Enabled bool
|
||||
ReconcileOnSearch bool
|
||||
SearchScoreFloor float64
|
||||
CCIndex bool
|
||||
}
|
||||
|
||||
// Resolve applies the [memory] defaults: absent keys fall back to
|
||||
// true/true/0.15/false; an explicit search_score_floor outside [0,1] is clamped
|
||||
// into range.
|
||||
func (m MemoryConfig) Resolve() ResolvedMemory {
|
||||
r := ResolvedMemory{
|
||||
Enabled: DefaultMemoryEnabled,
|
||||
ReconcileOnSearch: DefaultMemoryReconcileOnSearch,
|
||||
SearchScoreFloor: DefaultMemorySearchScoreFloor,
|
||||
CCIndex: m.CCIndex,
|
||||
}
|
||||
if m.Enabled != nil {
|
||||
r.Enabled = *m.Enabled
|
||||
}
|
||||
if m.ReconcileOnSearch != nil {
|
||||
r.ReconcileOnSearch = *m.ReconcileOnSearch
|
||||
}
|
||||
if m.SearchScoreFloor != nil {
|
||||
f := *m.SearchScoreFloor
|
||||
switch {
|
||||
case f < 0:
|
||||
f = 0
|
||||
case f > 1:
|
||||
f = 1
|
||||
}
|
||||
r.SearchScoreFloor = f
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// IntOrString accepts either a TOML integer or string for the optional
|
||||
// [checkpoint].reserved key (e.g. reserved = 4096 or reserved = "10%"). Set
|
||||
// reports whether the key was present; IsInt selects the populated field.
|
||||
type IntOrString struct {
|
||||
Set bool
|
||||
IsInt bool
|
||||
Int int
|
||||
Str string
|
||||
}
|
||||
|
||||
// UnmarshalTOML implements toml.Unmarshaler so a bare int or a quoted string
|
||||
// both decode without failing the whole file.
|
||||
func (v *IntOrString) UnmarshalTOML(data any) error {
|
||||
v.Set = true
|
||||
switch t := data.(type) {
|
||||
case int64:
|
||||
v.IsInt, v.Int = true, int(t)
|
||||
case int:
|
||||
v.IsInt, v.Int = true, t
|
||||
case float64:
|
||||
v.IsInt, v.Int = true, int(t)
|
||||
case string:
|
||||
v.Str = t
|
||||
default:
|
||||
return fmt.Errorf("reserved: unsupported type %T (want int or string)", data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckpointConfig is the [checkpoint] TOML table. push_caps is a nested table
|
||||
// of per-section token caps (e.g. [checkpoint.push_caps] with memory = 800,
|
||||
// recall = 1200), modeled as a map.
|
||||
type CheckpointConfig struct {
|
||||
Thresholds []string `toml:"thresholds"`
|
||||
Reserved IntOrString `toml:"reserved"`
|
||||
PushCaps map[string]int `toml:"push_caps"`
|
||||
}
|
||||
|
||||
// ResolveThresholds parses the configured threshold percentage strings into
|
||||
// fractions in (0,1], skipping out-of-range/unparseable entries. An empty list
|
||||
// — or one where every entry is invalid — falls back to the built-in defaults.
|
||||
func (c CheckpointConfig) ResolveThresholds() []float64 {
|
||||
src := c.Thresholds
|
||||
if len(src) == 0 {
|
||||
src = DefaultCheckpointThresholds()
|
||||
}
|
||||
out := parseThresholds(src)
|
||||
if len(out) == 0 {
|
||||
out = parseThresholds(DefaultCheckpointThresholds())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseThresholds(ss []string) []float64 {
|
||||
var out []float64
|
||||
for _, s := range ss {
|
||||
if f, ok := ParseThresholdFraction(s); ok {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseThresholdFraction parses a percentage string like "80%" into a fraction
|
||||
// in (0,1]. Values outside that range (<=0, >100%) or lacking a % suffix are
|
||||
// rejected with ok=false so callers fall back to a default.
|
||||
func ParseThresholdFraction(s string) (float64, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasSuffix(s, "%") {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(s, "%")), 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f := n / 100
|
||||
if f <= 0 || f > 1 {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
|
||||
// CompactionConfig is the [compaction] TOML table. Only max_context is wired
|
||||
// here; it lowers the auto-compaction trigger point and is always clamped by
|
||||
// the provider window by the consumer.
|
||||
type CompactionConfig struct {
|
||||
MaxContext string `toml:"max_context"`
|
||||
}
|
||||
|
||||
// ResolveMaxContext parses the max_context string form. An empty value yields
|
||||
// an unset MaxContext (no error).
|
||||
func (c CompactionConfig) ResolveMaxContext() (MaxContext, error) {
|
||||
return ParseMaxContext(c.MaxContext)
|
||||
}
|
||||
|
||||
// MaxContext is a parsed compaction.max_context value: either an absolute token
|
||||
// count or a fraction of the provider window. The zero value is "unset" and
|
||||
// Resolve returns 0. Resolve is a pure function; the provider-limit clamp is
|
||||
// applied by the consumer.
|
||||
type MaxContext struct {
|
||||
set bool
|
||||
fraction float64 // >0 for a "N%" form
|
||||
tokens int // absolute token count when fraction == 0
|
||||
}
|
||||
|
||||
// IsSet reports whether max_context was configured.
|
||||
func (m MaxContext) IsSet() bool { return m.set }
|
||||
|
||||
// Resolve returns the token budget for the given provider window: window*
|
||||
// fraction (rounded) for a percentage form, or the absolute token count
|
||||
// otherwise. An unset value returns 0. This is intentionally unclamped — the
|
||||
// consumer applies the provider-limit clamp.
|
||||
func (m MaxContext) Resolve(window int) int {
|
||||
if !m.set {
|
||||
return 0
|
||||
}
|
||||
if m.fraction > 0 {
|
||||
return int(float64(window)*m.fraction + 0.5)
|
||||
}
|
||||
return m.tokens
|
||||
}
|
||||
|
||||
// ParseMaxContext parses the accepted max_context forms: a plain token count
|
||||
// ("300000"), a K/M-suffixed count ("300K", "1M", case-insensitive, fractions
|
||||
// allowed like "1.5M"), or a percentage of the provider window ("50%"). An
|
||||
// empty string is unset (no error); other malformed or non-positive values are
|
||||
// errors.
|
||||
func ParseMaxContext(s string) (MaxContext, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return MaxContext{}, nil
|
||||
}
|
||||
if strings.HasSuffix(s, "%") {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(s, "%")), 64)
|
||||
if err != nil {
|
||||
return MaxContext{}, fmt.Errorf("max_context: invalid percent %q: %w", s, err)
|
||||
}
|
||||
f := n / 100
|
||||
if f <= 0 || f > 1 {
|
||||
return MaxContext{}, fmt.Errorf("max_context: percent out of range %q (want (0%%,100%%])", s)
|
||||
}
|
||||
return MaxContext{set: true, fraction: f}, nil
|
||||
}
|
||||
mult := 1.0
|
||||
body := s
|
||||
switch last := s[len(s)-1]; last {
|
||||
case 'k', 'K':
|
||||
mult, body = 1_000, s[:len(s)-1]
|
||||
case 'm', 'M':
|
||||
mult, body = 1_000_000, s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(body), 64)
|
||||
if err != nil {
|
||||
return MaxContext{}, fmt.Errorf("max_context: invalid token count %q: %w", s, err)
|
||||
}
|
||||
if n <= 0 {
|
||||
return MaxContext{}, fmt.Errorf("max_context: must be positive %q", s)
|
||||
}
|
||||
return MaxContext{set: true, tokens: int(n*mult + 0.5)}, nil
|
||||
}
|
||||
|
||||
// MemorySettings bundles the resolved [memory]/[checkpoint]/[compaction] config
|
||||
// for overlay into runtime options: defaults applied, string forms pre-parsed.
|
||||
// It is produced by FileConfig.ResolveMemorySettings and is always well-formed
|
||||
// (an invalid max_context is treated as unset rather than failing the overlay).
|
||||
type MemorySettings struct {
|
||||
Memory ResolvedMemory
|
||||
CheckpointThresholds []float64
|
||||
CheckpointReserved IntOrString
|
||||
CheckpointPushCaps map[string]int
|
||||
MaxContext MaxContext
|
||||
}
|
||||
|
||||
// ResolveMemorySettings resolves the three nested tables into MemorySettings,
|
||||
// applying defaults. It never fails: an unparseable compaction.max_context is
|
||||
// dropped to unset so a single bad key cannot break config overlay.
|
||||
func (c FileConfig) ResolveMemorySettings() MemorySettings {
|
||||
mc, _ := c.Compaction.ResolveMaxContext()
|
||||
return MemorySettings{
|
||||
Memory: c.Memory.Resolve(),
|
||||
CheckpointThresholds: c.Checkpoint.ResolveThresholds(),
|
||||
CheckpointReserved: c.Checkpoint.Reserved,
|
||||
CheckpointPushCaps: c.Checkpoint.PushCaps,
|
||||
MaxContext: mc,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boolPtr / floatPtr are test helpers for the pointer-valued MemoryConfig fields.
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
func floatPtr(f float64) *float64 { return &f }
|
||||
|
||||
func TestMemoryConfig_ResolveDefaults(t *testing.T) {
|
||||
got := MemoryConfig{}.Resolve()
|
||||
want := ResolvedMemory{
|
||||
Enabled: true,
|
||||
ReconcileOnSearch: true,
|
||||
SearchScoreFloor: 0.15,
|
||||
CCIndex: false,
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("Resolve() defaults = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryConfig_ResolveOverrides(t *testing.T) {
|
||||
got := MemoryConfig{
|
||||
Enabled: boolPtr(false),
|
||||
ReconcileOnSearch: boolPtr(false),
|
||||
SearchScoreFloor: floatPtr(0.5),
|
||||
CCIndex: true,
|
||||
}.Resolve()
|
||||
want := ResolvedMemory{
|
||||
Enabled: false,
|
||||
ReconcileOnSearch: false,
|
||||
SearchScoreFloor: 0.5,
|
||||
CCIndex: true,
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("Resolve() overrides = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// memory.enabled=false must be representable and distinct from "absent".
|
||||
func TestMemoryConfig_EnabledFalseRepresentable(t *testing.T) {
|
||||
if r := (MemoryConfig{Enabled: boolPtr(false)}).Resolve(); r.Enabled {
|
||||
t.Fatal("explicit enabled=false should resolve to false")
|
||||
}
|
||||
if r := (MemoryConfig{}).Resolve(); !r.Enabled {
|
||||
t.Fatal("absent enabled should default to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryConfig_ScoreFloorClamp(t *testing.T) {
|
||||
if r := (MemoryConfig{SearchScoreFloor: floatPtr(-1)}).Resolve(); r.SearchScoreFloor != 0 {
|
||||
t.Errorf("negative score floor should clamp to 0, got %v", r.SearchScoreFloor)
|
||||
}
|
||||
if r := (MemoryConfig{SearchScoreFloor: floatPtr(2)}).Resolve(); r.SearchScoreFloor != 1 {
|
||||
t.Errorf("score floor >1 should clamp to 1, got %v", r.SearchScoreFloor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseThresholdFraction(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{"40%", 0.40, true},
|
||||
{"60%", 0.60, true},
|
||||
{"80%", 0.80, true},
|
||||
{"100%", 1.0, true},
|
||||
{" 75% ", 0.75, true},
|
||||
{"0%", 0, false}, // out of range (must be >0)
|
||||
{"120%", 0, false}, // out of range (>100%)
|
||||
{"-10%", 0, false},
|
||||
{"80", 0, false}, // missing %
|
||||
{"abc%", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseThresholdFraction(c.in)
|
||||
if ok != c.ok || (ok && got != c.want) {
|
||||
t.Errorf("ParseThresholdFraction(%q) = (%v,%v), want (%v,%v)", c.in, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointConfig_ResolveThresholds(t *testing.T) {
|
||||
// Absent → defaults 40/60/80%.
|
||||
if got := (CheckpointConfig{}).ResolveThresholds(); !reflect.DeepEqual(got, []float64{0.40, 0.60, 0.80}) {
|
||||
t.Errorf("default thresholds = %v, want [0.4 0.6 0.8]", got)
|
||||
}
|
||||
// Explicit override.
|
||||
if got := (CheckpointConfig{Thresholds: []string{"50%", "90%"}}).ResolveThresholds(); !reflect.DeepEqual(got, []float64{0.50, 0.90}) {
|
||||
t.Errorf("override thresholds = %v, want [0.5 0.9]", got)
|
||||
}
|
||||
// Out-of-range entries skipped, valid kept.
|
||||
if got := (CheckpointConfig{Thresholds: []string{"120%", "70%"}}).ResolveThresholds(); !reflect.DeepEqual(got, []float64{0.70}) {
|
||||
t.Errorf("mixed thresholds = %v, want [0.7]", got)
|
||||
}
|
||||
// All invalid → fall back to defaults.
|
||||
if got := (CheckpointConfig{Thresholds: []string{"nope", "0%"}}).ResolveThresholds(); !reflect.DeepEqual(got, []float64{0.40, 0.60, 0.80}) {
|
||||
t.Errorf("all-invalid thresholds = %v, want defaults", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaxContext(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
set bool
|
||||
window int
|
||||
want int
|
||||
}{
|
||||
{"", false, 200000, 0},
|
||||
{"300000", true, 200000, 300000},
|
||||
{"300K", true, 200000, 300000},
|
||||
{"1M", true, 200000, 1000000},
|
||||
{"1m", true, 200000, 1000000},
|
||||
{"1.5M", true, 200000, 1500000},
|
||||
{"50%", true, 200000, 100000},
|
||||
{"25%", true, 400000, 100000},
|
||||
}
|
||||
for _, c := range cases {
|
||||
mc, err := ParseMaxContext(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseMaxContext(%q) unexpected error: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if mc.IsSet() != c.set {
|
||||
t.Errorf("ParseMaxContext(%q).IsSet() = %v, want %v", c.in, mc.IsSet(), c.set)
|
||||
}
|
||||
if got := mc.Resolve(c.window); got != c.want {
|
||||
t.Errorf("ParseMaxContext(%q).Resolve(%d) = %d, want %d", c.in, c.window, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaxContext_Invalid(t *testing.T) {
|
||||
for _, in := range []string{"0%", "150%", "-100%", "abc", "0", "-5", "12x"} {
|
||||
if _, err := ParseMaxContext(in); err == nil {
|
||||
t.Errorf("ParseMaxContext(%q) should error", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_MemoryTables(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
content := `
|
||||
[memory]
|
||||
enabled = false
|
||||
reconcile_on_search = false
|
||||
search_score_floor = 0.3
|
||||
cc_index = true
|
||||
|
||||
[checkpoint]
|
||||
thresholds = ["50%", "70%"]
|
||||
reserved = 4096
|
||||
|
||||
[checkpoint.push_caps]
|
||||
memory = 800
|
||||
recall = 1200
|
||||
|
||||
[compaction]
|
||||
max_context = "300K"
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
|
||||
mem := cfg.Memory.Resolve()
|
||||
if mem.Enabled || mem.ReconcileOnSearch || !mem.CCIndex || mem.SearchScoreFloor != 0.3 {
|
||||
t.Errorf("memory resolved = %+v", mem)
|
||||
}
|
||||
if got := cfg.Checkpoint.ResolveThresholds(); !reflect.DeepEqual(got, []float64{0.50, 0.70}) {
|
||||
t.Errorf("thresholds = %v, want [0.5 0.7]", got)
|
||||
}
|
||||
if !cfg.Checkpoint.Reserved.Set || !cfg.Checkpoint.Reserved.IsInt || cfg.Checkpoint.Reserved.Int != 4096 {
|
||||
t.Errorf("reserved = %+v, want int 4096", cfg.Checkpoint.Reserved)
|
||||
}
|
||||
if cfg.Checkpoint.PushCaps["memory"] != 800 || cfg.Checkpoint.PushCaps["recall"] != 1200 {
|
||||
t.Errorf("push_caps = %v", cfg.Checkpoint.PushCaps)
|
||||
}
|
||||
mc, err := cfg.Compaction.ResolveMaxContext()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMaxContext: %v", err)
|
||||
}
|
||||
if got := mc.Resolve(200000); got != 300000 {
|
||||
t.Errorf("max_context resolve = %d, want 300000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig_ReservedString(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte("[checkpoint]\nreserved = \"10%\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
if !cfg.Checkpoint.Reserved.Set || cfg.Checkpoint.Reserved.IsInt || cfg.Checkpoint.Reserved.Str != "10%" {
|
||||
t.Errorf("reserved = %+v, want string 10%%", cfg.Checkpoint.Reserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Absent tables → default-safe resolved settings.
|
||||
func TestResolveMemorySettings_Defaults(t *testing.T) {
|
||||
ms := FileConfig{}.ResolveMemorySettings()
|
||||
if !ms.Memory.Enabled || !ms.Memory.ReconcileOnSearch || ms.Memory.SearchScoreFloor != 0.15 || ms.Memory.CCIndex {
|
||||
t.Errorf("default memory = %+v", ms.Memory)
|
||||
}
|
||||
if !reflect.DeepEqual(ms.CheckpointThresholds, []float64{0.40, 0.60, 0.80}) {
|
||||
t.Errorf("default thresholds = %v", ms.CheckpointThresholds)
|
||||
}
|
||||
if ms.MaxContext.IsSet() {
|
||||
t.Errorf("default max_context should be unset")
|
||||
}
|
||||
if ms.CheckpointReserved.Set {
|
||||
t.Errorf("default reserved should be unset")
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid max_context must not fail the overlay — it drops to unset.
|
||||
func TestResolveMemorySettings_InvalidMaxContextIgnored(t *testing.T) {
|
||||
ms := FileConfig{Compaction: CompactionConfig{MaxContext: "garbage"}}.ResolveMemorySettings()
|
||||
if ms.MaxContext.IsSet() {
|
||||
t.Errorf("invalid max_context should resolve to unset, got set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user