| 1 | package history |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/historycatalog" |
| 10 | "reasonix/internal/tool" |
| 11 | ) |
| 12 | |
| 13 | type historyTool struct { |
| 14 | searcher historySearcher |
| 15 | } |
| 16 | |
| 17 | type historySearcher interface { |
| 18 | Search(context.Context, SearchRequest) ([]Hit, error) |
| 19 | Around(context.Context, AroundRequest) ([]MessageContext, error) |
| 20 | } |
| 21 | |
| 22 | // NewTool returns a read-only history retrieval tool bound to local sessions. |
| 23 | func NewTool(opts Options) tool.Tool { |
| 24 | return historyTool{searcher: NewSearcher(opts)} |
| 25 | } |
| 26 | |
| 27 | // NewIndexedTool is the production history tool. It never performs a |
| 28 | // synchronous directory scan; while the shared projection is building it |
| 29 | // returns the already indexed subset and an explicit progress notice. |
| 30 | func NewIndexedTool(opts Options) tool.Tool { |
| 31 | return historyTool{searcher: NewIndexedSearcher(opts)} |
| 32 | } |
| 33 | |
| 34 | func (historyTool) Name() string { return tool.HostHistory } |
| 35 | |
| 36 | func (historyTool) Description() string { |
| 37 | return "Search saved local session history with lightweight BM25 retrieval, then read messages around a hit. " + |
| 38 | "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. " + |
| 39 | "By default it searches user text, assistant text, tool inputs, and tool errors; normal tool outputs are excluded unless kind includes tool_output." |
| 40 | } |
| 41 | |
| 42 | func (historyTool) Schema() json.RawMessage { |
| 43 | return json.RawMessage(`{ |
| 44 | "type": "object", |
| 45 | "properties": { |
| 46 | "operation": {"type": "string", "enum": ["search", "around"], "description": "search ranks saved history; around returns nearby messages for a search hit."}, |
| 47 | "query": {"type": "string", "description": "Search query for operation=search."}, |
| 48 | "scope": {"type": "string", "enum": ["project", "global"], "description": "project searches the current session directory; global also includes compacted-history archives."}, |
| 49 | "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."}, |
| 50 | "tool_name": {"type": "string", "description": "Optional tool-name filter for tool_input, tool_error, or tool_output."}, |
| 51 | "limit": {"type": "integer", "description": "Maximum search hits to return, default 8, max 20."}, |
| 52 | "session_path": {"type": "string", "description": "Path from a search hit. Required for operation=around."}, |
| 53 | "message_index": {"type": "integer", "description": "Message index from a search hit. Required for operation=around."}, |
| 54 | "before": {"type": "integer", "description": "Messages before message_index for operation=around, default 3, max 10."}, |
| 55 | "after": {"type": "integer", "description": "Messages after message_index for operation=around, default 3, max 10."} |
| 56 | }, |
| 57 | "required": ["operation"] |
| 58 | }`) |
| 59 | } |
| 60 | |
| 61 | func (t historyTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 62 | var in struct { |
| 63 | Operation string `json:"operation"` |
| 64 | Query string `json:"query"` |
| 65 | Scope string `json:"scope"` |
| 66 | Kind []Kind `json:"kind"` |
| 67 | ToolName string `json:"tool_name"` |
| 68 | Limit int `json:"limit"` |
| 69 | SessionPath string `json:"session_path"` |
| 70 | MessageIndex *int `json:"message_index"` |
| 71 | Before int `json:"before"` |
| 72 | After int `json:"after"` |
| 73 | } |
| 74 | if err := json.Unmarshal(args, &in); err != nil { |
| 75 | return "", fmt.Errorf("invalid arguments: %w", err) |
| 76 | } |
| 77 | switch strings.TrimSpace(in.Operation) { |
| 78 | case "search": |
| 79 | hits, err := t.searcher.Search(ctx, SearchRequest{ |
| 80 | Query: in.Query, |
| 81 | Scope: in.Scope, |
| 82 | Kinds: in.Kind, |
| 83 | ToolName: in.ToolName, |
| 84 | Limit: in.Limit, |
| 85 | }) |
| 86 | if err != nil { |
| 87 | return "", err |
| 88 | } |
| 89 | formatted := formatHits(in.Query, hits) |
| 90 | if statusProvider, ok := t.searcher.(interface{ IndexStatus() historycatalog.Status }); ok { |
| 91 | status := statusProvider.IndexStatus() |
| 92 | if status.State != "ready" || status.Pending > 0 || (status.Total > 0 && status.Indexed < status.Total) { |
| 93 | formatted = fmt.Sprintf("History index is still building (%d/%d); results may be incomplete.\n\n%s", status.Indexed, status.Total, formatted) |
| 94 | } |
| 95 | } |
| 96 | return formatted, nil |
| 97 | case "around": |
| 98 | if in.MessageIndex == nil { |
| 99 | return "", fmt.Errorf("message_index is required for operation=around") |
| 100 | } |
| 101 | msgs, err := t.searcher.Around(ctx, AroundRequest{ |
| 102 | SessionPath: in.SessionPath, |
| 103 | MessageIndex: *in.MessageIndex, |
| 104 | Before: in.Before, |
| 105 | After: in.After, |
| 106 | }) |
| 107 | if err != nil { |
| 108 | return "", err |
| 109 | } |
| 110 | return formatAround(in.SessionPath, *in.MessageIndex, msgs), nil |
| 111 | case "": |
| 112 | return "", fmt.Errorf("operation is required") |
| 113 | default: |
| 114 | return "", fmt.Errorf("unknown operation %q", in.Operation) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func (historyTool) ReadOnly() bool { return true } |
| 119 | |
| 120 | func formatHits(query string, hits []Hit) string { |
| 121 | if len(hits) == 0 { |
| 122 | return strings.Join([]string{ |
| 123 | "No saved session history matched " + strconvQuote(query) + ".", |
| 124 | "", |
| 125 | "0 results does not prove the event never happened. Try:", |
| 126 | "1. Retry with fewer, rarer terms such as a function name, command, error phrase, ticket id, or decision keyword.", |
| 127 | "2. Widen scope from project to global when cross-project or compacted-history context may matter.", |
| 128 | "3. If you need tool output, include kind=[\"tool_output\"] or filter by tool_name for tool input/error/output searches.", |
| 129 | }, "\n") |
| 130 | } |
| 131 | var b strings.Builder |
| 132 | fmt.Fprintf(&b, "History search results for %s:\n", strconvQuote(query)) |
| 133 | for i, hit := range hits { |
| 134 | fmt.Fprintf(&b, "\n%d. score=%.3f source=%s session_id=%s message_index=%d kind=%s role=%s", |
| 135 | i+1, hit.Score, hit.Source, hit.SessionID, hit.MessageIndex, hit.Kind, hit.Role) |
| 136 | if hit.ToolName != "" { |
| 137 | fmt.Fprintf(&b, " tool=%s", hit.ToolName) |
| 138 | } |
| 139 | fmt.Fprintf(&b, "\n session_path: %s\n snippet: %s\n", |
| 140 | hit.SessionPath, hit.Snippet) |
| 141 | } |
| 142 | b.WriteString("\nUse operation=\"around\" with a session_path and message_index to read nearby messages.") |
| 143 | return strings.TrimSpace(b.String()) |
| 144 | } |
| 145 | |
| 146 | func formatAround(path string, idx int, msgs []MessageContext) string { |
| 147 | var b strings.Builder |
| 148 | fmt.Fprintf(&b, "History around %s message_index=%d:\n", path, idx) |
| 149 | for _, msg := range msgs { |
| 150 | fmt.Fprintf(&b, "\n%s\n", msg.Text) |
| 151 | } |
| 152 | return strings.TrimSpace(b.String()) |
| 153 | } |
| 154 | |
| 155 | func strconvQuote(s string) string { |
| 156 | b, _ := json.Marshal(s) |
| 157 | return string(b) |
| 158 | } |
| 159 |