esengine--deepseek-reasonix
597 行
15 KiB
Go
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))
|
|
}
|