Files
2026-07-13 13:00:08 +08:00

139 lines
5.4 KiB
Go

package history
import (
"context"
"encoding/json"
"fmt"
"strings"
"reasonix/internal/tool"
)
type historyTool struct {
searcher *Searcher
}
// NewTool returns a read-only history retrieval tool bound to local sessions.
func NewTool(opts Options) tool.Tool {
return historyTool{searcher: NewSearcher(opts)}
}
func (historyTool) Name() string { return "history" }
func (historyTool) Description() string {
return "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. " +
"Use search when past decisions, failed attempts, commands, or tool inputs may help the current task; use around with a returned session_path and message_index to inspect the nearby transcript. " +
"By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output."
}
func (historyTool) Schema() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."},
"query": {"type": "string", "description": "Search query for operation=search."},
"scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."},
"kind": {"type": "array", "items": {"type": "string", "enum": ["user_text", "assistant_text", "tool_input", "tool_error", "tool_output"]}, "description": "History parts to search. Defaults to user_text, assistant_text, tool_input, and tool_error."},
"tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."},
"limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."},
"session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."},
"message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."},
"before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."},
"after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."}
},
"required": ["operation"]
}`)
}
func (t historyTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var in struct {
Operation string `json:"operation"`
Query string `json:"query"`
Scope string `json:"scope"`
Kind []Kind `json:"kind"`
ToolName string `json:"tool_name"`
Limit int `json:"limit"`
SessionPath string `json:"session_path"`
MessageIndex *int `json:"message_index"`
Before int `json:"before"`
After int `json:"after"`
}
if err := json.Unmarshal(args, &in); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
switch strings.TrimSpace(in.Operation) {
case "search":
hits, err := t.searcher.Search(ctx, SearchRequest{
Query: in.Query,
Scope: in.Scope,
Kinds: in.Kind,
ToolName: in.ToolName,
Limit: in.Limit,
})
if err != nil {
return "", err
}
return formatHits(in.Query, hits), nil
case "around":
if in.MessageIndex == nil {
return "", fmt.Errorf("message_index is required for operation=around")
}
msgs, err := t.searcher.Around(ctx, AroundRequest{
SessionPath: in.SessionPath,
MessageIndex: *in.MessageIndex,
Before: in.Before,
After: in.After,
})
if err != nil {
return "", err
}
return formatAround(in.SessionPath, *in.MessageIndex, msgs), nil
case "":
return "", fmt.Errorf("operation is required")
default:
return "", fmt.Errorf("unknown operation %q", in.Operation)
}
}
func (historyTool) ReadOnly() bool { return true }
func formatHits(query string, hits []Hit) string {
if len(hits) == 0 {
return strings.Join([]string{
"No saved session history matched " + strconvQuote(query) + ".",
"",
"0 results does not prove the event never happened. Try:",
"1. Retry with fewer, rarer terms such as a function name, command, error phrase, ticket id, or decision keyword.",
"2. Widen scope from project to global when cross-project or compacted-history context may matter.",
"3. If you need tool output, include kind=[\"tool_output\"] or filter by tool_name for tool input/error/output searches.",
}, "\n")
}
var b strings.Builder
fmt.Fprintf(&b, "History search results for %s:\n", strconvQuote(query))
for i, hit := range hits {
fmt.Fprintf(&b, "\n%d. score=%.3f source=%s session_id=%s message_index=%d kind=%s role=%s",
i+1, hit.Score, hit.Source, hit.SessionID, hit.MessageIndex, hit.Kind, hit.Role)
if hit.ToolName != "" {
fmt.Fprintf(&b, " tool=%s", hit.ToolName)
}
fmt.Fprintf(&b, "\n session_path: %s\n snippet: %s\n",
hit.SessionPath, hit.Snippet)
}
b.WriteString("\nUse operation=\"around\" with a session_path and message_index to read nearby messages.")
return strings.TrimSpace(b.String())
}
func formatAround(path string, idx int, msgs []MessageContext) string {
var b strings.Builder
fmt.Fprintf(&b, "History around %s message_index=%d:\n", path, idx)
for _, msg := range msgs {
fmt.Fprintf(&b, "\n%s\n", msg.Text)
}
return strings.TrimSpace(b.String())
}
func strconvQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}