返回 DeepSeek-Reasonix
search.go
根目录 / internal / history / search.go
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
597 lines GO