diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d590e..87f60e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,11 @@ from this file's releases; see CONTRACT.md for the stability policy. read-only `mcp_list_resources`, `mcp_read_resource`, `mcp_list_prompts` and `mcp_get_prompt` tools. - TUI: `ctrl+k` opens the command palette (the slash menu without typing `/`). +- Incremental indexing: successful `write`/`edit`/`multiedit` calls notify the + engine, which updates the symbol index and filename index immediately + (`ToolRegistry.SetFileChangeHandler`). +- Cache diagnostics: `llm.PrefixFingerprint` hashes the stable system+tools + prefix; `RAKIT_DEBUG=1` logs it per request so cache busts are visible. - Permission engine (`permission`): modes `plan|build|edit|yolo|auto`, per-tool allow/deny/ask rules from `~/.rakit/settings.json` and `/.rakit/settings.json`, per-call decisions, project/session diff --git a/engine/engine.go b/engine/engine.go index ba00fdd..8550e83 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -84,6 +84,7 @@ type Engine struct { mcpMgr *mcp.Manager mgr *agent.Manager idxStore *index.Store + searchMgr *search.SearchManager home string workspace string yolo bool @@ -122,8 +123,9 @@ func New(opts Options) (*Engine, error) { } toolReg := opts.Tools + var searchMgr *search.SearchManager if toolReg == nil { - searchMgr := search.NewSearchManager() + searchMgr = search.NewSearchManager() searchMgr.SetDirectories([]string{workspace}) toolReg = tools.NewRegistry(searchMgr) } @@ -163,6 +165,18 @@ func New(opts Options) (*Engine, error) { } }) } + if toolReg != nil { + toolReg.SetFileChangeHandler(func(path string) { + go func() { + if err := idxStore.Update(path); err != nil { + log.Printf("[engine] index update %s: %v", path, err) + } + if searchMgr != nil { + searchMgr.IndexFile(path) + } + }() + }) + } if toolReg != nil { if _, exists := toolReg.Lookup("symbol_search"); !exists { toolReg.Register(tools.ToolSpec{ @@ -249,6 +263,7 @@ func New(opts Options) (*Engine, error) { mcpMgr: mcpMgr, mgr: mgr, idxStore: idxStore, + searchMgr: searchMgr, home: home, workspace: workspace, yolo: opts.Yolo, diff --git a/llm/fingerprint.go b/llm/fingerprint.go new file mode 100644 index 0000000..0441a98 --- /dev/null +++ b/llm/fingerprint.go @@ -0,0 +1,29 @@ +package llm + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" +) + +// PrefixFingerprint hashes the cacheable prefix: system messages plus tool +// definitions. Log it (RAKIT_DEBUG=1) to detect accidental cache busting. +func PrefixFingerprint(messages []LLMMessage, tools []ToolDefinition) string { + h := sha256.New() + for _, m := range messages { + if m.Role != RoleSystem { + continue + } + switch c := m.Content.(type) { + case string: + h.Write([]byte(c)) + default: + b, _ := json.Marshal(c) + h.Write(b) + } + h.Write([]byte{'\n'}) + } + b, _ := json.Marshal(tools) + h.Write(b) + return hex.EncodeToString(h.Sum(nil))[:16] +} diff --git a/llm/fingerprint_test.go b/llm/fingerprint_test.go new file mode 100644 index 0000000..9cd033c --- /dev/null +++ b/llm/fingerprint_test.go @@ -0,0 +1,19 @@ +package llm + +import "testing" + +func TestPrefixFingerprintStability(t *testing.T) { + tools := []ToolDefinition{{Type: "function", Function: FunctionSpec{Name: "read"}}} + a := []LLMMessage{{Role: RoleSystem, Content: "system"}, {Role: RoleUser, Content: "hello"}} + b := []LLMMessage{{Role: RoleSystem, Content: "system"}, {Role: RoleUser, Content: "different"}} + if PrefixFingerprint(a, tools) != PrefixFingerprint(b, tools) { + t.Fatal("user content must not affect the prefix fingerprint") + } + c := []LLMMessage{{Role: RoleSystem, Content: "changed"}, {Role: RoleUser, Content: "hello"}} + if PrefixFingerprint(a, tools) == PrefixFingerprint(c, tools) { + t.Fatal("system change must change the fingerprint") + } + if PrefixFingerprint(a, tools) == PrefixFingerprint(a, nil) { + t.Fatal("tools change must change the fingerprint") + } +} diff --git a/llm/provider.go b/llm/provider.go index b5027e9..6e7e9f1 100644 --- a/llm/provider.go +++ b/llm/provider.go @@ -981,6 +981,10 @@ func (c *LLMClient) ChatWithProviderStream( if len(tools) > 0 { reqBody["tools"] = reqTools } + if os.Getenv("RAKIT_DEBUG") != "" { + log.Printf("[cache] provider=%s model=%s tools=%d msgs=%d prefix=%s", + targetProviderID, model, len(tools), len(messages), PrefixFingerprint(messages, tools)) + } // Normalize reasoning effort ("off", "low", "medium", "high") so gateways // like OpenAI and OpenRouter never receive unsupported values (e.g. "max"). diff --git a/tools/registry.go b/tools/registry.go index 0d4e67a..1e57e64 100644 --- a/tools/registry.go +++ b/tools/registry.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "sort" "strings" "sync" @@ -46,11 +47,12 @@ type ToolSpec struct { } type Registry struct { - mu sync.RWMutex - tools map[string]ToolSpec - nextOrder int - mcpCaller MCPCaller - bg bgState + mu sync.RWMutex + tools map[string]ToolSpec + nextOrder int + mcpCaller MCPCaller + fileChange func(path string) + bg bgState } func NewRegistry(searchMgr *search.SearchManager) *Registry { @@ -316,6 +318,38 @@ func (r *Registry) Definitions() []llm.ToolDefinition { return defs } +// SetFileChangeHandler installs a callback invoked after a successful +// file-mutating tool (write/edit/multiedit) with the absolute path. Hosts use +// it to update search/symbol indexes incrementally. +func (r *Registry) SetFileChangeHandler(fn func(path string)) { + r.mu.Lock() + r.fileChange = fn + r.mu.Unlock() +} + +func (r *Registry) notifyFileChange(ctx context.Context, spec ToolSpec, args map[string]any) { + if spec.ReadOnly || spec.Category != "file" { + return + } + r.mu.RLock() + fn := r.fileChange + r.mu.RUnlock() + if fn == nil { + return + } + for _, key := range []string{"path", "file_path", "target_file", "file"} { + p, ok := args[key].(string) + if !ok || p == "" { + continue + } + if !filepath.IsAbs(p) { + p = filepath.Join(defaultCwd(ctx), p) + } + fn(filepath.Clean(p)) + return + } +} + func (r *Registry) Lookup(name string) (ToolSpec, bool) { r.mu.RLock() defer r.mu.RUnlock() @@ -360,5 +394,9 @@ func (r *Registry) Execute(ctx context.Context, name string, rawArgs string) (in args = make(map[string]interface{}) } - return spec.Handler(ctx, args) + res, err := spec.Handler(ctx, args) + if err == nil { + r.notifyFileChange(ctx, spec, args) + } + return res, err }