Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}
}