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
141 lines
4.7 KiB
Go
141 lines
4.7 KiB
Go
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/profiles"
|
|
"github.com/zzet/gortex/internal/toolref"
|
|
)
|
|
|
|
// UserPromptSubmitInput is the JSON Claude Code sends on UserPromptSubmit. We
|
|
// only consume the fields we use; unknown fields are ignored.
|
|
type UserPromptSubmitInput struct {
|
|
HookEventName string `json:"hook_event_name"`
|
|
SessionID string `json:"session_id"`
|
|
CWD string `json:"cwd"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
// userPromptProbeTimeout bounds the pre-turn context probe. It runs on every
|
|
// user turn, so it must be fast and must never block the turn.
|
|
const userPromptProbeTimeout = 800 * time.Millisecond
|
|
|
|
// maxInjectedHits caps how many relevant symbols are injected per turn so the
|
|
// block stays a nudge, not a wall of text.
|
|
const maxInjectedHits = 6
|
|
|
|
var userPromptProbe grepProbeFn = probeViaDaemon
|
|
|
|
// runUserPromptSubmit handles a UserPromptSubmit hook: it proactively searches
|
|
// the graph for symbols relevant to the user's prompt and injects them as
|
|
// additionalContext, so the model reaches for Gortex's knowledge instead of
|
|
// blindly grepping. It is best-effort — any miss (daemon down, no hits, a
|
|
// trivial / non-code prompt) is a silent no-op so the turn is never blocked or
|
|
// polluted, and no warning is emitted (SessionStart already warns once when the
|
|
// daemon is down; doing so every turn would be noise).
|
|
func runUserPromptSubmit(data []byte) {
|
|
var input UserPromptSubmitInput
|
|
if err := json.Unmarshal(data, &input); err != nil {
|
|
return
|
|
}
|
|
block := buildUserPromptSubmitContext(input.HookEventName, input.Prompt)
|
|
if block == "" {
|
|
return
|
|
}
|
|
out, err := json.Marshal(HookOutput{
|
|
HookSpecificOutput: &HookSpecificOutput{
|
|
HookEventName: "UserPromptSubmit",
|
|
AdditionalContext: block,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
fmt.Print(string(out))
|
|
}
|
|
|
|
func buildUserPromptSubmitContext(eventName, prompt string) string {
|
|
if eventName != "UserPromptSubmit" {
|
|
return ""
|
|
}
|
|
query := promptQuery(prompt)
|
|
if query == "" {
|
|
return ""
|
|
}
|
|
hits, err := userPromptProbe(query, userPromptProbeTimeout)
|
|
if err != nil || len(hits) == 0 {
|
|
return ""
|
|
}
|
|
return buildPromptInjection(hits)
|
|
}
|
|
|
|
// promptQuery normalizes a raw prompt into a search query, or "" when the
|
|
// prompt is too trivial / not code-related to bother probing. Slash commands
|
|
// and one-word acknowledgements ("ok", "go on") are skipped.
|
|
func promptQuery(prompt string) string {
|
|
p := strings.TrimSpace(prompt)
|
|
if p == "" || strings.HasPrefix(p, "/") {
|
|
return ""
|
|
}
|
|
p = strings.Join(strings.Fields(p), " ") // collapse newlines / runs of spaces
|
|
if r := []rune(p); len(r) > 240 {
|
|
p = strings.TrimSpace(string(r[:240]))
|
|
}
|
|
// Require at least one token of length >= 3 so "ok", "yes" don't probe.
|
|
for _, w := range strings.Fields(p) {
|
|
if len(w) >= 3 {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// maxInjectedHitsLean is the per-turn cap under the lean hook tier —
|
|
// the block keeps its cues but stops paying for marginal hits.
|
|
const maxInjectedHitsLean = 3
|
|
|
|
// buildPromptInjection renders the additionalContext block from search hits.
|
|
func buildPromptInjection(hits []grepSymbolHit) string {
|
|
if len(hits) == 0 {
|
|
return ""
|
|
}
|
|
lean := activeHookTier() == profiles.HookTierLean
|
|
limit := maxInjectedHits
|
|
if lean {
|
|
limit = maxInjectedHitsLean
|
|
}
|
|
if len(hits) > limit {
|
|
hits = hits[:limit]
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteString("## Gortex — relevant indexed symbols for your request\n\n")
|
|
if !lean {
|
|
sb.WriteString("Before reaching for grep/Read, these graph symbols look relevant to what you just asked:\n\n")
|
|
}
|
|
for _, h := range hits {
|
|
kind := h.Kind
|
|
if kind == "" {
|
|
kind = "symbol"
|
|
}
|
|
if h.Line > 0 {
|
|
fmt.Fprintf(&sb, "- `%s` (%s) — %s:%d\n", h.Name, kind, h.FilePath, h.Line)
|
|
} else {
|
|
fmt.Fprintf(&sb, "- `%s` (%s) — %s\n", h.Name, kind, h.FilePath)
|
|
}
|
|
}
|
|
if lean {
|
|
sb.WriteString("\nLeads, not the full picture — call `explore` with the request text for the ranked neighborhood " +
|
|
"in one call; `get_symbol_source` reads one symbol. Prefer these graph facts over grep/Read.\n")
|
|
} else {
|
|
sb.WriteString("\nThese are leads, not the full picture — call `explore` with the request text to get the ranked " +
|
|
"neighborhood (these symbols and their siblings, WITH source + call paths + the files to change) in one call, " +
|
|
"then answer or edit directly from it. To read just one of them use `get_symbol_source` (several: `batch_symbols`); " +
|
|
"trace with `find_usages` / `get_callers`. These are indexed graph facts — prefer them over grep/Read. " +
|
|
"Shell only (no MCP tools)? Reach any with `gortex call <tool> --arg k=v` (e.g. `" + toolref.CLIFallback("explore") + "`).\n")
|
|
}
|
|
return sb.String()
|
|
}
|