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