| 1 | package history |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/agent" |
| 13 | fileencoding "reasonix/internal/fileutil/encoding" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/retrieval" |
| 16 | "reasonix/internal/store" |
| 17 | ) |
| 18 | |
| 19 | // Kind identifies the part of a saved message indexed for retrieval. |
| 20 | type Kind string |
| 21 | |
| 22 | const ( |
| 23 | KindUserText Kind = "user_text" |
| 24 | KindAssistantText Kind = "assistant_text" |
| 25 | KindToolInput Kind = "tool_input" |
| 26 | KindToolError Kind = "tool_error" |
| 27 | KindToolOutput Kind = "tool_output" |
| 28 | ) |
| 29 | |
| 30 | const ( |
| 31 | scopeProject = "project" |
| 32 | scopeGlobal = "global" |
| 33 | |
| 34 | defaultLimit = 8 |
| 35 | maxLimit = 20 |
| 36 | defaultAround = 3 |
| 37 | maxAround = 10 |
| 38 | maxSnippet = 240 |
| 39 | scoreFloor = 0.15 |
| 40 | ) |
| 41 | |
| 42 | var defaultKinds = map[Kind]bool{ |
| 43 | KindUserText: true, |
| 44 | KindAssistantText: true, |
| 45 | KindToolInput: true, |
| 46 | KindToolError: true, |
| 47 | } |
| 48 | |
| 49 | // Options binds a Searcher to the session/history roots it may read. |
| 50 | type Options struct { |
| 51 | // SessionDir is the current controller's session directory. In desktop this |
| 52 | // is usually project-scoped; in CLI it is often the user-global session dir. |
| 53 | SessionDir string |
| 54 | // GlobalSessionDir is the user-global session directory. It is searched only |
| 55 | // when the caller asks for global scope, and may equal SessionDir. |
| 56 | GlobalSessionDir string |
| 57 | ArchiveDir string |
| 58 | } |
| 59 | |
| 60 | // Searcher performs lightweight BM25 retrieval over saved session JSONL files. |
| 61 | type Searcher struct { |
| 62 | sessionDir string |
| 63 | globalSessionDir string |
| 64 | archiveDir string |
| 65 | } |
| 66 | |
| 67 | // NewSearcher returns a searcher confined to the supplied directories. |
| 68 | func NewSearcher(opts Options) *Searcher { |
| 69 | return &Searcher{ |
| 70 | sessionDir: strings.TrimSpace(opts.SessionDir), |
| 71 | globalSessionDir: strings.TrimSpace(opts.GlobalSessionDir), |
| 72 | archiveDir: strings.TrimSpace(opts.ArchiveDir), |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // SearchRequest describes a history search. |
| 77 | type SearchRequest struct { |
| 78 | Query string |
| 79 | Scope string |
| 80 | Kinds []Kind |
| 81 | ToolName string |
| 82 | Limit int |
| 83 | } |
| 84 | |
| 85 | // AroundRequest fetches messages adjacent to a search hit. |
| 86 | type AroundRequest struct { |
| 87 | SessionPath string |
| 88 | MessageIndex int |
| 89 | Before int |
| 90 | After int |
| 91 | } |
| 92 | |
| 93 | // Hit is a ranked search result. |
| 94 | type Hit struct { |
| 95 | Score float64 |
| 96 | SessionPath string |
| 97 | SessionID string |
| 98 | Source string |
| 99 | MessageIndex int |
| 100 | Role provider.Role |
| 101 | Kind Kind |
| 102 | ToolName string |
| 103 | Snippet string |
| 104 | } |
| 105 | |
| 106 | // MessageContext is one message returned by Around. |
| 107 | type MessageContext struct { |
| 108 | Index int |
| 109 | Text string |
| 110 | } |
| 111 | |
| 112 | type sourceFile struct { |
| 113 | path string |
| 114 | source string |
| 115 | mod int64 |
| 116 | } |
| 117 | |
| 118 | type document struct { |
| 119 | source sourceFile |
| 120 | messageIndex int |
| 121 | role provider.Role |
| 122 | kind Kind |
| 123 | toolName string |
| 124 | text string |
| 125 | counts map[string]int |
| 126 | length int |
| 127 | } |
| 128 | |
| 129 | // Search ranks saved history by BM25. It indexes only the selected documents for |
| 130 | // this call, which keeps the implementation dependency-free and cache-neutral. |
| 131 | func (s *Searcher) Search(ctx context.Context, req SearchRequest) ([]Hit, error) { |
| 132 | query := strings.TrimSpace(req.Query) |
| 133 | if query == "" { |
| 134 | return nil, fmt.Errorf("query is required") |
| 135 | } |
| 136 | queryTerms, err := retrieval.QueryTerms(query) |
| 137 | if err != nil { |
| 138 | return nil, err |
| 139 | } |
| 140 | scope, err := normalizeScope(req.Scope) |
| 141 | if err != nil { |
| 142 | return nil, err |
| 143 | } |
| 144 | limit := clamp(req.Limit, defaultLimit, maxLimit) |
| 145 | kindSet, err := normalizeKinds(req.Kinds) |
| 146 | if err != nil { |
| 147 | return nil, err |
| 148 | } |
| 149 | toolName := strings.TrimSpace(req.ToolName) |
| 150 | |
| 151 | sources, err := s.sources(scope) |
| 152 | if err != nil { |
| 153 | return nil, err |
| 154 | } |
| 155 | var docs []document |
| 156 | for _, src := range sources { |
| 157 | if err := ctx.Err(); err != nil { |
| 158 | return nil, err |
| 159 | } |
| 160 | msgs, err := loadMessages(src.path) |
| 161 | if err != nil { |
| 162 | continue |
| 163 | } |
| 164 | docs = append(docs, extractDocuments(src, msgs, kindSet, toolName)...) |
| 165 | } |
| 166 | if len(docs) == 0 { |
| 167 | return nil, nil |
| 168 | } |
| 169 | |
| 170 | df := map[string]int{} |
| 171 | totalLen := 0 |
| 172 | for i := range docs { |
| 173 | totalLen += docs[i].length |
| 174 | seen := map[string]bool{} |
| 175 | for term := range docs[i].counts { |
| 176 | if !seen[term] { |
| 177 | df[term]++ |
| 178 | seen[term] = true |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | avgLen := float64(totalLen) / float64(len(docs)) |
| 183 | if avgLen <= 0 { |
| 184 | avgLen = 1 |
| 185 | } |
| 186 | |
| 187 | var hits []Hit |
| 188 | for _, doc := range docs { |
| 189 | score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen) |
| 190 | if score <= 0 { |
| 191 | continue |
| 192 | } |
| 193 | hits = append(hits, Hit{ |
| 194 | Score: score, |
| 195 | SessionPath: doc.source.path, |
| 196 | SessionID: sessionID(doc.source.path), |
| 197 | Source: doc.source.source, |
| 198 | MessageIndex: doc.messageIndex, |
| 199 | Role: doc.role, |
| 200 | Kind: doc.kind, |
| 201 | ToolName: doc.toolName, |
| 202 | Snippet: retrieval.MakeSnippet(doc.text, query, queryTerms, maxSnippet), |
| 203 | }) |
| 204 | } |
| 205 | sort.Slice(hits, func(i, j int) bool { |
| 206 | if hits[i].Score == hits[j].Score { |
| 207 | if hits[i].SessionPath == hits[j].SessionPath { |
| 208 | return hits[i].MessageIndex < hits[j].MessageIndex |
| 209 | } |
| 210 | return hits[i].SessionPath < hits[j].SessionPath |
| 211 | } |
| 212 | return hits[i].Score > hits[j].Score |
| 213 | }) |
| 214 | hits = retrieval.KeepTopRelativeScore(hits, scoreFloor, func(hit Hit) float64 { |
| 215 | return hit.Score |
| 216 | }) |
| 217 | if len(hits) > limit { |
| 218 | hits = hits[:limit] |
| 219 | } |
| 220 | return hits, nil |
| 221 | } |
| 222 | |
| 223 | // Around returns a compact transcript window around a saved message. |
| 224 | func (s *Searcher) Around(ctx context.Context, req AroundRequest) ([]MessageContext, error) { |
| 225 | path := strings.TrimSpace(req.SessionPath) |
| 226 | if path == "" { |
| 227 | return nil, fmt.Errorf("session_path is required") |
| 228 | } |
| 229 | if req.MessageIndex < 0 { |
| 230 | return nil, fmt.Errorf("message_index must be non-negative") |
| 231 | } |
| 232 | if !s.allowedPath(path) { |
| 233 | return nil, fmt.Errorf("session_path is outside the configured history roots") |
| 234 | } |
| 235 | if !s.visiblePath(path) { |
| 236 | return nil, fmt.Errorf("session_path is pending cleanup") |
| 237 | } |
| 238 | if err := ctx.Err(); err != nil { |
| 239 | return nil, err |
| 240 | } |
| 241 | msgs, err := loadMessages(path) |
| 242 | if err != nil { |
| 243 | return nil, err |
| 244 | } |
| 245 | if req.MessageIndex >= len(msgs) { |
| 246 | return nil, fmt.Errorf("message_index %d is outside session length %d", req.MessageIndex, len(msgs)) |
| 247 | } |
| 248 | before := clamp(req.Before, defaultAround, maxAround) |
| 249 | after := clamp(req.After, defaultAround, maxAround) |
| 250 | start := req.MessageIndex - before |
| 251 | if start < 0 { |
| 252 | start = 0 |
| 253 | } |
| 254 | remainingAfter := len(msgs) - req.MessageIndex - 1 |
| 255 | end := len(msgs) |
| 256 | if after < remainingAfter { |
| 257 | end = len(msgs) - (remainingAfter - after) |
| 258 | } |
| 259 | out := make([]MessageContext, 0, end-start) |
| 260 | for i := start; i < end; i++ { |
| 261 | out = append(out, MessageContext{Index: i, Text: renderMessage(i, msgs[i])}) |
| 262 | } |
| 263 | return out, nil |
| 264 | } |
| 265 | |
| 266 | func normalizeScope(scope string) (string, error) { |
| 267 | switch strings.TrimSpace(scope) { |
| 268 | case "", scopeProject: |
| 269 | return scopeProject, nil |
| 270 | case scopeGlobal: |
| 271 | return scopeGlobal, nil |
| 272 | default: |
| 273 | return "", fmt.Errorf("scope must be %q or %q", scopeProject, scopeGlobal) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func normalizeKinds(kinds []Kind) (map[Kind]bool, error) { |
| 278 | if len(kinds) == 0 { |
| 279 | out := make(map[Kind]bool, len(defaultKinds)) |
| 280 | for k, v := range defaultKinds { |
| 281 | out[k] = v |
| 282 | } |
| 283 | return out, nil |
| 284 | } |
| 285 | out := map[Kind]bool{} |
| 286 | for _, k := range kinds { |
| 287 | switch k { |
| 288 | case KindUserText, KindAssistantText, KindToolInput, KindToolError, KindToolOutput: |
| 289 | out[k] = true |
| 290 | default: |
| 291 | return nil, fmt.Errorf("unknown kind %q", k) |
| 292 | } |
| 293 | } |
| 294 | return out, nil |
| 295 | } |
| 296 | |
| 297 | func (s *Searcher) sources(scope string) ([]sourceFile, error) { |
| 298 | var out []sourceFile |
| 299 | seen := map[string]bool{} |
| 300 | out = appendSessionSources(out, seen, s.sessionDir, scopeProject) |
| 301 | if scope == scopeGlobal { |
| 302 | out = appendSessionSources(out, seen, s.globalSessionDir, scopeGlobal) |
| 303 | out = appendFiles(out, seen, listJSONL(s.archiveDir, "archive", nil)...) |
| 304 | } |
| 305 | sort.Slice(out, func(i, j int) bool { |
| 306 | if out[i].mod == out[j].mod { |
| 307 | return out[i].path < out[j].path |
| 308 | } |
| 309 | return out[i].mod > out[j].mod |
| 310 | }) |
| 311 | return out, nil |
| 312 | } |
| 313 | |
| 314 | func appendSessionSources(out []sourceFile, seen map[string]bool, dir, source string) []sourceFile { |
| 315 | out = appendFiles(out, seen, listJSONL(dir, source, agent.IsVisibleSession)...) |
| 316 | if strings.TrimSpace(dir) != "" { |
| 317 | out = appendFiles(out, seen, listJSONL(subagentsDir(dir), source, func(path string) bool { |
| 318 | return visibleSubagentSession(dir, path) |
| 319 | })...) |
| 320 | } |
| 321 | return out |
| 322 | } |
| 323 | |
| 324 | func appendFiles(out []sourceFile, seen map[string]bool, files ...sourceFile) []sourceFile { |
| 325 | for _, file := range files { |
| 326 | key := file.path |
| 327 | if abs, err := filepath.Abs(file.path); err == nil { |
| 328 | key = abs |
| 329 | } |
| 330 | if seen[key] { |
| 331 | continue |
| 332 | } |
| 333 | seen[key] = true |
| 334 | out = append(out, file) |
| 335 | } |
| 336 | return out |
| 337 | } |
| 338 | |
| 339 | func listJSONL(dir, source string, visible func(string) bool) []sourceFile { |
| 340 | if strings.TrimSpace(dir) == "" { |
| 341 | return nil |
| 342 | } |
| 343 | entries, err := os.ReadDir(dir) |
| 344 | if err != nil { |
| 345 | return nil |
| 346 | } |
| 347 | var out []sourceFile |
| 348 | for _, entry := range entries { |
| 349 | if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) { |
| 350 | continue |
| 351 | } |
| 352 | info, err := entry.Info() |
| 353 | if err != nil { |
| 354 | continue |
| 355 | } |
| 356 | path := filepath.Join(dir, entry.Name()) |
| 357 | if visible != nil && !visible(path) { |
| 358 | continue |
| 359 | } |
| 360 | // Recency must track the event log too: the .jsonl checkpoint's mtime |
| 361 | // only moves at checkpoints. |
| 362 | mod := info.ModTime() |
| 363 | if contentMod := agent.SessionContentModTime(path); !contentMod.IsZero() { |
| 364 | mod = contentMod |
| 365 | } |
| 366 | out = append(out, sourceFile{ |
| 367 | path: path, |
| 368 | source: source, |
| 369 | mod: mod.UnixNano(), |
| 370 | }) |
| 371 | } |
| 372 | return out |
| 373 | } |
| 374 | |
| 375 | func loadMessages(path string) ([]provider.Message, error) { |
| 376 | sess, err := agent.LoadSession(path) |
| 377 | if err != nil { |
| 378 | return nil, err |
| 379 | } |
| 380 | return sess.Snapshot(), nil |
| 381 | } |
| 382 | |
| 383 | func extractDocuments(src sourceFile, msgs []provider.Message, kinds map[Kind]bool, toolName string) []document { |
| 384 | var docs []document |
| 385 | for i, msg := range msgs { |
| 386 | switch msg.Role { |
| 387 | case provider.RoleUser: |
| 388 | if kinds[KindUserText] && strings.TrimSpace(msg.Content) != "" { |
| 389 | docs = appendDoc(docs, src, i, msg.Role, KindUserText, "", stripComposePrefixes(msg.Content)) |
| 390 | } |
| 391 | case provider.RoleAssistant: |
| 392 | if kinds[KindAssistantText] && strings.TrimSpace(msg.Content) != "" { |
| 393 | docs = appendDoc(docs, src, i, msg.Role, KindAssistantText, "", msg.Content) |
| 394 | } |
| 395 | if kinds[KindToolInput] { |
| 396 | for _, call := range msg.ToolCalls { |
| 397 | if toolName != "" && call.Name != toolName { |
| 398 | continue |
| 399 | } |
| 400 | text := strings.TrimSpace(call.Name + " " + call.Arguments) |
| 401 | docs = appendDoc(docs, src, i, msg.Role, KindToolInput, call.Name, text) |
| 402 | } |
| 403 | } |
| 404 | case provider.RoleTool: |
| 405 | if toolName != "" && msg.Name != toolName { |
| 406 | continue |
| 407 | } |
| 408 | if kinds[KindToolError] && isToolError(msg.Content) { |
| 409 | docs = appendDoc(docs, src, i, msg.Role, KindToolError, msg.Name, msg.Name+" "+msg.Content) |
| 410 | } |
| 411 | if kinds[KindToolOutput] { |
| 412 | docs = appendDoc(docs, src, i, msg.Role, KindToolOutput, msg.Name, msg.Name+" "+msg.Content) |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | return docs |
| 417 | } |
| 418 | |
| 419 | func appendDoc(docs []document, src sourceFile, idx int, role provider.Role, kind Kind, toolName, text string) []document { |
| 420 | text = strings.TrimSpace(text) |
| 421 | if text == "" { |
| 422 | return docs |
| 423 | } |
| 424 | terms := retrieval.Tokens(text) |
| 425 | if len(terms) == 0 { |
| 426 | return docs |
| 427 | } |
| 428 | counts := retrieval.Counts(terms) |
| 429 | return append(docs, document{ |
| 430 | source: src, |
| 431 | messageIndex: idx, |
| 432 | role: role, |
| 433 | kind: kind, |
| 434 | toolName: toolName, |
| 435 | text: text, |
| 436 | counts: counts, |
| 437 | length: len(terms), |
| 438 | }) |
| 439 | } |
| 440 | |
| 441 | func isToolError(content string) bool { |
| 442 | s := strings.ToLower(strings.TrimSpace(content)) |
| 443 | return strings.HasPrefix(s, "error:") || |
| 444 | strings.HasPrefix(s, "blocked:") || |
| 445 | strings.Contains(s, "permission denied") |
| 446 | } |
| 447 | |
| 448 | func sessionID(path string) string { |
| 449 | base := filepath.Base(path) |
| 450 | return strings.TrimSuffix(base, filepath.Ext(base)) |
| 451 | } |
| 452 | |
| 453 | func renderMessage(idx int, msg provider.Message) string { |
| 454 | var b strings.Builder |
| 455 | switch msg.Role { |
| 456 | case provider.RoleUser: |
| 457 | fmt.Fprintf(&b, "[%d user]\n%s", idx, truncate(stripComposePrefixes(msg.Content), 2000)) |
| 458 | case provider.RoleAssistant: |
| 459 | if strings.TrimSpace(msg.Content) != "" { |
| 460 | fmt.Fprintf(&b, "[%d assistant]\n%s", idx, truncate(msg.Content, 2000)) |
| 461 | } else { |
| 462 | fmt.Fprintf(&b, "[%d assistant]", idx) |
| 463 | } |
| 464 | for _, call := range msg.ToolCalls { |
| 465 | fmt.Fprintf(&b, "\n[tool call: %s]\n%s", call.Name, truncate(call.Arguments, 1200)) |
| 466 | } |
| 467 | case provider.RoleTool: |
| 468 | fmt.Fprintf(&b, "[%d tool %s result]\n%s", idx, msg.Name, truncate(msg.Content, 2000)) |
| 469 | case provider.RoleSystem: |
| 470 | fmt.Fprintf(&b, "[%d system]\n%s", idx, truncate(msg.Content, 1200)) |
| 471 | default: |
| 472 | fmt.Fprintf(&b, "[%d %s]\n%s", idx, msg.Role, truncate(msg.Content, 2000)) |
| 473 | } |
| 474 | return strings.TrimSpace(b.String()) |
| 475 | } |
| 476 | |
| 477 | func truncate(s string, maxRunes int) string { |
| 478 | s = strings.TrimSpace(s) |
| 479 | runes := []rune(s) |
| 480 | if len(runes) <= maxRunes { |
| 481 | return s |
| 482 | } |
| 483 | return string(runes[:maxRunes]) + "..." |
| 484 | } |
| 485 | |
| 486 | func clamp(n, def, max int) int { |
| 487 | if n <= 0 { |
| 488 | return def |
| 489 | } |
| 490 | if n > max { |
| 491 | return max |
| 492 | } |
| 493 | return n |
| 494 | } |
| 495 | |
| 496 | func (s *Searcher) visiblePath(path string) bool { |
| 497 | switch { |
| 498 | case underRoot(path, subagentsDir(s.sessionDir)): |
| 499 | return visibleSubagentSession(s.sessionDir, path) |
| 500 | case underRoot(path, s.sessionDir): |
| 501 | return agent.IsVisibleSession(path) |
| 502 | case underRoot(path, subagentsDir(s.globalSessionDir)): |
| 503 | return visibleSubagentSession(s.globalSessionDir, path) |
| 504 | case underRoot(path, s.globalSessionDir): |
| 505 | return agent.IsVisibleSession(path) |
| 506 | case underRoot(path, s.archiveDir): |
| 507 | return true |
| 508 | default: |
| 509 | return false |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | func visibleSubagentSession(sessionDir, path string) bool { |
| 514 | if !agent.IsVisibleSession(path) { |
| 515 | return false |
| 516 | } |
| 517 | parentSession, ok := subagentParentSession(path) |
| 518 | if !ok || parentSession == "" { |
| 519 | return true |
| 520 | } |
| 521 | return !agent.IsCleanupPending(filepath.Join(sessionDir, parentSession+".jsonl")) |
| 522 | } |
| 523 | |
| 524 | func subagentParentSession(path string) (string, bool) { |
| 525 | ref := strings.TrimSuffix(filepath.Base(path), ".jsonl") |
| 526 | if ref == "" || ref == filepath.Base(path) { |
| 527 | return "", false |
| 528 | } |
| 529 | b, err := fileencoding.ReadFileUTF8(filepath.Join(filepath.Dir(path), ref+".meta.json")) |
| 530 | if err != nil { |
| 531 | return "", false |
| 532 | } |
| 533 | var meta agent.SubagentMeta |
| 534 | if err := json.Unmarshal(b, &meta); err != nil { |
| 535 | return "", false |
| 536 | } |
| 537 | return strings.TrimSpace(meta.ParentSession), true |
| 538 | } |
| 539 | |
| 540 | func subagentsDir(dir string) string { |
| 541 | if strings.TrimSpace(dir) == "" { |
| 542 | return "" |
| 543 | } |
| 544 | return filepath.Join(dir, "subagents") |
| 545 | } |
| 546 | |
| 547 | func (s *Searcher) allowedPath(path string) bool { |
| 548 | roots := []string{s.sessionDir, s.globalSessionDir, s.archiveDir} |
| 549 | if s.sessionDir != "" { |
| 550 | roots = append(roots, subagentsDir(s.sessionDir)) |
| 551 | } |
| 552 | if s.globalSessionDir != "" { |
| 553 | roots = append(roots, subagentsDir(s.globalSessionDir)) |
| 554 | } |
| 555 | for _, root := range roots { |
| 556 | if underRoot(path, root) { |
| 557 | return true |
| 558 | } |
| 559 | } |
| 560 | return false |
| 561 | } |
| 562 | |
| 563 | func underRoot(path, root string) bool { |
| 564 | if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" { |
| 565 | return false |
| 566 | } |
| 567 | absPath, err := filepath.Abs(path) |
| 568 | if err != nil { |
| 569 | return false |
| 570 | } |
| 571 | absRoot, err := filepath.Abs(root) |
| 572 | if err != nil { |
| 573 | return false |
| 574 | } |
| 575 | rel, err := filepath.Rel(absRoot, absPath) |
| 576 | if err != nil { |
| 577 | return false |
| 578 | } |
| 579 | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) |
| 580 | } |
| 581 | |
| 582 | // MarshalJSON keeps Hit stable if frontends choose to expose the same data later. |
| 583 | func (h Hit) MarshalJSON() ([]byte, error) { |
| 584 | type hit struct { |
| 585 | Score float64 `json:"score"` |
| 586 | SessionPath string `json:"session_path"` |
| 587 | SessionID string `json:"session_id"` |
| 588 | Source string `json:"source"` |
| 589 | MessageIndex int `json:"message_index"` |
| 590 | Role provider.Role `json:"role"` |
| 591 | Kind Kind `json:"kind"` |
| 592 | ToolName string `json:"tool_name,omitempty"` |
| 593 | Snippet string `json:"snippet"` |
| 594 | } |
| 595 | return json.Marshal(hit(h)) |
| 596 | } |
| 597 |