package serverstack import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "time" "github.com/gofrs/flock" "go.uber.org/zap" "github.com/zzet/gortex/internal/astquery" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/contracts" "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/embedding" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" gortexmcp "github.com/zzet/gortex/internal/mcp" "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/parser/languages" "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/savings" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/semantic/goanalysis" "github.com/zzet/gortex/internal/semantic/lsp" "github.com/zzet/gortex/internal/semantic/scip" "github.com/zzet/gortex/internal/semantic/tstypes" "github.com/zzet/gortex/internal/telemetry" ) // Lifecycle selects the backend default, whether warm-restart/snapshot // machinery the entry point wires is appropriate, and the store-lock // posture. type Lifecycle int const ( // LifecycleDaemon is the durable, long-lived daemon: sqlite default, // cross-process store lock. LifecycleDaemon Lifecycle = iota // LifecycleHTTP is the daemon's HTTP surface (gortex daemon --http): // durable, sqlite default, store lock. LifecycleHTTP // LifecycleOneshot is the ephemeral embedded server: memory-only, // FileStore snapshot, no store lock. LifecycleOneshot ) // Writable reports whether the lifecycle owns a durable on-disk store // (and therefore takes the cross-process store lock). func (l Lifecycle) Writable() bool { return l == LifecycleDaemon || l == LifecycleHTTP } // defaultBackend resolves the backend name for an empty cfg.Backend. func (l Lifecycle) defaultBackend() string { if l == LifecycleOneshot { return "memory" } return "sqlite" } // SharedServerConfig carries the knobs that vary between the daemon, the // HTTP surface, and the one-shot embedded path. The first block is the // authoritative public surface consumers cite; the rest are // entry-point-resolved options threaded through with the already-loaded // config rather than re-derived here. type SharedServerConfig struct { Lifecycle Lifecycle // backend default + store-lock posture Index string // workspace root the indexer/LSP/bind anchor at Backend string // "" resolves via Lifecycle; else memory|sqlite BackendPath string // "" => ~/.gortex/store/store.sqlite SnapshotPath string // gob+gzip pre-load path; entry point applies it HTTPAddr string // opts the lifecycle into the /mcp HTTP surface Watch bool // filesystem watcher / incremental reindex // Entry-point-resolved options (not part of the authoritative surface). Config *config.Config // loaded .gortex.yaml (required) Global *config.GlobalConfig // loaded ~/.gortex/config.yaml Logger *zap.Logger Version string Embedder EmbedderRequest BufferPoolMB uint64 SideStores SideStores ScopeWorkspace string ScopeProject string // ActiveProject names the project the MCP server should start scoped // to (multi-repo mode hint). Empty leaves it unset. ActiveProject string // SemanticMode selects the goanalysis provider mode: "callgraph" // builds the call graph; anything else (default) type-checks. SemanticMode string // SavingsPath overrides the token-savings ledger database; empty // defaults to the machine-global sidecar under the data dir // (savings.DefaultDBPath() — the same database the `gortex savings` // CLI reads), independent of SideStores: deriving it from a per-mode // side-store dir would split the ledger between writer and reader. // Tests MUST set it (with SavingsLegacyJSON) to temp paths or the // constructor mutates the developer's real ledger. SavingsRepo // scopes the accumulated totals (empty = workspace-global). // SavingsLegacyJSON names the flat-file era's cumulative // savings.json to import once — its sibling .jsonl event log rides // along; empty uses the historical default location under the // cache dir. SavingsPath string SavingsLegacyJSON string SavingsRepo string } // SideStores configures where the agent-authored knowledge stores // persist. Each (dir, repo) pair selects the sidecar DB file (dir, which // opens /sidecar.sqlite) and the partition key (repo, hashed via // persistence.RepoCacheKey); an empty dir OR repo yields an in-memory // store that never flushes. The zero value therefore makes every store // ephemeral — entry points opt into persistence explicitly, encoding // whether the server owns one repo (per-repo keying) or many (a // workspace-global key). type SideStores struct { // NotesDir / NotesRepo back the durable, repo-partitioned notes and // development-memory stores. NotesDir string NotesRepo string // FeedbackDir / FeedbackRepo back feedback, the combo tracker, and the // frecency tracker (the implicit-signal stores). FeedbackDir string FeedbackRepo string // NotebookPath is the repository-local notebook root (committed to // git so it travels with the repo); empty leaves the notebook // uninitialised. NotebookPath string } // SharedServer bundles the constructed stack plus the teardown chain. type SharedServer struct { Graph graph.Store Indexer *indexer.Indexer MultiIndexer *indexer.MultiIndexer // nil in single-repo standalone Engine *query.Engine MCP *gortexmcp.Server ConfigMgr *config.ConfigManager Overlays *daemon.OverlayManager // EmbedderDims is the active embedder's vector dimensionality, or 0 // when embeddings are off. The entry point's snapshot warm-start // compares it to a snapshot's vector dims before skipping a re-embed. EmbedderDims int // ResolverLSPRegistry / LSPRouter are the resolve-time LSP wiring the // entry point's warmup hooks reference; nil when semantic enrichment // is off. ResolverLSPRegistry *lsp.ResolverHelperRegistry LSPRouter *lsp.Router cleanup []func() // run LIFO by Close (backend close, savings flush). } // Close runs the teardown chain LIFO. func (s *SharedServer) Close() error { for i := len(s.cleanup) - 1; i >= 0; i-- { s.cleanup[i]() } return nil } // telemetrySendInterval bounds how often the daemon flushes the in-memory // usage recorder and attempts to send completed daily aggregates. Long, since // only whole UTC days are ever sent and MaybeSend self-limits to once per day. const telemetrySendInterval = 6 * time.Hour // telemetryConsentTTL bounds how often the daemon recorder re-reads consent on // the hot record path — short enough that `gortex telemetry off` stops // recording promptly, long enough to avoid a consent-file read per tool call. const telemetryConsentTTL = 5 * time.Second // startTelemetrySender launches the daemon-only periodic flush-and-send loop // and returns a stop function for the cleanup chain. It re-resolves consent // each tick so `gortex telemetry off` stops transmission within one interval, // and is a complete no-op while GORTEX_TELEMETRY_ENDPOINT is unset. func startTelemetrySender(srv *gortexmcp.Server, store *telemetry.Store, dir, version string) func() { sender := telemetry.NewSender(store, dir, version, os.Getenv) ctx, cancel := context.WithCancel(context.Background()) go func() { t := time.NewTicker(telemetrySendInterval) defer t.Stop() flushAndSend := func() { srv.FlushTelemetry() consent := telemetry.ResolveConsent(telemetry.LoadConsentConfig(dir), os.Getenv) sender.MaybeSend(ctx, consent) } flushAndSend() // opportunistic send on startup for { select { case <-ctx.Done(): return case <-t.C: flushAndSend() } } }() return cancel } // NewSharedServer builds the whole server stack — graph.Store -> // parser.Registry -> indexer -> query.Engine -> mcp.Server plus every // side-effect init — through one wiring shared by the daemon, the HTTP // surface, and the one-shot embedded path. Snapshot warm-start and the // per-repo warmup loop stay with the entry point (they orchestrate around // the returned graph/indexer); this constructor is the dedup spine for // the stack wiring itself. func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { logger := cfg.Logger if logger == nil { logger = zap.NewNop() } conf := cfg.Config if conf == nil { conf = config.Default() } backendName := cfg.Backend if backendName == "" { backendName = cfg.Lifecycle.defaultBackend() } // Load user-defined domain-extractor rules (TOML tree-sitter patterns). for _, pattern := range conf.RuleFiles { matches, _ := filepath.Glob(pattern) if len(matches) == 0 { matches = []string{pattern} } for _, rp := range matches { if n, lerr := astquery.LoadUserRulesFile(rp); lerr != nil { logger.Warn("serverstack: failed to load domain rule file", zap.String("file", rp), zap.Error(lerr)) } else if n > 0 { logger.Info("serverstack: loaded domain-extractor rules", zap.String("file", rp), zap.Int("rules", n)) } } } s := &SharedServer{} // Cross-process store lock: a writable, on-disk lifecycle // acquires an advisory flock on store.sqlite.lock and fails fast if // another process owns the store. SQLite's in-process writeMu + // busy_timeout serialise in-process writers only; nothing else stops // a second OS process opening the same file and corrupting it. storeLockHeld := false // set true once the exclusive store flock is owned below if cfg.Lifecycle.Writable() && isSqliteBackend(backendName) { storePath, perr := resolveBackendPath(cfg.BackendPath, "store.sqlite") if perr != nil { return nil, perr } lock := flock.New(storePath + ".lock") locked, lerr := lock.TryLock() if lerr != nil { return nil, fmt.Errorf("acquire store lock %q: %w", storePath+".lock", lerr) } if !locked { hint := "the store is already owned by another gortex process" if pid, ok := daemon.RunningPID(); ok { hint = fmt.Sprintf("store already owned by daemon pid %d", pid) } return nil, fmt.Errorf("%s — stop it first (`gortex daemon stop`)", hint) } s.cleanup = append(s.cleanup, func() { _ = lock.Unlock() }) storeLockHeld = true } // allowRebuild is gated on actually holding the store lock: only then may // the sqlite backend drop and recreate an incompatible-schema DB. g, backendCleanup, err := OpenBackend(backendName, cfg.BackendPath, cfg.BufferPoolMB, logger, storeLockHeld) if err != nil { return nil, err } s.cleanup = append(s.cleanup, backendCleanup) s.Graph = g reg := parser.NewRegistry() languages.RegisterAll(reg) languages.RegisterCustomGrammars(reg, conf.Index.Grammars, logger) languages.RegisterExtractorPlugins(reg, conf.Index.ExtractorPlugins, logger) languages.RegisterFallbackChunkers(reg, conf.Index.FallbackChunkers, logger) idx := indexer.New(g, reg, conf.Index, logger) s.Indexer = idx // Semantic enrichment (opt-in). A daemon-managed LSP router owns // subprocess lifecycle; SCIP + goanalysis providers register eagerly; // Manager.SetLSPRouter installs the bridge so EnrichAll can lazy-spawn // LSPs on demand. RegisterAvailable auto-registers every known LSP // spec whose binary resolves on PATH (per-spec / GORTEX_LSP_DISABLE // opt-out). All lifecycles get this (reconciles the prior mcp-only // drift where the embedded path skipped auto-registration). var semMgr *semantic.Manager if conf.Semantic.Enabled { semCfg := semantic.Config{ Enabled: conf.Semantic.Enabled, TimeoutSeconds: conf.Semantic.TimeoutSeconds, EnrichOnWatch: conf.Semantic.EnrichOnWatch, WatchDebounceMs: conf.Semantic.WatchDebounceMs, RefuteUnconfirmed: conf.Semantic.RefuteUnconfirmed, ExcludeGlobs: conf.Semantic.ExcludeGlobs, LSPSweep: conf.Semantic.LSPSweep, EagerLSP: eagerLSPEnabled(conf.Semantic), } for _, pc := range conf.Semantic.Providers { out := semantic.ProviderConfig{ Name: pc.Name, Languages: pc.Languages, Command: pc.Command, Args: pc.Args, Env: pc.Env, Mode: pc.Mode, Daemon: pc.Daemon, Priority: pc.Priority, Enabled: pc.Enabled, } if pc.Connect != nil { out.Connect = &semantic.ConnectConfig{ Network: pc.Connect.Network, Address: pc.Connect.Address, FallbackSpawn: pc.Connect.FallbackSpawn, } } semCfg.Providers = append(semCfg.Providers, out) } semMgr = semantic.NewManager(semCfg, logger) goMode := goanalysis.ModeTypeCheck if cfg.SemanticMode == "callgraph" { goMode = goanalysis.ModeCallGraph } goProvider := goanalysis.NewProvider(goMode, goTypesIncludeTests(), logger) // The go/packages "go-types" provider owns Go enrichment when the // toolchain is present: it type-checks the module once in-process and // stamps every symbol's exact type plus resolves stdlib/dep calls to // real nodes, in a fraction of the time the per-request gopls LSP pass // takes (which additionally leaves external calls as dead stubs). As an // eager provider it claims the "go" language slot, so the LSP router's // gopls spec is skipped as a gap-filler and never spawns. Registration // is gated on Available() (a `go` toolchain on PATH): without it the // slot stays open and gopls / the tree-sitter floor serve Go instead. if goTypesEnrichEnabled(conf.Semantic) && goProvider.Available() { semMgr.RegisterProvider(goProvider) } contracts.SetBindingResolver(goProvider) // In-process tree-sitter type resolvers — always-available // (no external binary), supplemental (they coexist with LSP / // SCIP providers instead of competing for the language slot). // Disable one via a `semantic.providers` entry with // `enabled: false` under its name (e.g. "java-types"). for _, tp := range tstypes.DefaultProviders(logger) { semMgr.RegisterProvider(tp) } lspWorkspace := cfg.Index if lspWorkspace == "" { lspWorkspace, _ = os.Getwd() } lspRouter := lsp.NewRouter(lspWorkspace, logger). WithIdleTimeout(10 * time.Minute). WithReaperInterval(time.Minute). WithMaxAlive(6). WithAdditionalWorkspaceFolders(conf.Semantic.AdditionalWorkspaceFolders). WithEnrichExcludeGlobs(conf.Semantic.ExcludeGlobs). WithEnrichSweepMode(semCfg.LSPSweep) semMgr.SetLSPRouter(lspRouter) for _, pc := range semCfg.Providers { if !pc.Enabled { continue } switch { case strings.HasPrefix(pc.Name, "scip-") && pc.Command != "": scipProv := scip.NewProvider(pc.Command, pc.Args, pc.Languages, semCfg.TimeoutSeconds, logger) if pc.Mode == "definitions" { scipProv = scipProv.WithDefinitionsOnly() } semMgr.RegisterProvider(scipProv) case lsp.SpecByName(pc.Name) != nil: var connect *lsp.ConnectSpec if pc.Connect != nil { connect = &lsp.ConnectSpec{ Network: pc.Connect.Network, Address: pc.Connect.Address, FallbackSpawn: pc.Connect.FallbackSpawn, } } lspRouter.RegisterSpec(lsp.SpecWithOverridesConnect( lsp.SpecByName(pc.Name), pc.Command, pc.Args, pc.Env, connect)) case pc.Daemon: semMgr.RegisterProvider(lsp.NewProvider(pc.Command, pc.Args, pc.Languages, pc.Daemon, pc.MaxParallel, logger)) } } disabled := LspDisabledSet(conf.Semantic.Providers, os.Getenv("GORTEX_LSP_DISABLE")) var autoRegistered []string if !disabled["__all__"] { autoRegistered = lspRouter.RegisterAvailable(disabled) } idx.SetSemanticManager(semMgr) if !IsFalsyEnv("GORTEX_LSP_RESOLVER") { s.ResolverLSPRegistry = lsp.NewResolverHelperRegistry() s.LSPRouter = lspRouter idx.SetResolverLSPHelper(s.ResolverLSPRegistry) // Single-repo standalone mode: anchor a "" prefix helper at // the workspace the server points at. Only fires when Index is // set (the embedded path); the daemon leaves Index empty and // registers per-repo helpers via the OnRepoTracked hook. if cfg.Index != "" { if abs, aerr := filepath.Abs(cfg.Index); aerr == nil { helper, specs := BuildResolverLSPHelperForRepo(lspRouter, abs, lsp.ResolverPoolSizeFromEnv(1), logger) if helper != nil { s.ResolverLSPRegistry.Register("", helper) logger.Info("serverstack: single-repo resolve-time LSP helpers registered", zap.Strings("specs", specs)) } } } logger.Info("serverstack: resolve-time LSP hot path enabled") } else { logger.Info("serverstack: resolve-time LSP hot path disabled (GORTEX_LSP_RESOLVER=0)") } logger.Info("serverstack: semantic enrichment enabled", zap.Int("providers", len(semCfg.Providers)), zap.Strings("lsp_auto_registered", autoRegistered)) } // Layer the global ~/.gortex/config.yaml `embedding:` block under the // repo-local one before resolving the embedder, so an `embedding:` block in // the global file is honoured while any repo-local non-zero field still // wins. Done here (not at the LLM merge below) so it precedes ResolveEmbedder // and leaves its flag/env precedence untouched. Warn about any unrecognised // top-level keys — most often an `embedding:` block written in the wrong file. embGlobal := cfg.Global if embGlobal == nil { var gerr error if embGlobal, gerr = config.LoadGlobal(); gerr != nil { // A malformed global file is otherwise silently ignored — exactly // when its embedding/llm block would have taken effect. logger.Warn("serverstack: global config could not be parsed — its embedding/llm block is ignored", zap.Error(gerr)) } } conf.Embedding = embGlobal.MergeEmbeddingInto(conf.Embedding) // Diagnose unknown keys in the file that was actually loaded (which may be an // overridden path from cfg.Global), not always the default location. globalPath := "" if embGlobal != nil { globalPath = embGlobal.ConfigPath() } if unknown := config.UnknownGlobalKeys(globalPath); len(unknown) > 0 { logger.Warn("serverstack: ~/.gortex/config.yaml contains keys gortex does not recognize", zap.Strings("keys", unknown), zap.String("hint", "see docs/semantic-search.md for embedding config placement")) } // Embeddings: explicit flag/env > `embedding:` config > default (on, // static GloVe). embedder, embDesc, embReport, embErr := ResolveEmbedder(cfg.Embedder, conf) // Probe API-backed providers up front so Dimensions() is truthful before // we log it and — crucially — before EmbedderDims gates snapshot-vector // reload. An APIProvider reports 0 until its first embed; without this the // log reads dim:0 and the warm-restart vector reload rejects a // correctly-sized cached index, re-embedding the whole graph needlessly. // Static / local providers know their width natively and don't implement // the prober, so they skip this. Best-effort: a probe failure (bad key / // unreachable URL) only warns — indexing still falls back to BM25. if embedder != nil { if prober, ok := embedder.(interface { ProbeDimensions(context.Context) (int, error) }); ok { if dim, perr := prober.ProbeDimensions(context.Background()); perr != nil { logger.Warn("serverstack: embedding dimension probe failed — width unknown until first embed", zap.Error(perr)) } else { logger.Info("serverstack: embedding dimension probed", zap.Int("dim", dim)) } } } // Surface every backend the local auto-selection tried. Warn per backend // only when the outcome was actually degraded (fell to the static fallback, // or built nothing) AND the backend could really have worked — a backend // that is simply not compiled into this build (the onnx/gomlx stubs) is // benign noise and stays at debug even on a degradation. outcomeDegraded := (embedder == nil || embReport.Chosen == "static") && len(embReport.Attempts) > 0 for _, a := range embReport.Attempts { if outcomeDegraded && !errors.Is(a.Err, embedding.ErrBackendNotCompiled) { logger.Warn("serverstack: embedding backend unavailable — degraded to static fallback", zap.String("backend", a.Backend), zap.Error(a.Err)) } else { logger.Debug("serverstack: embedding backend not available", zap.String("backend", a.Backend), zap.Error(a.Err)) } } switch { case embErr != nil: logger.Warn("serverstack: embeddings requested but unavailable", zap.Error(embErr)) case embedder != nil: logger.Info("serverstack: embeddings enabled", zap.String("provider", embDesc), zap.Int("dim", embedder.Dimensions())) default: logger.Info("serverstack: embeddings disabled") } if embedder != nil { idx.SetEmbedder(embedder) idx.SetEmbeddingChunkOptions(EmbeddingChunkOptions(conf)) idx.SetEmbeddingMaxSymbols(conf.Embedding.MaxSymbols) idx.SetEmbeddingAPIConcurrency(conf.Embedding.APIConcurrency) s.EmbedderDims = embedder.Dimensions() } cm, err := config.NewConfigManager("") if err != nil { logger.Warn("serverstack: could not load global config", zap.Error(err)) } s.ConfigMgr = cm var mi *indexer.MultiIndexer if cm != nil { mi = indexer.NewMultiIndexer(g, reg, idx.Search(), cm, logger) if embedder != nil { mi.SetEmbedder(embedder) mi.SetEmbeddingChunkOptions(EmbeddingChunkOptions(conf)) mi.SetEmbeddingMaxSymbols(conf.Embedding.MaxSymbols) mi.SetEmbeddingAPIConcurrency(conf.Embedding.APIConcurrency) } // Propagate the semantic manager so per-repo Indexers // created by the MultiIndexer can run LSP enrichment. // Without this, daemon-mode enrichment produces zero // results (enrich:0 in DEFERRED-TIMING). if semMgr != nil { mi.SetSemanticManager(semMgr) } if s.ResolverLSPRegistry != nil { mi.SetResolverLSPHelper(s.ResolverLSPRegistry) if s.LSPRouter != nil { routerRef := s.LSPRouter registryRef := s.ResolverLSPRegistry poolSize := lsp.ResolverPoolSizeFromEnv(1) mi.SetOnRepoTracked(func(prefix, absPath string) { helper, _ := BuildResolverLSPHelperForRepo(routerRef, absPath, poolSize, logger) if helper != nil { registryRef.Register(prefix, helper) } }) } } } s.MultiIndexer = mi toolPolicyCfg := gortexmcp.ToolPolicyConfig{ Preset: conf.MCP.Tools.Preset, Mode: conf.MCP.Tools.Mode, Allow: conf.MCP.Tools.Allow, Deny: conf.MCP.Tools.Deny, } scopeIntentDefaults := conf.Scope.IntentDefaults multiOpts := []gortexmcp.MultiRepoOptions{{ MultiIndexer: mi, ConfigManager: cm, ActiveProject: cfg.ActiveProject, ScopeWorkspace: cfg.ScopeWorkspace, ScopeProject: cfg.ScopeProject, ToolPolicy: &toolPolicyCfg, ScopeIntentDefaults: &scopeIntentDefaults, }} eng := query.NewEngine(g) eng.SetSearchProvider(idx.Search) eng.ApplyRerankWeights(conf.Search.Weights) s.Engine = eng gortexmcp.Version = cfg.Version srv := gortexmcp.NewServer(eng, g, idx, nil, logger, conf.Guards.Rules, multiOpts...) srv.SetArchitecture(conf.Architecture) srv.SetEventRules(conf.Events.Rules) srv.SetArtifacts(conf.Artifacts) srv.SetNamedQueries(conf.Queries) srv.SetSearchConfig(conf.Search) s.MCP = srv overlays := daemon.NewOverlayManager(daemon.OverlayIdleTTLFromEnv(0)) srv.SetOverlayManager(overlays) s.Overlays = overlays stopOverlayJanitor := overlays.StartJanitor(0, func(dropped int) { logger.Info("overlay janitor: swept idle sessions", zap.Int("dropped", dropped)) }) s.cleanup = append(s.cleanup, stopOverlayJanitor) if semMgr := idx.SemanticManager(); semMgr != nil { srv.SetSemanticManager(semMgr) srv.SetLSPDiagnosticsBroadcasting() } // Opt-in usage telemetry. Every entry point records (consent-gated and // fail-silent — nothing happens unless the user enabled it); the daemon // additionally flushes and sends completed daily aggregates on a long // interval, dormant until GORTEX_TELEMETRY_ENDPOINT is configured. Both the // record path (via a TTL-cached resolver) and the send path re-resolve // consent live, so `gortex telemetry off` stops recording within // telemetryConsentTTL and stops transmission within one send interval — // rather than freezing the decision at startup. teleDir := platform.TelemetryDir() teleStore := telemetry.NewStore(teleDir) teleRec := telemetry.NewRecorderFunc( telemetry.CachedConsentResolver(teleDir, telemetryConsentTTL), teleStore) srv.SetTelemetryRecorder(teleRec) s.cleanup = append(s.cleanup, func() { srv.FlushTelemetry() }) if cfg.Lifecycle == LifecycleDaemon { // One daemon-session event per process start, dimensioned by backend. // Opt-in + fail-silent: a disabled recorder drops it. telemetry.RecordDaemonSession(teleRec, backendName) s.cleanup = append(s.cleanup, startTelemetrySender(srv, teleStore, teleDir, cfg.Version)) } // Side-stores. The HTTP path previously wired NONE of these; routing it // through the shared constructor adds notes/memories/savings to it. sideCfg := cfg.SideStores srv.InitFeedback(sideCfg.FeedbackDir, sideCfg.FeedbackRepo) srv.InitNotes(sideCfg.NotesDir, sideCfg.NotesRepo) // Learned tool surface: re-promote tools this workspace promoted in // prior sessions (demoting the ones that fell out of use). Runs after // the NewServer register sweep, so the cold tools are already deferred. srv.InitLearnedTools(sideCfg.NotesDir, sideCfg.NotesRepo) srv.InitMemories(sideCfg.NotesDir, sideCfg.NotesRepo) srv.InitSuppressions(sideCfg.NotesDir, sideCfg.NotesRepo) srv.InitNotebook(sideCfg.NotebookPath) srv.InitCombo(sideCfg.FeedbackDir, sideCfg.FeedbackRepo, gortexmcp.ModeAI) srv.InitFrecency(sideCfg.FeedbackDir, sideCfg.FeedbackRepo, gortexmcp.ModeAI) // The savings ledger is machine-global: every entry point defaults to // the same sidecar database the `gortex savings` CLI reads. Deriving // it from a per-mode side-store dir would split the ledger between // writer and reader — the failure mode the flat files had. savingsPath := cfg.SavingsPath if savingsPath == "" { savingsPath = savings.DefaultDBPath() } if savingsStore, err := savings.Open(savingsPath); err == nil { legacyJSON := cfg.SavingsLegacyJSON if legacyJSON == "" { legacyJSON = savings.DefaultPath() } if ierr := savingsStore.ImportLegacy(legacyJSON); ierr != nil { logger.Warn("serverstack: legacy savings import failed", zap.Error(ierr)) } srv.InitSavings(savingsStore, cfg.SavingsRepo) s.cleanup = append(s.cleanup, func() { _ = srv.FlushSavings() }) } else { logger.Warn("serverstack: savings persistence disabled", zap.Error(err)) } global := cfg.Global if global == nil { global, _ = config.LoadGlobal() } if global != nil { srv.SetupLLM(global.MergeLLMInto(conf.LLM)) // SetupLLM constructs the LLM service lazily and attaches it; nothing // else frees it, so a graceful shutdown would leave a loaded local // model resident (only the idle reaper unloads at runtime). Register // its Close in the cleanup chain — the "caller owns Close" contract // SetLLMService documents. if llmSvc := srv.LLMService(); llmSvc != nil { s.cleanup = append(s.cleanup, func() { _ = llmSvc.Close() }) } } return s, nil } // goTypesEnrichEnabled reports whether the in-process go/packages "go-types" // enrichment provider should be registered for Go. Default ON: it is both // faster and more complete than driving gopls over LSP (see the registration // site), and registration is separately gated on the toolchain being present. // Precedence: GORTEX_GO_TYPES=1/0 wins, then an explicit semantic.go_types // config value, then the on-by-default. func goTypesEnrichEnabled(sem config.SemanticConfig) bool { if v := os.Getenv("GORTEX_GO_TYPES"); v != "" { return v == "1" || strings.EqualFold(v, "true") } if sem.GoTypes != nil { return *sem.GoTypes } return true } // goTypesIncludeTests reports whether the go-types provider should load // _test.go files (and each package's external test variant). Loading them // roughly doubles the go/packages work per module, so it is OFF by default; // GORTEX_GO_TYPES_TESTS=1 opts in to stamp test-file symbols too (LSP hover // covers them, so the opt-in closes that coverage gap when it matters). func goTypesIncludeTests() bool { return os.Getenv("GORTEX_GO_TYPES_TESTS") == "1" || strings.EqualFold(os.Getenv("GORTEX_GO_TYPES_TESTS"), "true") } // eagerLSPEnabled reports whether the subprocess LSP servers run during the // synchronous enrichment pass. Default OFF — LSP is the slowest part of a cold // index and its net-new value over the in-process tiers (go-types for Go, the // tree-sitter floor for every language) is narrow, so it is kept off the // cold/warm-start path and the router lazy-spawns a server on demand instead. // GORTEX_LSP_EAGER=1 (or semantic.eager_lsp in config) restores the eager // behaviour. func eagerLSPEnabled(sem config.SemanticConfig) bool { if v := os.Getenv("GORTEX_LSP_EAGER"); v != "" { return v == "1" || strings.EqualFold(v, "true") } return sem.EagerLSP }