Files
wehub-resource-sync a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

719 lines
28 KiB
Go

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 <dir>/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
}