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

129 lines
4.9 KiB
Go

package hooks
import (
"fmt"
"strings"
"github.com/zzet/gortex/internal/toolref"
)
// The Gortex MCP read tools that return whole-file source. A call to
// either with default arguments hands the agent every function body —
// the exact pattern that burns context in issue #40. They are the one
// gap the native-tool (Read / Grep / Bash) redirects can't cover: once
// the agent is already inside a Gortex tool, nothing else guards how it
// is called.
const (
gortexReadFileTool = gortexMCPToolPrefix + "read_file"
gortexEditingContextTool = gortexMCPToolPrefix + "get_editing_context"
)
// gortexForceCompressEnvVar upgrades the compress-bodies advisory from a
// soft nudge to a hard deny. It mirrors GORTEX_HOOK_BLOCK_EDIT: the
// default posture is a non-blocking reminder — a full-body read is
// sometimes genuinely needed — and a team can flip this on to enforce
// the rule once they trust it.
const gortexForceCompressEnvVar = "GORTEX_HOOK_FORCE_COMPRESS"
// enrichGortexRead nudges (or, when GORTEX_HOOK_FORCE_COMPRESS is set,
// denies) a whole-file read through read_file / get_editing_context
// that omits compress_bodies on a source file. Returns an empty result
// — i.e. silent pass-through — for any call that is already economical
// (compressed, size-capped, or a non-code file where compression is a
// no-op).
func enrichGortexRead(toolName string, toolInput map[string]any) enrichResult {
msg := gortexReadNudge(toolName, toolInput)
if msg == "" {
return enrichResult{}
}
if gortexForceCompressEnabled() {
return enrichResult{deny: true, reason: msg}
}
return enrichResult{context: msg}
}
// gortexReadNudge returns the advisory message for a Gortex read-tool
// call that should be nudged, or "" when the call needs no nudge. It is
// pure (no env gate, no daemon round-trip — the decision is made
// entirely from the tool input, so the hook stays sub-millisecond) so
// callers can surface the message either as soft context or as a deny.
func gortexReadNudge(toolName string, toolInput map[string]any) string {
// Already compressing — nothing to suggest.
if asBool(toolInput["compress_bodies"]) {
return ""
}
path, _ := toolInput["path"].(string)
if path == "" {
return ""
}
// compress_bodies only elides code bodies; on prose / config it is a
// no-op, so don't nag on non-source reads.
if !looksLikeSourceFile(path) {
return ""
}
// The agent already bounded the read (a slice or a token / byte cap)
// — it knows what it wants; don't second-guess a constrained call.
if hasReadSizeCap(toolInput) {
return ""
}
return gortexReadAdvisory(toolName, path)
}
// gortexReadAdvisory builds the reminder shown when a Gortex read tool
// is about to pull full bodies. It names the cheaper paths the reporter
// of issue #40 wished the agent had taken: search_text to locate sites
// without reading bodies, and compress_bodies (+ keep) to read for an
// edit at a fraction of the tokens.
func gortexReadAdvisory(toolName, path string) string {
var b strings.Builder
fmt.Fprintf(&b, "[Gortex] %s on %s without compress_bodies — a full-body read can dominate context.\n",
shortGortexToolName(toolName), path)
b.WriteString(" - Locating specific call sites? `search_text` returns line-precise hits and reads no bodies.\n")
b.WriteString(" - Reading to edit? Re-call with compress_bodies:true (~30-40% of the tokens; signatures, types, and comments kept).\n")
b.WriteString(" - Need certain bodies in full? Add keep:\"Name1,Name2\" alongside compress_bodies:true.\n")
b.WriteString(toolref.FallbackLine("search_text"))
return b.String()
}
// shortGortexToolName strips the mcp__gortex__ namespace so the advisory
// reads "read_file" rather than the fully-qualified tool name.
func shortGortexToolName(toolName string) string {
return strings.TrimPrefix(toolName, gortexMCPToolPrefix)
}
// hasReadSizeCap reports whether the read already bounds its output via a
// line / byte / token cap. read_file uses max_lines / max_bytes;
// get_editing_context uses max_bytes / max_tokens. A zero or non-numeric
// value is treated as "no cap" so an explicit `max_tokens: 0` opt-out
// still draws the nudge.
func hasReadSizeCap(toolInput map[string]any) bool {
for _, k := range []string{"max_lines", "max_bytes", "max_tokens"} {
if n, ok := toFloat64(toolInput[k]); ok && n > 0 {
return true
}
}
return false
}
// asBool coerces a JSON-decoded tool-input value to bool. Claude Code
// sends booleans as JSON true/false (decoded to Go bool); the string
// fallback covers hosts that stringify tool inputs.
func asBool(v any) bool {
switch b := v.(type) {
case bool:
return b
case string:
switch strings.ToLower(strings.TrimSpace(b)) {
case "true", "1", "yes", "on":
return true
}
}
return false
}
// gortexForceCompressEnabled reports whether the hard-deny gate is on.
// Same truthiness rules as editBlockingEnabled (see envGateEnabled).
func gortexForceCompressEnabled() bool {
return envGateEnabled(gortexForceCompressEnvVar)
}