a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
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
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
// Package modelhint is a best-effort, filesystem-backed bridge that lets
|
|
// the host's hook layer tell the (separate-process) gortex daemon which
|
|
// LLM model is driving a session.
|
|
//
|
|
// The MCP protocol never transmits the calling model to a tool server,
|
|
// but a Claude Code hook *can* see it (the SessionStart payload carries a
|
|
// `model` field; the session transcript records it per assistant turn).
|
|
// The hook runs as its own short-lived process, so it can't hand the
|
|
// model to the daemon in-memory — it writes a hint here, keyed by the
|
|
// session's working directory, and the daemon's savings recorder reads it
|
|
// back. Both processes share the filesystem, exactly like the per-session
|
|
// state the PreToolUse hook already round-trips through disk.
|
|
//
|
|
// Everything is best-effort: a missing, stale, or unparseable hint simply
|
|
// degrades to "model unknown", and the savings dashboard falls back to its
|
|
// provider-neutral estimate. Two coordinates exist:
|
|
//
|
|
// - <hash(cwd)>.json — the model active in a given working directory.
|
|
// Precise when concurrent sessions live in different directories.
|
|
// - _last.json — the most recently announced model, the fallback when
|
|
// no per-cwd hint matches. Exact for the common single-session case;
|
|
// ambiguous only when concurrent sessions share one cwd on different
|
|
// models (a rare edge we accept rather than chase).
|
|
package modelhint
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/platform"
|
|
)
|
|
|
|
// dirEnvVar lets tests redirect the hint directory at a t.TempDir(),
|
|
// parallel to GORTEX_HOOK_SESSION_DIR for the hook session state.
|
|
const dirEnvVar = "GORTEX_MODEL_HINT_DIR"
|
|
|
|
// ttl bounds how long a hint is trusted. A daemon that outlives the
|
|
// agent process shouldn't keep attributing savings to a model from a
|
|
// session that ended hours ago, so reads older than ttl are ignored.
|
|
const ttl = 12 * time.Hour
|
|
|
|
// lastFile is the global "most recently announced model" fallback.
|
|
const lastFile = "_last.json"
|
|
|
|
// Hint is one model announcement.
|
|
type Hint struct {
|
|
CWD string `json:"cwd,omitempty"`
|
|
Model string `json:"model"`
|
|
Client string `json:"client,omitempty"`
|
|
Updated int64 `json:"updated"` // unix nanoseconds
|
|
}
|
|
|
|
// hintDir returns the directory holding model-hint files, honouring
|
|
// GORTEX_MODEL_HINT_DIR. Returns "" when no base dir resolves — every
|
|
// caller treats "" as "hints disabled" and degrades gracefully.
|
|
func hintDir() string {
|
|
if p := strings.TrimSpace(os.Getenv(dirEnvVar)); p != "" {
|
|
return p
|
|
}
|
|
base := platform.OSCacheDir()
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
return filepath.Join(base, "modelhints")
|
|
}
|
|
|
|
// cwdFile returns the per-cwd hint file path for an absolute, cleaned
|
|
// working directory. The cwd is hashed so an arbitrary path collapses to
|
|
// one safe filename segment regardless of separators or length.
|
|
func cwdFile(dir, cwd string) string {
|
|
sum := sha256.Sum256([]byte(filepath.Clean(cwd)))
|
|
return filepath.Join(dir, hex.EncodeToString(sum[:8])+".json")
|
|
}
|
|
|
|
// Write records that `model` (driven by `client`) is active in `cwd`.
|
|
// Best-effort and non-blocking: any error is swallowed so a read-only
|
|
// cache dir or full disk can never stall the hook that called it. An
|
|
// empty model is a no-op — there is nothing to attribute.
|
|
func Write(cwd, model, client string) {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return
|
|
}
|
|
dir := hintDir()
|
|
if dir == "" {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return
|
|
}
|
|
abs, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
abs = cwd
|
|
}
|
|
h := Hint{CWD: abs, Model: model, Client: strings.TrimSpace(client), Updated: time.Now().UTC().UnixNano()}
|
|
data, err := json.Marshal(h)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// _last always; the per-cwd file only when we have a cwd to key on.
|
|
writeFileAtomic(filepath.Join(dir, lastFile), data)
|
|
if strings.TrimSpace(cwd) != "" {
|
|
writeFileAtomic(cwdFile(dir, abs), data)
|
|
}
|
|
}
|
|
|
|
// Read returns the model hint for `cwd`: the per-cwd announcement when
|
|
// one exists and is fresh, otherwise the global most-recent hint. The
|
|
// bool is false when no usable hint is found (none written, all stale,
|
|
// or hints disabled).
|
|
func Read(cwd string) (Hint, bool) {
|
|
dir := hintDir()
|
|
if dir == "" {
|
|
return Hint{}, false
|
|
}
|
|
if strings.TrimSpace(cwd) != "" {
|
|
abs, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
abs = cwd
|
|
}
|
|
if h, ok := readFresh(cwdFile(dir, abs)); ok {
|
|
return h, true
|
|
}
|
|
}
|
|
return readFresh(filepath.Join(dir, lastFile))
|
|
}
|
|
|
|
// readFresh loads a hint file and returns it only when it parses and is
|
|
// within the trust window.
|
|
func readFresh(path string) (Hint, bool) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Hint{}, false
|
|
}
|
|
var h Hint
|
|
if err := json.Unmarshal(data, &h); err != nil || h.Model == "" {
|
|
return Hint{}, false
|
|
}
|
|
if h.Updated > 0 && time.Since(time.Unix(0, h.Updated)) > ttl {
|
|
return Hint{}, false
|
|
}
|
|
return h, true
|
|
}
|
|
|
|
// writeFileAtomic writes via a temp file + rename so a concurrent reader
|
|
// never observes a torn JSON object. Best-effort: failures are ignored.
|
|
func writeFileAtomic(path string, data []byte) {
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".hint-*.tmp")
|
|
if err != nil {
|
|
return
|
|
}
|
|
tmpName := tmp.Name()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return
|
|
}
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}
|