config.go
Rama main · réplica de lectura del repositorio original.
package graymatter
import (
"os"
"strconv"
"strings"
"time"
"github.com/angelnicolasc/graymatter/pkg/memory"
)
// EmbeddingMode controls how GrayMatter generates vector embeddings.
type EmbeddingMode int
const (
// EmbeddingAuto detects the best available provider at runtime.
// Detection order: Ollama → OpenAI → Voyage → keyword-only.
EmbeddingAuto EmbeddingMode = iota
// EmbeddingOllama forces Ollama (requires a running Ollama instance).
EmbeddingOllama
// EmbeddingAnthropic resolves to the Voyage-backed embeddings slot.
// Deprecated: Anthropic has no embeddings API; kept for numeric stability.
// Use EmbeddingVoyage with VOYAGE_API_KEY instead.
EmbeddingAnthropic
// EmbeddingKeyword disables vector search; uses keyword+recency scoring only.
EmbeddingKeyword
// EmbeddingOpenAI forces OpenAI Embeddings API (requires OPENAI_API_KEY).
EmbeddingOpenAI
// EmbeddingVoyage forces the Voyage AI Embeddings API (requires VOYAGE_API_KEY).
EmbeddingVoyage
)
// Config holds all GrayMatter configuration. All fields have sane defaults
// via DefaultConfig(). Zero-value Config is not valid — always call DefaultConfig().
type Config struct {
// ConfidenceWeight is the product preference default in [0, 0.5]. The
// compatible opt-in delivery keeps it zero pending a published rollout.
ConfidenceWeight float64
// DataDir is the directory where gray.db and vector files are stored.
// Default: ".graymatter"
DataDir string
// TopK is the maximum number of facts returned by Recall.
// Default: 8
TopK int
// EmbeddingMode controls which embedding backend is used.
// Default: EmbeddingAuto (Ollama → OpenAI → Anthropic → keyword)
EmbeddingMode EmbeddingMode
// OllamaURL is the base URL of the Ollama API.
// Default: value of GRAYMATTER_OLLAMA_URL env var, or "http://localhost:11434"
OllamaURL string
// OllamaModel is the embedding model used with Ollama.
// Default: value of GRAYMATTER_OLLAMA_MODEL env var, or "nomic-embed-text"
OllamaModel string
// OllamaConsolidateModel is the local model used for consolidation
// summarisation when ConsolidateLLM is "ollama".
// Default: value of GRAYMATTER_OLLAMA_CONSOLIDATE_MODEL env var, or "llama3.2"
OllamaConsolidateModel string
// AnthropicAPIKey for the Anthropic consolidation endpoints.
// Default: value of ANTHROPIC_API_KEY env var.
AnthropicAPIKey string
// VoyageAPIKey for the Voyage AI embeddings endpoint (api.voyageai.com),
// which is what the third slot of the embedding chain actually dials.
// Before v0.15 that slot dialled an Anthropic embeddings endpoint that
// does not exist, silently degrading every store to keyword-only.
// Default: value of VOYAGE_API_KEY env var.
VoyageAPIKey string
// OpenAIAPIKey for the OpenAI Embeddings API (text-embedding-3-small).
// Default: value of OPENAI_API_KEY env var.
OpenAIAPIKey string
// OpenAIModel overrides the OpenAI embedding model.
// Default: value of GRAYMATTER_OPENAI_MODEL env var, or "text-embedding-3-small"
OpenAIModel string
// ConsolidateLLM specifies which LLM provider drives memory consolidation.
// Values: "anthropic", "ollama", "" (disable consolidation).
// Default: "anthropic" if ANTHROPIC_API_KEY is set, else "" (disabled).
// To use Ollama as the consolidation LLM, set this field explicitly to "ollama".
ConsolidateLLM string
// ConsolidateModel is the model used for consolidation summarisation.
// Default: "claude-haiku-4-5-20251001"
ConsolidateModel string
// ConsolidateThreshold is the minimum fact count that triggers consolidation.
// Default: 20
ConsolidateThreshold int
// DecayHalfLife is the half-life for the exponential weight decay curve.
// Facts not accessed within this window lose half their retrieval weight.
// Default: 720h (30 days)
DecayHalfLife time.Duration
// AsyncConsolidate runs consolidation in a background goroutine after Remember.
// Default: true
AsyncConsolidate bool
// MaxAsyncConsolidations bounds how many consolidation goroutines may run
// concurrently. Additional triggers while at capacity are silently dropped.
// Default: 2
MaxAsyncConsolidations int
// OnConsolidateError is called when an async consolidation goroutine returns
// an error. If nil, errors are discarded. The callback must be safe for
// concurrent use.
OnConsolidateError func(agentID string, err error)
// OnVectorIndexError is called when the vector store fails to index a fact
// after the bbolt write has already succeeded. The fact is durably marked
// as pending and will be retried on the next reconcile tick or process
// restart. If nil, errors are silently retried. Must be safe for concurrent
// use.
OnVectorIndexError func(agentID, factID string, err error)
// VectorReconcileInterval controls how often the background reconciler
// drains the pending-vector queue. Vectors that failed to index inline
// (e.g. transient embedder error) are retried on this cadence.
// Default: 30s. Set to 0 to disable the background loop (reconciliation
// will then only run at Open()).
VectorReconcileInterval time.Duration
// ReadOnly opens the store in read-only mode, skipping all mutating
// operations. When false (default), the TUI and CLI automatically fall
// back to read-only if the write lock is held by another process (e.g.
// opencode running in the same directory).
ReadOnly bool
// StrictWrite disables the automatic read-only fallback: if the write
// lock cannot be acquired, Open fails immediately instead of degrading.
// The store daemon sets this — a store owner that silently came up
// read-only would break every connected client. StrictWrite wins over
// ReadOnly when both are set.
StrictWrite bool
// SignalWeights sets how much vector similarity, keyword relevance and
// recency each contribute to the fused ranking.
// Default: nil, meaning memory.DefaultSignalWeights() — vector 1.0,
// keyword 1.0, recency 0.5, the values hardcoded before v0.10.0.
SignalWeights *memory.SignalWeights
// MinRelevance drops recalled facts scoring below this fraction of the
// best score in the same result set.
// Default: 0 — no cut, Recall returns exactly TopK as it always has.
MinRelevance float64
// StemKeywords folds English morphology into the keyword signal so a
// question about "backups" reaches a fact about "backup retention". Pure
// Go, no model, no download, no state.
//
// ON by default since the measurement that earned it: on a corpus this
// change was not designed against — the scale corpus, 35 probes, 5k/10k/30k
// facts — it lifts one-query retrieval from 25/35 to 29/35 at every size,
// and the four it fixes are morphology and nothing else (backups/backup,
// rotations/rotation, releases/release, deployment/deploy). The queries it
// fails with stemming are a STRICT SUBSET of the ones it fails without: it
// wins four and loses none. That subset property is the revert criterion,
// and TestStemmingNeverLosesAQueryItWinsWithout pins it.
//
// The cost is latency on the scan path, and it is not small. Two machines
// measured it at +61%/+65% and at +90%/+90% (10k and 30k facts), so the
// durable statement is the ratio and not either point: the scan pays
// roughly TWICE, and the candidate-set path pays NOTHING — 5 ms either way
// at 10k, 11 ms at 30k, on both machines.
//
// That gap is structural, not luck. On the scan path keywordScoreDetailed
// re-tokenises every stored fact on every query, so each token of each fact
// walks the suffix rules once per query. The index stems the corpus once at
// write time and stems only the query's own terms at read time: O(N) versus
// O(query). The cost falls away entirely on the candidate-set path, which
// is default ON now — its gate was the counterpart of this default, not a
// competing optimisation. Below a few thousand facts it is single-digit
// milliseconds either way.
//
// Env: GRAYMATTER_STEM_KEYWORDS=0 opts out.
StemKeywords bool
// UsageAliasLearning lets the store promote its own vocabulary from
// observed reformulations: a weak match followed by a strong one from the
// same agent is evidence that the agent's word and the store's word mean
// the same thing, and the second independent observation of the pair
// promotes an alias. No agent action, no server-side semantics, no model.
// Off by default; see pkg/memory/usagealias.go for the guardrails.
//
// Env: GRAYMATTER_USAGE_ALIAS=1
UsageAliasLearning bool
// UsageAliasAffinityMin is how much lexical affinity an unknown word needs
// with the working word before the store promotes a usage alias: the
// minimum shared leading prefix, in characters.
//
// 0 (the zero value) means the conservative default of 3 — the
// morphology class, where co-occurrence evidence is decisive. -1 removes
// the gate and lets the store learn the synonym class as well.
//
// Measured on a blind evaluation corpus with real agent reformulations, the
// open gate is worth +2 families out of 40 and promotes about one junk
// pair in three. That is why it is reachable and not a default.
//
// Env: GRAYMATTER_USAGE_ALIAS_AFFINITY (e.g. -1)
UsageAliasAffinityMin int
// CandidateRetrieval answers a recall from an inverted index and a
// recency spine instead of loading and re-tokenising every stored fact.
// The ranking is identical — pinned by a test that runs both paths over
// one store and compares fused scores, per-signal ranks and lineage — so
// this changes what gets read, never what gets returned.
//
// ON by default since both of its gates went green on two machines: the
// 30k latency gate measured p99 20.4 ms and 21.2 ms against the 40 ms bar,
// with a harness that verifies every measured recall returned facts, and
// the write-cost bar (<= 3 ms) was ratified by the owner — a write-once,
// read-many store pays ~1.5 ms per Put to keep every read sub-linear.
// A store written before this default keeps answering: the first recall
// rebuilds the index and the ranking does not change.
//
// Env: GRAYMATTER_CANDIDATE_RETRIEVAL=0 opts out.
CandidateRetrieval bool
}
// DefaultConfig returns a Config with all defaults applied from environment
// variables and runtime probes. Safe to call multiple times.
func DefaultConfig() Config {
return Config{
ConfidenceWeight: memory.DefaultConfidenceWeight,
DataDir: ".graymatter",
TopK: 8,
EmbeddingMode: EmbeddingAuto,
OllamaURL: envOrDefault("GRAYMATTER_OLLAMA_URL", "http://localhost:11434"),
OllamaModel: envOrDefault("GRAYMATTER_OLLAMA_MODEL", "nomic-embed-text"),
OllamaConsolidateModel: envOrDefault("GRAYMATTER_OLLAMA_CONSOLIDATE_MODEL", "llama3.2"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
VoyageAPIKey: os.Getenv("VOYAGE_API_KEY"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"),
OpenAIModel: envOrDefault("GRAYMATTER_OPENAI_MODEL", "text-embedding-3-small"),
ConsolidateLLM: resolveConsolidateLLM(),
ConsolidateModel: "claude-haiku-4-5-20251001",
ConsolidateThreshold: 20,
VectorReconcileInterval: 30 * time.Second,
DecayHalfLife: 720 * time.Hour,
AsyncConsolidate: true,
MaxAsyncConsolidations: 2,
StemKeywords: envBoolDefault("GRAYMATTER_STEM_KEYWORDS", true),
UsageAliasLearning: envBool("GRAYMATTER_USAGE_ALIAS"),
UsageAliasAffinityMin: envInt("GRAYMATTER_USAGE_ALIAS_AFFINITY"),
CandidateRetrieval: envBoolDefault("GRAYMATTER_CANDIDATE_RETRIEVAL", true),
}
}
// resolveConsolidateLLM returns the best available consolidation LLM based on
// environment variables. It does NOT probe network endpoints at startup.
//
// Detection order:
// 1. "anthropic" — if ANTHROPIC_API_KEY is set
// 2. "" — disabled (set ConsolidateLLM="ollama" explicitly for Ollama)
//
// Ollama is excluded from auto-detection because probing the Ollama endpoint on
// every process startup would add 500 ms+ of latency. Configure it explicitly:
//
// cfg := graymatter.DefaultConfig()
// cfg.ConsolidateLLM = "ollama"
func resolveConsolidateLLM() string {
if os.Getenv("ANTHROPIC_API_KEY") != "" {
return "anthropic"
}
return ""
}
func envOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// envInt reads a signed integer knob. Unset or unparseable is 0, which every
// consumer reads as "use the built-in default" — so a typo degrades to the
// documented behaviour instead of to a number nobody chose.
func envInt(key string) int {
v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(key)))
if err != nil {
return 0
}
return v
}
// envBool reads an opt-in switch: unset means off, and only an explicit yes
// turns it on.
//
// DefaultConfig is the single place the daemon, the CLI's --no-daemon path,
// the MCP server, the REST server and the harness all build their store from,
// so wiring a switch there reaches every surface at once — and keeps the
// README's promise intact, since an environment variable is not a config file.
func envBool(key string) bool {
return envBoolDefault(key, false)
}
// envBoolDefault reads a switch that has a default in either direction.
//
// envBool alone cannot express a default-on flag: it maps everything that is
// not an explicit yes to false, so "unset" and "0" are the same answer and a
// flag flipped to default-on becomes impossible to turn off. That is the same
// shape as the affinity knob nobody could reach and the memory_alias that
// resolved nowhere — a setting that exists in the documentation and not in the
// product. Three states, so an opt-out is sayable.
func envBoolDefault(key string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
}
return def
}
// Config implements memory.ConsolidateConfig so it can be passed directly
// to Store.Consolidate / Store.MaybeConsolidate without an adapter.
func (c Config) GetAnthropicAPIKey() string { return c.AnthropicAPIKey }
func (c Config) GetConsolidateLLM() string { return c.ConsolidateLLM }
func (c Config) GetConsolidateModel() string { return c.ConsolidateModel }
func (c Config) GetOllamaURL() string { return c.OllamaURL }
func (c Config) GetOllamaConsolidateModel() string { return c.OllamaConsolidateModel }
func (c Config) GetConsolidateThreshold() int { return c.ConsolidateThreshold }
func (c Config) GetDecayHalfLife() time.Duration { return c.DecayHalfLife }