项目文件夹

文件
2026-07-13 13:00:08 +08:00

597 行
15 KiB
Go

package history
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/agent"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/provider"
"reasonix/internal/retrieval"
"reasonix/internal/store"
)
// Kind identifies the part of a saved message indexed for retrieval.
type Kind string
const (
KindUserText Kind = "user_text"
KindAssistantText Kind = "assistant_text"
KindToolInput Kind = "tool_input"
KindToolError Kind = "tool_error"
KindToolOutput Kind = "tool_output"
)
const (
scopeProject = "project"
scopeGlobal = "global"
defaultLimit = 8
maxLimit = 20
defaultAround = 3
maxAround = 10
maxSnippet = 240
scoreFloor = 0.15
)
var defaultKinds = map[Kind]bool{
KindUserText: true,
KindAssistantText: true,
KindToolInput: true,
KindToolError: true,
}
// Options binds a Searcher to the session/history roots it may read.
type Options struct {
// SessionDir is the current controller's session directory. In desktop this
// is usually project-scoped; in CLI it is often the user-global session dir.
SessionDir string
// GlobalSessionDir is the user-global session directory. It is searched only
// when the caller asks for global scope, and may equal SessionDir.
GlobalSessionDir string
ArchiveDir string
}
// Searcher performs lightweight BM25 retrieval over saved session JSONL files.
type Searcher struct {
sessionDir string
globalSessionDir string
archiveDir string
}
// NewSearcher returns a searcher confined to the supplied directories.
func NewSearcher(opts Options) *Searcher {
return &Searcher{
sessionDir: strings.TrimSpace(opts.SessionDir),
globalSessionDir: strings.TrimSpace(opts.GlobalSessionDir),
archiveDir: strings.TrimSpace(opts.ArchiveDir),
}
}
// SearchRequest describes a history search.
type SearchRequest struct {
Query string
Scope string
Kinds []Kind
ToolName string
Limit int
}
// AroundRequest fetches messages adjacent to a search hit.
type AroundRequest struct {
SessionPath string
MessageIndex int
Before int
After int
}
// Hit is a ranked search result.
type Hit struct {
Score float64
SessionPath string
SessionID string
Source string
MessageIndex int
Role provider.Role
Kind Kind
ToolName string
Snippet string
}
// MessageContext is one message returned by Around.
type MessageContext struct {
Index int
Text string
}
type sourceFile struct {
path string
source string
mod int64
}
type document struct {
source sourceFile
messageIndex int
role provider.Role
kind Kind
toolName string
text string
counts map[string]int
length int
}
// Search ranks saved history by BM25. It indexes only the selected documents for
// this call, which keeps the implementation dependency-free and cache-neutral.
func (s *Searcher) Search(ctx context.Context, req SearchRequest) ([]Hit, error) {
query := strings.TrimSpace(req.Query)
if query == "" {
return nil, fmt.Errorf("query is required")
}
queryTerms, err := retrieval.QueryTerms(query)
if err != nil {
return nil, err
}
scope, err := normalizeScope(req.Scope)
if err != nil {
return nil, err
}
limit := clamp(req.Limit, defaultLimit, maxLimit)
kindSet, err := normalizeKinds(req.Kinds)
if err != nil {
return nil, err
}
toolName := strings.TrimSpace(req.ToolName)
sources, err := s.sources(scope)
if err != nil {
return nil, err
}
var docs []document
for _, src := range sources {
if err := ctx.Err(); err != nil {
return nil, err
}
msgs, err := loadMessages(src.path)
if err != nil {
continue
}
docs = append(docs, extractDocuments(src, msgs, kindSet, toolName)...)
}
if len(docs) == 0 {
return nil, nil
}
df := map[string]int{}
totalLen := 0
for i := range docs {
totalLen += docs[i].length
seen := map[string]bool{}
for term := range docs[i].counts {
if !seen[term] {
df[term]++
seen[term] = true
}
}
}
avgLen := float64(totalLen) / float64(len(docs))
if avgLen <= 0 {
avgLen = 1
}
var hits []Hit
for _, doc := range docs {
score := retrieval.BM25Score(doc.counts, doc.length, queryTerms, df, len(docs), avgLen)
if score <= 0 {
continue
}
hits = append(hits, Hit{
Score: score,
SessionPath: doc.source.path,
SessionID: sessionID(doc.source.path),
Source: doc.source.source,
MessageIndex: doc.messageIndex,
Role: doc.role,
Kind: doc.kind,
ToolName: doc.toolName,
Snippet: retrieval.MakeSnippet(doc.text, query, queryTerms, maxSnippet),
})
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].Score == hits[j].Score {
if hits[i].SessionPath == hits[j].SessionPath {
return hits[i].MessageIndex < hits[j].MessageIndex
}
return hits[i].SessionPath < hits[j].SessionPath
}
return hits[i].Score > hits[j].Score
})
hits = retrieval.KeepTopRelativeScore(hits, scoreFloor, func(hit Hit) float64 {
return hit.Score
})
if len(hits) > limit {
hits = hits[:limit]
}
return hits, nil
}
// Around returns a compact transcript window around a saved message.
func (s *Searcher) Around(ctx context.Context, req AroundRequest) ([]MessageContext, error) {
path := strings.TrimSpace(req.SessionPath)
if path == "" {
return nil, fmt.Errorf("session_path is required")
}
if req.MessageIndex < 0 {
return nil, fmt.Errorf("message_index must be non-negative")
}
if !s.allowedPath(path) {
return nil, fmt.Errorf("session_path is outside the configured history roots")
}
if !s.visiblePath(path) {
return nil, fmt.Errorf("session_path is pending cleanup")
}
if err := ctx.Err(); err != nil {
return nil, err
}
msgs, err := loadMessages(path)
if err != nil {
return nil, err
}
if req.MessageIndex >= len(msgs) {
return nil, fmt.Errorf("message_index %d is outside session length %d", req.MessageIndex, len(msgs))
}
before := clamp(req.Before, defaultAround, maxAround)
after := clamp(req.After, defaultAround, maxAround)
start := req.MessageIndex - before
if start < 0 {
start = 0
}
remainingAfter := len(msgs) - req.MessageIndex - 1
end := len(msgs)
if after < remainingAfter {
end = len(msgs) - (remainingAfter - after)
}
out := make([]MessageContext, 0, end-start)
for i := start; i < end; i++ {
out = append(out, MessageContext{Index: i, Text: renderMessage(i, msgs[i])})
}
return out, nil
}
func normalizeScope(scope string) (string, error) {
switch strings.TrimSpace(scope) {
case "", scopeProject:
return scopeProject, nil
case scopeGlobal:
return scopeGlobal, nil
default:
return "", fmt.Errorf("scope must be %q or %q", scopeProject, scopeGlobal)
}
}
func normalizeKinds(kinds []Kind) (map[Kind]bool, error) {
if len(kinds) == 0 {
out := make(map[Kind]bool, len(defaultKinds))
for k, v := range defaultKinds {
out[k] = v
}
return out, nil
}
out := map[Kind]bool{}
for _, k := range kinds {
switch k {
case KindUserText, KindAssistantText, KindToolInput, KindToolError, KindToolOutput:
out[k] = true
default:
return nil, fmt.Errorf("unknown kind %q", k)
}
}
return out, nil
}
func (s *Searcher) sources(scope string) ([]sourceFile, error) {
var out []sourceFile
seen := map[string]bool{}
out = appendSessionSources(out, seen, s.sessionDir, scopeProject)
if scope == scopeGlobal {
out = appendSessionSources(out, seen, s.globalSessionDir, scopeGlobal)
out = appendFiles(out, seen, listJSONL(s.archiveDir, "archive", nil)...)
}
sort.Slice(out, func(i, j int) bool {
if out[i].mod == out[j].mod {
return out[i].path < out[j].path
}
return out[i].mod > out[j].mod
})
return out, nil
}
func appendSessionSources(out []sourceFile, seen map[string]bool, dir, source string) []sourceFile {
out = appendFiles(out, seen, listJSONL(dir, source, agent.IsVisibleSession)...)
if strings.TrimSpace(dir) != "" {
out = appendFiles(out, seen, listJSONL(subagentsDir(dir), source, func(path string) bool {
return visibleSubagentSession(dir, path)
})...)
}
return out
}
func appendFiles(out []sourceFile, seen map[string]bool, files ...sourceFile) []sourceFile {
for _, file := range files {
key := file.path
if abs, err := filepath.Abs(file.path); err == nil {
key = abs
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, file)
}
return out
}
func listJSONL(dir, source string, visible func(string) bool) []sourceFile {
if strings.TrimSpace(dir) == "" {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []sourceFile
for _, entry := range entries {
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
path := filepath.Join(dir, entry.Name())
if visible != nil && !visible(path) {
continue
}
// Recency must track the event log too: the .jsonl checkpoint's mtime
// only moves at checkpoints.
mod := info.ModTime()
if contentMod := agent.SessionContentModTime(path); !contentMod.IsZero() {
mod = contentMod
}
out = append(out, sourceFile{
path: path,
source: source,
mod: mod.UnixNano(),
})
}
return out
}
func loadMessages(path string) ([]provider.Message, error) {
sess, err := agent.LoadSession(path)
if err != nil {
return nil, err
}
return sess.Snapshot(), nil
}
func extractDocuments(src sourceFile, msgs []provider.Message, kinds map[Kind]bool, toolName string) []document {
var docs []document
for i, msg := range msgs {
switch msg.Role {
case provider.RoleUser:
if kinds[KindUserText] && strings.TrimSpace(msg.Content) != "" {
docs = appendDoc(docs, src, i, msg.Role, KindUserText, "", stripComposePrefixes(msg.Content))
}
case provider.RoleAssistant:
if kinds[KindAssistantText] && strings.TrimSpace(msg.Content) != "" {
docs = appendDoc(docs, src, i, msg.Role, KindAssistantText, "", msg.Content)
}
if kinds[KindToolInput] {
for _, call := range msg.ToolCalls {
if toolName != "" && call.Name != toolName {
continue
}
text := strings.TrimSpace(call.Name + " " + call.Arguments)
docs = appendDoc(docs, src, i, msg.Role, KindToolInput, call.Name, text)
}
}
case provider.RoleTool:
if toolName != "" && msg.Name != toolName {
continue
}
if kinds[KindToolError] && isToolError(msg.Content) {
docs = appendDoc(docs, src, i, msg.Role, KindToolError, msg.Name, msg.Name+" "+msg.Content)
}
if kinds[KindToolOutput] {
docs = appendDoc(docs, src, i, msg.Role, KindToolOutput, msg.Name, msg.Name+" "+msg.Content)
}
}
}
return docs
}
func appendDoc(docs []document, src sourceFile, idx int, role provider.Role, kind Kind, toolName, text string) []document {
text = strings.TrimSpace(text)
if text == "" {
return docs
}
terms := retrieval.Tokens(text)
if len(terms) == 0 {
return docs
}
counts := retrieval.Counts(terms)
return append(docs, document{
source: src,
messageIndex: idx,
role: role,
kind: kind,
toolName: toolName,
text: text,
counts: counts,
length: len(terms),
})
}
func isToolError(content string) bool {
s := strings.ToLower(strings.TrimSpace(content))
return strings.HasPrefix(s, "error:") ||
strings.HasPrefix(s, "blocked:") ||
strings.Contains(s, "permission denied")
}
func sessionID(path string) string {
base := filepath.Base(path)
return strings.TrimSuffix(base, filepath.Ext(base))
}
func renderMessage(idx int, msg provider.Message) string {
var b strings.Builder
switch msg.Role {
case provider.RoleUser:
fmt.Fprintf(&b, "[%d user]\n%s", idx, truncate(stripComposePrefixes(msg.Content), 2000))
case provider.RoleAssistant:
if strings.TrimSpace(msg.Content) != "" {
fmt.Fprintf(&b, "[%d assistant]\n%s", idx, truncate(msg.Content, 2000))
} else {
fmt.Fprintf(&b, "[%d assistant]", idx)
}
for _, call := range msg.ToolCalls {
fmt.Fprintf(&b, "\n[tool call: %s]\n%s", call.Name, truncate(call.Arguments, 1200))
}
case provider.RoleTool:
fmt.Fprintf(&b, "[%d tool %s result]\n%s", idx, msg.Name, truncate(msg.Content, 2000))
case provider.RoleSystem:
fmt.Fprintf(&b, "[%d system]\n%s", idx, truncate(msg.Content, 1200))
default:
fmt.Fprintf(&b, "[%d %s]\n%s", idx, msg.Role, truncate(msg.Content, 2000))
}
return strings.TrimSpace(b.String())
}
func truncate(s string, maxRunes int) string {
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + "..."
}
func clamp(n, def, max int) int {
if n <= 0 {
return def
}
if n > max {
return max
}
return n
}
func (s *Searcher) visiblePath(path string) bool {
switch {
case underRoot(path, subagentsDir(s.sessionDir)):
return visibleSubagentSession(s.sessionDir, path)
case underRoot(path, s.sessionDir):
return agent.IsVisibleSession(path)
case underRoot(path, subagentsDir(s.globalSessionDir)):
return visibleSubagentSession(s.globalSessionDir, path)
case underRoot(path, s.globalSessionDir):
return agent.IsVisibleSession(path)
case underRoot(path, s.archiveDir):
return true
default:
return false
}
}
func visibleSubagentSession(sessionDir, path string) bool {
if !agent.IsVisibleSession(path) {
return false
}
parentSession, ok := subagentParentSession(path)
if !ok || parentSession == "" {
return true
}
return !agent.IsCleanupPending(filepath.Join(sessionDir, parentSession+".jsonl"))
}
func subagentParentSession(path string) (string, bool) {
ref := strings.TrimSuffix(filepath.Base(path), ".jsonl")
if ref == "" || ref == filepath.Base(path) {
return "", false
}
b, err := fileencoding.ReadFileUTF8(filepath.Join(filepath.Dir(path), ref+".meta.json"))
if err != nil {
return "", false
}
var meta agent.SubagentMeta
if err := json.Unmarshal(b, &meta); err != nil {
return "", false
}
return strings.TrimSpace(meta.ParentSession), true
}
func subagentsDir(dir string) string {
if strings.TrimSpace(dir) == "" {
return ""
}
return filepath.Join(dir, "subagents")
}
func (s *Searcher) allowedPath(path string) bool {
roots := []string{s.sessionDir, s.globalSessionDir, s.archiveDir}
if s.sessionDir != "" {
roots = append(roots, subagentsDir(s.sessionDir))
}
if s.globalSessionDir != "" {
roots = append(roots, subagentsDir(s.globalSessionDir))
}
for _, root := range roots {
if underRoot(path, root) {
return true
}
}
return false
}
func underRoot(path, root string) bool {
if strings.TrimSpace(path) == "" || strings.TrimSpace(root) == "" {
return false
}
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// MarshalJSON keeps Hit stable if frontends choose to expose the same data later.
func (h Hit) MarshalJSON() ([]byte, error) {
type hit struct {
Score float64 `json:"score"`
SessionPath string `json:"session_path"`
SessionID string `json:"session_id"`
Source string `json:"source"`
MessageIndex int `json:"message_index"`
Role provider.Role `json:"role"`
Kind Kind `json:"kind"`
ToolName string `json:"tool_name,omitempty"`
Snippet string `json:"snippet"`
}
return json.Marshal(hit(h))
}