Review fixes for the solution-targeting change:
- Correct the stale premise in comments: current csharp-ls discovers
solutions on its own (recursive .sln/.slnx glob, most-projects
heuristic since 0.19.0, .slnx since 0.18.0). The pin's value is
determinism, a concrete target for the pre-spawn restore, and an
operator override — not a capability the server lacks.
- GORTEX_LSP_CSHARP_SOLUTION gains an explicit off switch (falsy value
or 'none') that disables injection AND auto-detect, leaving the
server's own discovery untouched.
- The resolved pin and its provenance (env vs auto-detect) are logged
at spawn, and env entries that don't resolve for a root log a warning
with the reason instead of being dropped silently.
- The caller-pinned guard also recognizes the =-joined spellings
(--solution=X / -s=X) config args can carry.
- GORTEX_LSP_RESOLVER_CSHARP accepts any set, non-falsy value ('on',
'yes'), mirroring the sibling GORTEX_LSP_RESOLVER vocabulary.
- docs/lsp.md documents all three new env vars, corrects the restore
row to the targeted form, and replaces both edit-the-router-chain
passages with GORTEX_LSP_IDLE_TTL.
One idle window cannot fit every server class: a Roslyn or jdtls
workspace can take longer to load than the 10-minute default, and a
provider reaped mid-load never becomes useful — each later pass pays
the load again and meets the reaper again. GORTEX_LSP_IDLE_TTL takes a
Go duration; zero or negative disables reaping (the router's existing
idleTimeout <= 0 no-op).
The resolve-time helper mux wired TypeScript and Python only, so C#
repositories could never earn OriginLSPResolved edges regardless of
server availability. Wire .cs through the same intent-probe pattern
(root-level .sln/.slnx/.csproj), gated by GORTEX_LSP_RESOLVER_CSHARP —
off by default because the Roslyn workspace load behind csharp-ls costs
minutes and hundreds of MB on a large solution, a price only C# shops
should opt into.
The cap was threaded through five files and discarded on arrival:
openSqliteBackend took it only to write `_ = bufferPoolMB`, since
SQLite sizes its page cache via a pragma. Removes the resolver, the
env read, the SharedServerConfig field and the OpenBackend parameter.
--backend-buffer-pool-mb stays registered as a hidden deprecated no-op
so existing start scripts and the detach re-exec keep working.
The vector-index serialization path existed only for the daemon warmup
snapshot, which no longer exists. Export/ImportVectorIndex on Indexer and
MultiIndexer, the skipVectorBuild flag that gated re-embedding during
warmup, and the VectorBackend Save/LoadFrom/SetCount frame codec had no
remaining callers. EmbedderDims on the shared server was write-only.
The store-path resolution added its own checkBackend call, but the
constructor already rejects an unusable backend name at the top — before
the store lock is taken and before any path is resolved, which is the
whole point of doing it there. The second call could only ever agree
with the first.
* origin/main: (83 commits)
State what the startup barrier actually guarantees
Update CONTRIBUTING.md
Report a repository the daemon is not actually watching
Let the watcher observe its own startup handshake on macOS
csharp: review round — claim self-typed-field recursion, stamp cross-repo exact-type origins
csharp: stop member calls from binding to the calling method itself
Write down where the user, not Gortex, is carrying the risk
Describe the boundaries the code actually enforces
Stop the HTTP surfaces from being reachable by anyone who can route to them
Confine the generator tools' output paths, and keep the root set fixed
Refuse a git revision that git would read as an option
Stop diff handlers falling back to the daemon's own working directory
Apply the overbroad-root refusal to ScopeForCWD's own containment arm
Refuse to bind a session to a root too broad to be anyone's project
Keep the embedded MCP fallback out of the directory it launched from
Bind an MCP session opened above its repos to the repos it contains
fix(indexer): announce a point patch whose graph mutation already landed
Verify the user-state sandbox on the platform it exists for
Fail cmd/gortex when a test writes to the real user state
Route every home-isolating test through the shared sandbox
...
# Conflicts:
# docs/onboarding.md
`gortex repos` reads index freshness straight out of the graph store,
but only ever looked at the platform default. A daemon started with
--backend-path writes its repo_index_state rows somewhere else, so every
repo that daemon had indexed came back reported as never indexed, and
there was no way to correct it — the command took no path of its own.
The daemon now records the choices an out-of-band CLI cannot discover in
a small runtime-state file beside its PID file: the PID that wrote it and
the resolved store path. It shares the PID file's lifetime and a reader
ignores a record whose process is gone, so a killed daemon cannot route
anyone at a store nothing has open.
`gortex repos` resolves the store as --backend-path, else the running
daemon's recorded store, else the platform default — and now registers
--backend-path, which existed only as an internal variable, with the
order documented in the command help. The server stack publishes the
store file it actually opened rather than each caller re-deriving it.
RunAnalysis defers scheduleAnalysisGenerationPrune, which detaches a
goroutine of batched DELETEs against the backend sqlite store. Nothing
joined it: a test (or any teardown) could close the store and delete its
directory while the prune's already-acquired connection kept committing,
recreating WAL files mid-RemoveAll. On macOS CI this surfaced as
TestRunAnalysis_FeedsBundleFingerprintsToSQLiteBackend failing TempDir
cleanup with "directory not empty".
Track the goroutine on a Server WaitGroup and expose DrainBackground,
which waits for an in-flight prune and refuses to schedule new ones.
The Add is gated by a mutex + drained flag so it can never start at
counter zero concurrently with the drain's Wait, and a prune requested
after the drain is dropped instead of escaping it into a closing store.
The wiring test drains before its store closes, and the SharedServer
cleanup chain drains before the backend close during daemon teardown.
The server stack now opens the sqlite store for every lifecycle. An empty
backend name still resolves to it, so the daemon and the one-shot embedded
server keep working unchanged, but the in-memory names are refused with an
error that names the replacement: --backend sqlite with a throwaway
--backend-path reproduces the old ephemeral behaviour. Falling back silently
would write a caller who asked for a scratch store into the shared database.
With one backend left, the name check no longer varies by lifecycle: the
per-lifecycle default and the is-it-sqlite predicate collapse into a single
validation that runs before the store lock is taken, and the unused HTTP
lifecycle constant goes with them (the daemon's HTTP surface has always run
as the daemon lifecycle).
*graph.Graph is untouched and still implements the whole Store contract — it
remains the indexer's cold-index staging buffer and the fixture the rest of
the tree's tests build on.
The embedded MCP server used to run on an in-process graph. It now
defaults to sqlite like every other lifecycle, backed by a per-process
temp file that `gortex mcp` creates and removes on shutdown.
That path has to be explicit. The one-shot server takes no store lock,
and an empty backend path resolves to the shared store under ~/.gortex —
so a second, unsynchronised writer would land on the daemon's database.
The constructor now refuses a one-shot with no backend path instead of
silently sharing, and tests pin both the refusal and the temp location.
The daemonless `gortex mcp` path kept a second, independent graph
snapshot: a file store it replayed nodes and edges from on startup and
wrote back on shutdown, plus the --no-cache flag that only existed to
turn it off. It is gone, and so is the flag.
The accepted consequence, stated plainly: without the snapshot cache,
every daemonless one-shot invocation cold-indexes the repository from
scratch. Run the daemon if you want an index that survives the process.
`gortex repos` loses its snapshot-store fallback with it — that store was
only ever written by this path, so the repo_index_state rows the daemon
writes are now the sole freshness source and a repo without one reports
as never indexed. Its tests move to seeding those rows.
The rerank semantic-cosine channel loads a bundled static code model on
the first search that reranks. Three things were wrong with how it was
held.
It was expanded to float32 at load. The tensor ships F16, and the loader
converted the whole 32 MB matrix into 62 MiB of float32 that the process
then kept forever — for a matrix that is read at most a few hundred rows
per call. Keep it packed and convert inside the gather loop: the same
arithmetic, half the resident cost.
It was a sync.Once with a no-op Close, so a daemon that stopped searching
held it for the life of the process. Track last use and drop it after 30
minutes of no rerank traffic; the next search reloads.
It had no supported way to decline it. The loader is independent of the
embedding: section — that section drives vector indexing, this drives
rerank scoring — so a user who configured provider: api, or disabled
embeddings entirely, paid for it identically. Add
search.rerank_embedder, honoured from the resolved config at server
bootstrap, and document the existing GORTEX_POTION=0 escape hatch, which
appeared nowhere outside a code comment.
A daemon tracking several repos is normally launched from none of them.
Three paths let that launch directory leak into an LSP workspace, which
becomes the language server subprocess's working directory:
- Server.absolutePath fell back to filepath.Abs for a repo-relative
graph path the prefix join could not claim, joining the fragment onto
the daemon's cwd and minting a path that exists nowhere.
- Server.workspaceRootFor then returned filepath.Dir of that phantom
path, and matched the indexer root with a plain string prefix, so
/src/repo could claim a file under /src/repo-old.
- The router's default workspace was seeded from os.Getwd() whenever
cfg.Index was empty — always, in daemon mode.
The spawn failed with a bare `chdir <phantom>: no such file or
directory`, and markSpawnFailed then disabled that spec for every other
tracked repo for the rest of the session, so one mis-anchored query
silently dropped a whole language's enrichment daemon-wide.
absolutePath now anchors an unclaimed relative path on a tracked repo
root, workspaceRootFor resolves the containing tracked repo on
component boundaries and refuses to invent a workspace from a
nonexistent directory, and the daemon's router default stays empty so a
caller that omits the per-repo root fails loudly. The router validates
the workspace before spawning, keeping the failure local to the one bad
workspace instead of poisoning the spec.
Fixes#297
Route TS/JS to tsgo by default when its binary is installed. The
per-extension table now keeps every covering spec in priority order and
routing picks the highest-priority spec whose binary is on PATH:
tsgo (one native process) wins over typescript-language-server (a
node + tsserver + typingsInstaller tree per workspace), which keeps
serving wherever tsgo is missing. The same ladder gives .py a pyrefly
fallback when pyright is not installed, instead of erroring.
Router.ForWorkspace walks the ladder against its own availability
cache, so a preferred server that fails to spawn (markSpawnFailed)
falls back to the next candidate on the following call. Resolve-time
helper registration derives its server pick from the same registry
order via preferredSpecName instead of hardcoding names, and the
warmup counters recognise both servers per language.
Service.Close — which unloads the in-process model and frees its Metal
buffers — had no runtime caller: the serverstack cleanup chain
registered lock, backend, overlay, telemetry, and savings teardowns but
never the LLM service, so only the idle reaper ever released the
multi-gigabyte model. A graceful daemon shutdown now closes the service
through the same cleanup chain as its peers.
The idle-unload TTL also gains a config surface: llm.local.idle_ttl
mirrors GORTEX_LLM_IDLE_TTL with env > config > default precedence and
the same 0/off/none-disables semantics, so operators pin it in
config.yaml instead of exporting an environment variable into launchd.
The subprocess language servers (tsserver, rust-analyzer, pyright, jdtls,
clangd, ...) are the slowest part of a cold/warm index — a full sweep runs for
minutes to hours — and their net-new value over the in-process tiers is narrow:
a Go module is served by go-types, and every language has the tree-sitter
floor, which stamps real types (measured: rust-types 1,089 nodes on a crate
where eager rust-analyzer produced 0 in 782s). So running them synchronously
taxes every startup for little return.
Gate the manager's router-backed LSP dispatch behind a new EagerLSP config flag
(default false); go-types, SCIP, and the tstypes floor still run eagerly. The
LSP router stays wired, so a query can lazy-spawn a server on demand — LSP moves
off the synchronous path rather than being removed. GORTEX_LSP_EAGER=1 (or
semantic.eager_lsp) restores the old behaviour.
Measured on full PATH with the servers installed: cold-to-settled drops to 37s
(rust crate) and 41s (python repo) with zero lsp-* subprocess passes, while the
in-process floor keeps stamping types. Router-dispatch tests opt into EagerLSP.
The go-types provider (go/packages + go/types) type-checks a module once and
stamps every symbol's exact type plus resolves stdlib/dep calls to real ext::
nodes. Driving gopls over LSP instead re-checks per request — one hover / one
references / one call-hierarchy round-trip per symbol and edge — which on a
mid-size Go repo ran the enrichment pass for ~900s and, being a whole gopls
subprocess, left external calls as dead stubs. go-types does the same work in
tens of seconds and produces a richer graph.
Flip go-types on by default (env GORTEX_GO_TYPES and an explicit
semantic.go_types config still win). Because it is 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 the toolchain being present, so a
host without `go` keeps the slot open for gopls / the tree-sitter floor.
Verified on two repos with default config and gopls installed: gopls did not
run, go-types stamped more symbols than the gopls hover pass (7,065 vs 6,851)
and added 8,779 external-node edges gopls never produced, cutting a repo's
index-to-settled from 942s to 157s.
go-types is constructed with includeTest=false, so go/packages never
type-checks _test.go files and their symbols go unstamped — the whole
remaining stamp gap versus the gopls hover pass, which opens every file.
Loading test variants roughly doubles the go/packages work per module (114s
to 524s on a mid-size repo), so keep it off by default and add
GORTEX_GO_TYPES_TESTS=1 to opt in — with it, go-types stamps 16,644 symbols,
exceeding the LSP pass everywhere while still finishing faster end to end.
Generalise promote-on-discovery into a persisted, per-workspace learned
surface. When a deferred tool is promoted — via tools_search or a direct
call by name — the promotion is recorded in the sidecar, keyed per repo
with a session epoch. On startup the daemon re-promotes the survivors into
the cold tools/list and demotes any promotion unused past a hysteresis
window (continued use resets its clock; floor tools never demote). On the
lean agent surface a learned tool is kept visible even though it is outside
the strict roster, so a tool the team reaches for is one hop cheaper next
session without bloating everyone else's cold list.
The active preset + learned-promotion count are surfaced on index_health
and `gortex daemon status` so the state is inspectable. Mid-session
promotion still fires notifications/tools/list_changed via the existing
AddTool path.
Claude-Session: https://claude.ai/code/session_01SXwzimUB1ZVcBaGsC9qfZ5
The whole-repo hover / call-hierarchy sweep re-opens and re-hovers every
file to confirm zero new edges on an already-resolved graph. Add a knob
that gates it: "demand" (default) sweeps only files whose declarations
still carry unresolved same-name call candidates, "full" sweeps every
file (the prior behaviour), and "off" skips the per-file sweep. The
tier-deciding confirm / add / interface passes are never gated.
The mode threads config.SemanticConfig -> semantic.Config -> the router's
WithEnrichSweepMode -> the provider's sweepMode field; the GORTEX_LSP_SWEEP
env override wins over the configured value.
Refinements from a self-review of the reliability work:
- A backend that is simply not compiled into the build (the onnx/gomlx stubs)
now wraps a sentinel error, so the degradation-to-static warning fires only
for backends that could really have worked — no more warning that onnx/gomlx
are 'not compiled in' on every default build. The warning also covers the
case where even the static fallback fails to construct.
- The unknown-global-key diagnostic now inspects the config file that was
actually loaded (which may be an overridden path), and a malformed global
config warns instead of being silently ignored — exactly when its embedding
block would have taken effect.
- The indexer's last-vector-build-error is reset at the top of the build so a
benign skip (no embedder / snapshot restore) can't report a stale failure.
An 'embedding:' block in ~/.gortex/config.yaml was silently ignored: GlobalConfig
had no Embedding field, and an unknown top-level key vanished with no warning —
so a user who put their embedding config in the global file (rather than a
repo-local .gortex.yaml) got the static default with no explanation.
Add an Embedding field to GlobalConfig and MergeEmbeddingInto (repo-local
non-zero fields win, the global block fills the rest; the tri-state Enabled
pointer inherits when unset), merged just before the embedder is resolved so
flag/env precedence is untouched. Add UnknownGlobalKeys plus a daemon-startup
warning so a misplaced or misspelled top-level key is surfaced rather than
dropped — never failing the load, to keep forward compatibility.
A local config that silently fell back to static GloVe still logged
'provider: local ... dim: 50' because the startup line echoed the configured
name, and every backend that failed along the way was swallowed with no signal.
Add a SelectionReport that records each backend the local chain tried and the
one it chose, thread it through ResolveEmbedder, and log the truth: the startup
line now reads the constructed backend ('local (hugot)', or 'local → static
fallback' when it degraded) with its real dimension. A genuine degradation to
static warns per failed backend; the benign misses behind a successful choice
(the onnx/gomlx stubs in a default build) stay at debug level.
In a multi-repo workspace, graph queries had no principled default
scope:
every tool read the full index, so results leaked across project and
workspace boundaries unless the caller manually scoped each call. This
PR
makes scope a first-class, intent-derived default while keeping explicit
overrides uniform and workspace a hard isolation boundary.
## Behavior change (default ON)
Ships enabled by default (`scope.intent_defaults: true`, override with
`GORTEX_SCOPE_INTENT_DEFAULTS=0`). In a multi-repo workspace, results
that
previously spanned every indexed repo now default to the session's repo
(lookups) or workspace (reachability / analysis). Set the flag to false
to
restore the prior whole-index behavior. Single-repo setups are
unaffected.
## Two-layer model
- Layer A — uniform overrides: an explicit `repo:` / `project:` /
`scope:`
(workspace) argument overrides the default identically across query,
MCP,
and analyze tools. `repo:"*"` is a sentinel escape hatch meaning "all
repos", not a post-filter.
- Layer B — intent-based defaults: with no explicit scope, the default
is
chosen by the tool's intent —
- Locate (symbol search / lookup) ............... repo (narrowest)
- Reach (callers / usages / dependents / walk) . workspace
- Analyze ....................................... workspace
Defaults only ever narrow; they never widen past the session
workspace.
## Analyze made scope-aware
The analyze dispatcher now scopes its kinds in tiers: kinds built on the
scoped-node accessors inherit scoping automatically; dead_code,
hotspots,
cycles, and releases are per-row filtered via analyzeNodeVisible; the
edge / algorithm / framework and file / AST-scan kinds were taught to
honor
the resolved scope. Kinds that read the full graph directly (community /
git-mining / per-id / synthesizer) are not narrowed in v1 and now stamp
a
`scope_note` saying their results may span the whole index.
## Tests & docs
- New tests: scope_resolve_test.go, analyze_scope_test.go,
workspace_isolation_test.go, scope_allows_test.go, plus search_text
and
field_query cases. `go build ./...` and `go test -race
./internal/mcp/...`
pass.
- docs/multi-repo.md documents the defaults and the narrow-only
invariant;
docs/proposals/scope-intent-defaults.md records the design and the
default-ON decision.
The go/packages go-types provider runs a full `go list ./...` + go/types
pass over the module (and its deps) on every index — tens of seconds per Go
module per warmup — yet on an already-resolved graph it routinely confirms
zero new edges, because the always-on go-ast-types tree-sitter floor already
covers Go receiver/cross-file resolution. Demote it to opt-in: keep it as the
contracts binding resolver, but only register it for enrichment when
semantic.go_types (or GORTEX_GO_TYPES=1) is set. Default OFF.
The baked static GloVe provider (dim-50 averaged word vectors) was wired
by default, so every index built and persisted a vector store — roughly
0.6-0.7s of the time-to-queryable on a small repo — for a semantic
signal that adds little over FTS5/BM25 text search and is auto-bypassed
for identifier queries anyway.
ResolveEmbedder now builds a vector index only when a real local/api
embedder is configured (or embeddings are explicitly enabled). With no
embedder, buildSearchIndex's existing embedder==nil short-circuit skips
the embed pass entirely; FTS5 text search is unchanged.
Indexing a fresh multi-repo workspace, the slowest remaining work by far
was a language server background-indexing machine-generated sources — in
this workspace clangd spent ~5 minutes indexing tree-sitter's generated
src/parser.c across the grammar repos, for essentially no enrichment value.
Add a generated/vendored heuristic (IsLowValueForEnrichment): vendored
dependency dirs, the tree-sitter runtime and generated parser.c/scanner.c,
and common generated suffixes, plus a configurable exclude_globs list.
Apply it both at the presence gate — so a repo whose only files of a
language are generated never spawns that language's server — and in the
LSP provider's work set, so a mixed repo's server never opens those files.
The destructive drop-and-recreate in store_sqlite.Open was unconditional: its
cross-process safety relied on the daemon's exclusive store flock, but that
lock is acquired in a separate place under a different condition
(Lifecycle.Writable() && sqlite). A future caller that opened an on-disk sqlite
store without that lock could reach the wipe and unlink a database another
process had open — silent split-brain rather than a clean failure. Latent today
(the only oneshot path resolves to the memory backend), but the invariant was
convention-only.
Make it intrinsic. Open now refuses to wipe by default, returning
ErrSchemaRebuildRequired and leaving the file intact; the destructive rebuild
happens only when the caller passes WithRebuild. NewSharedServer passes it
through OpenBackend solely in the branch where it actually acquired the
exclusive flock, so the permission to wipe and the lock that makes wiping safe
are now derived from the same fact. No behaviour change for the daemon (it holds
the lock and still rebuilds); a non-locked caller fails safe.
An APIProvider reported Dimensions()==0 until its first embed, so the
daemon logged dim:0 and the snapshot-vector reload gate
(daemon_state.go: vec.Dims == EmbedderDims) rejected a correctly-sized
cached index, re-embedding the whole graph on every restart.
Add APIProvider.ProbeDimensions(ctx): one tiny embed call that caches the
true width up front — idempotent, best-effort (a failure only warns and
the lazy path still fills it in), and doubles as an early key/URL
connectivity check. NewSharedServer probes any API-backed provider before
logging "embeddings enabled", so the width is truthful from the start.
Also fix a double-/v1 bug: NewAPIProvider("…/v1") + embedOpenAI appending
"/v1/embeddings" produced "…/v1/v1/embeddings" → 404 → silent fallback to
BM25. OpenAI-compatible bases are conventionally given with /v1 (OpenAI,
OpenRouter), so append it only when absent.
Tests: probe unit/error/URL-variant tests + a live OpenAI integration test
(skipped without a key) asserting a 1536-d width and token accounting.
Verified live: daemon now logs "embedding dimension probed dim:1536".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit d4b6a41f7bfd6110fb38cab53994378a130cee58)
Adds named presets (full/readonly/edit/nav) plus per-tool allow/deny
deltas to restrict the published MCP tool surface, so an agent on a
trusted box can drive a remote daemon through a small, fixed tool set
instead of the full ~170-tool catalogue.
Selected via mcp.tools in config, the GORTEX_TOOLS / GORTEX_TOOLS_MODE
env vars, or --tools / --tools-mode on 'gortex mcp' and 'gortex daemon
start' (precedence: env > flag > config > default full). Two modes:
hide removes non-allowed tools from tools/list and hard-blocks calls;
defer parks them behind tools_search via the lazy registry's eager
predicate. tool_profile reports the active preset and reflects the
narrowed surface. Detached daemons propagate the selection to the
re-exec'd child via env.
1. extractTypeFromHover (provider.go) — только Go-префиксы.
Java hover типа "public class Foo" или "void bar()" не проходил.
Добавлены: public/private/protected/abstract/static/final/class/
interface/enum/void/@ и markdown fence ```java.
2. jdtls без classpath — "invisible project" (только JRE).
Добавлены InitializationOptions в InitializeParams (protocol.go),
ServerSpec (registry.go) и провайдер (provider.go).
Для jdtls: Maven+Gradle import enabled, autobuild on.
3. MultiIndexer не передаёт semanticMgr — enrich:0 в daemon.
Добавлен semanticMgr в MultiIndexer, SetSemanticManager метод,
propagation в newPerRepoIndexer и вызов из shared_server.go.
Без этого патча демон (multi-repo режим) вообще не запускал
semantic enrichment.
A declarative events: block in .gortex.yaml constrains the pub/sub graph —
which paths may produce or consume a topic, whether a produced topic must have
a consumer, and which paths are forbidden from it. EventBoundaryFamily walks
the emit / produces_topic / listens_on / consumes_topic edges of each changed
symbol and plugs into change_contract as a RuleFamily like guards and
architecture.
New internal/semantic/tstypes package: per-language type resolvers for Java,
Python, Ruby, Rust, TypeScript/JavaScript, and C# that run fully in-process
over the shared tree-sitter AST — no external language server. A table-driven
engine builds per-file scope graphs, binds declared and constructor types,
propagates them through local assignments, resolves receivers against the
graph's method sets via import-aware cross-file lookup, and synthesizes
implements/extends edges per language.
Resolutions are stamped at the ast_resolved tier with semantic_source
<lang>-types, never downgrading a stronger edge; ambiguous receivers are
skipped. Enrichment is scoped to the repo being enriched, runs its graph-apply
phase under the resolve mutex, persists full edge provenance on disk backends,
and wires single-file incremental enrichment. Providers register as
supplemental in the semantic manager and coexist with LSP providers.
Three wiring holes from the review. The embedded server's --cache-dir
still relocated its ledger away from the dashboard's default read path
— the exact writer/reader split this branch exists to fix; the flag now
moves only the graph cache, and ledger isolation comes from
XDG_DATA_HOME / XDG_CACHE_HOME, which both ledger paths honour.
The savings/gain CLIs run the one-shot legacy import only against the
default locations: pointing a dashboard at a directory with --cache-dir
must never rename files there as a side effect of looking.
And the serverstack constructor test pinned its SavingsPath +
SavingsLegacyJSON to temp paths — with both empty it opened the REAL
machine-global sidecar and imported (renaming!) the developer's live
flat-file ledger on every 'go test ./internal/serverstack'. The config
doc now matches the machine-global behavior and carries the warning.
Also states the percentage semantics in the dashboard help: bars cover
ALL recorded source fetches, including uncompressed read_file calls
that saved nothing.
Deriving the ledger from the side-store dir split it by entry point:
the embedded server's side stores default to the cache dir, so its
ledger landed in <cache>/sidecar.sqlite while the savings CLI reads
~/.gortex/sidecar.sqlite — the writer/reader divergence the flat files
had. Every entry point now defaults to the machine-global sidecar; an
explicit --cache-dir still relocates both the ledger and the legacy
files for isolation.
The flat-file ledger (savings.json cumulative totals + savings.jsonl
event log under the cache dir) was only durable on a lucky schedule:
the cumulative file flushed every 20 observations, on a 5-minute ticker
gated on pending work, or on graceful shutdown — and MCP clients
SIGKILL their stdio servers, so light sessions never reached disk at
all. Every write error was silently discarded on top.
The ledger now lives in the machine-global SQLite sidecar
(~/.gortex/sidecar.sqlite, shared with notes/memories/scopes):
savings_events one row per call (now carrying the session id),
savings_totals running aggregates per bucket, savings_meta the
first/last stamps. Each observation is a single transaction — durable
at the call, safe across concurrent writer processes via WAL, no flush
machinery left to miss (the periodic flusher and its wiring are gone;
FlushSavings stays as a no-op for the shutdown chains).
Flat files import once on open — totals, per-repo/per-language buckets,
and the event history (a lone .jsonl without its cumulative file
rebuilds totals from events) — then rename to *.bak behind a migration
mark. The savings/gain CLIs and serverstack derive the ledger from the
same sidecar path the side-stores use, --cache-dir now relocating both
the ledger and the legacy files it imports. A fresh ledger reports a
zero FirstSeen instead of seeding time.Now(), so nothing claims to have
been tracking before anything was recorded.
SweepIdle existed but nothing ever called it — the documented 30m
idle expiry was inert, so an overlay session whose proxy vanished
without an observed disconnect pinned its pushed buffers for the
daemon's lifetime. Add OverlayManager.StartJanitor (ticker-driven
sweep, TTL/4 default interval, idempotent stop, no-op when expiry is
disabled) and wire it into NewSharedServer's cleanup chain.
Strips internal planning-document citations -- spec filenames,
requirement/decision identifiers, and the enumerated strategy labels --
from code comments and a few log strings across the cross-daemon
federation work. Comment- and string-only: no behaviour change, and every
rewording preserves the original technical content.
Adds the knobs the embedded entry point needs that the daemon doesn't:
SemanticMode (typecheck/callgraph), ActiveProject, SavingsPath/SavingsRepo,
WatchDebounceMs passthrough, and a single-repo "" resolver-LSP helper
registered when Index is set (the embedded path) — the daemon leaves
Index empty and registers per-repo helpers via the track hook. All
additive with daemon-compatible zero defaults; daemon tests unchanged.
The agent-knowledge stores (notes/memories/feedback/notebook/combo/
frecency) key differently per entry point: the daemon partitions
workspace-globally under a fixed key in DataDir, while the embedded path
partitions per-repo with a git-tracked notebook. NewSharedServer now
takes a SideStores config (NotesDir/NotesRepo, FeedbackDir/FeedbackRepo,
NotebookPath) instead of a single CacheDir/SideStoreKey, so each entry
point supplies its own layout. The daemon delegation reproduces its
prior keying verbatim.
A writable, on-disk lifecycle (daemon / http) now acquires an advisory
flock on store.sqlite.lock before opening the backend and fails fast
with an actionable hint when another gortex process already owns the
store. SQLite's in-process write serialisation does nothing to stop a
second OS process from opening the same file; the lock closes that gap.
Released via the teardown chain after the backend handle closes.