Class: Legion::CLI::Chat::Tools::SearchTraces

Inherits:
Tools::Base
  • Object
show all
Defined in:
lib/legion/cli/chat/tools/search_traces.rb

Constant Summary collapse

STRUCTURED_FIELDS =
[
  ['Person', 'displayName', :displayName, 'peer', :peer],
  ['Summary', 'summary', :summary],
  ['Subject', 'subject', :subject],
  ['Team', 'team', :team],
  ['Job', 'jobTitle', :jobTitle]
].freeze

Class Method Summary collapse

Methods inherited from Tools::Base

deferred, deferred?, description, error_response, extension, handle_exception, input_schema, log, mcp_category, mcp_tier, runner, sticky, tags, text_response, tool_name, trigger_words

Class Method Details

.call(query:, person: nil, domain: nil, trace_type: nil, limit: nil) ⇒ Object

rubocop:disable Metrics/ParameterLists



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 40

def self.call(query:, person: nil, domain: nil, trace_type: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
  return 'Memory trace system not available (lex-agentic-memory not loaded).' unless trace_store_available?

  limit = (limit || 20).clamp(1, 50)
  traces = collect_traces(person: person, domain: domain, trace_type: trace_type, limit: limit * 3)
  return 'No memory traces found matching those filters.' if traces.empty?

  ranked = rank_by_query(traces: traces, query: query)
  results = ranked.first(limit)
  return 'No traces matched your query.' if results.empty?

  format_results(results)
rescue StandardError => e
  Legion::Logging.warn("SearchTraces#execute failed: #{e.message}") if defined?(Legion::Logging)
  "Error searching traces: #{e.message}"
end

.collect_traces(person:, domain:, trace_type:, limit:) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 73

def self.collect_traces(person:, domain:, trace_type:, limit:)
  if person
    candidates = []
    name_variants = person_name_variants(person)
    name_variants.each do |name|
      %W[peer:#{name} sender:#{name}].each do |tag|
        candidates += store.retrieve_by_domain(tag, min_strength: 0.01, limit: limit)
      end
    end

    candidates += fuzzy_person_search(person, limit: limit) if candidates.size < 5

    candidates += store.retrieve_by_domain('teams', min_strength: 0.01, limit: limit) if candidates.size < 5
    return candidates.uniq { |t| t[:trace_id] }
  end

  return store.retrieve_by_domain(domain, min_strength: 0.01, limit: limit) if domain

  if trace_type
    sym = trace_type.to_sym
    return store.retrieve_by_type(sym, min_strength: 0.01, limit: limit)
  end

  store.all_traces(min_strength: 0.01).sort_by { |t| -t[:strength] }.first(limit)
end

.compute_score(text:, keywords:, trace:) ⇒ Object



145
146
147
148
149
150
151
152
153
154
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 145

def self.compute_score(text:, keywords:, trace:)
  keyword_hits = keywords.count { |kw| text.include?(kw) }
  return 0.0 if keyword_hits.zero?

  keyword_ratio = keyword_hits.to_f / keywords.size
  strength_bonus = trace[:strength] || 0.0
  recency_bonus = recency_score(trace[:created_at])

  (keyword_ratio * 10.0) + (strength_bonus * 2.0) + (recency_bonus * 3.0)
end

.extract_searchable_text(trace) ⇒ Object



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 116

def self.extract_searchable_text(trace)
  payload = trace[:content_payload] || trace[:content]
  text = case payload
         when String
           begin
             parsed = ::JSON.parse(payload)
             flatten_to_text(parsed)
           rescue ::JSON::ParserError
             payload
           end
         when Hash
           flatten_to_text(payload)
         else
           payload.to_s
         end
  text.downcase
end

.flatten_to_text(obj) ⇒ Object



134
135
136
137
138
139
140
141
142
143
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 134

def self.flatten_to_text(obj)
  case obj
  when Hash
    obj.values.map { |v| flatten_to_text(v) }.join(' ')
  when Array
    obj.map { |v| flatten_to_text(v) }.join(' ')
  else
    obj.to_s
  end
end

.format_age(created_at) ⇒ Object



211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 211

def self.format_age(created_at)
  return 'age unknown' unless created_at.is_a?(Time)

  seconds = Time.now.utc - created_at
  if seconds < 3600
    "#{(seconds / 60).to_i}m ago"
  elsif seconds < 86_400
    "#{(seconds / 3600).to_i}h ago"
  else
    "#{(seconds / 86_400).to_i}d ago"
  end
end

.format_payload(payload) ⇒ Object



176
177
178
179
180
181
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 176

def self.format_payload(payload)
  data = parse_payload(payload)
  return truncate(data, 300) if data.is_a?(String)

  format_structured(data)
end

.format_results(traces) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 163

def self.format_results(traces)
  parts = traces.map.with_index(1) do |trace, idx|
    payload = trace[:content_payload] || trace[:content]
    content = format_payload(payload)
    tags = (trace[:domain_tags] || []).join(', ')
    age = format_age(trace[:created_at])

    "#{idx}. [#{trace[:trace_type]}] #{content}\n   tags: #{tags} | strength: #{(trace[:strength] || 0).round(2)} | #{age}"
  end

  "Found #{traces.size} matching traces:\n\n#{parts.join("\n\n")}"
end

.format_structured(data) ⇒ Object



196
197
198
199
200
201
202
203
204
205
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 196

def self.format_structured(data)
  parts = STRUCTURED_FIELDS.filter_map do |label, *keys|
    val = keys.lazy.filter_map { |k| data[k] }.first
    "#{label}: #{val}" if val
  end

  return parts.join(' | ') unless parts.empty?

  truncate(flatten_to_text(data), 300)
end

.fuzzy_person_search(person, limit: 60) ⇒ Object



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 242

def self.fuzzy_person_search(person, limit: 60)
  needle = person.downcase
  parts = needle.split(/[\s,]+/).reject(&:empty?)

  matches = store.all_traces(min_strength: 0.01).select do |trace|
    tags = trace[:domain_tags] || []
    tags.any? do |tag|
      next false unless tag.start_with?('peer:', 'sender:')

      tag_name = tag.sub(/\A(peer|sender):/, '').downcase
      parts.all? { |p| tag_name.include?(p) }
    end
  end
  matches.sort_by { |t| -t[:strength] }.first(limit)
end

.load_trace_gemObject



63
64
65
66
67
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 63

def self.load_trace_gem
  require 'legion/extensions/agentic/memory/trace'
rescue LoadError
  nil
end

.parse_payload(payload) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 183

def self.parse_payload(payload)
  case payload
  when String
    ::JSON.parse(payload)
  when Hash
    payload
  else
    payload.to_s
  end
rescue ::JSON::ParserError
  payload
end

.person_name_variants(name) ⇒ Object



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 224

def self.person_name_variants(name)
  parts = name.strip.split(/[\s,]+/).reject(&:empty?)
  variants = [name]

  if parts.length == 2
    variants << "#{parts[1]}, #{parts[0]}"
    variants << "#{parts[0]} #{parts[1]}"
    variants << "#{parts[1]} #{parts[0]}"
  elsif parts.length >= 3
    variants << "#{parts.last}, #{parts[0...-1].join(' ')}"
    variants << "#{parts[0...-1].join(' ')} #{parts.last}"
  end

  variants << parts.first if parts.first && parts.first.length > 2

  variants.uniq
end

.rank_by_query(traces:, query:) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 99

def self.rank_by_query(traces:, query:)
  keywords = query.downcase.split(/\s+/).reject { |w| w.length < 3 }
  return traces if keywords.empty?

  scored = traces.filter_map do |trace|
    text = extract_searchable_text(trace)
    next nil if text.empty?

    score = compute_score(text: text, keywords: keywords, trace: trace)
    next nil if score.zero?

    { trace: trace, score: score }
  end

  scored.sort_by { |s| -s[:score] }.map { |s| s[:trace] }
end

.recency_score(created_at) ⇒ Object



156
157
158
159
160
161
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 156

def self.recency_score(created_at)
  return 0.0 unless created_at.is_a?(Time)

  age_hours = (Time.now.utc - created_at) / 3600.0
  1.0 / (1.0 + (age_hours / 24.0))
end

.storeObject



69
70
71
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 69

def self.store
  Legion::Extensions::Agentic::Memory::Trace.shared_store
end

.trace_store_available?Boolean

Returns:

  • (Boolean)


57
58
59
60
61
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 57

def self.trace_store_available?
  load_trace_gem unless defined?(Legion::Extensions::Agentic::Memory::Trace)
  defined?(Legion::Extensions::Agentic::Memory::Trace) &&
    Legion::Extensions::Agentic::Memory::Trace.respond_to?(:shared_store)
end

.truncate(text, max) ⇒ Object



207
208
209
# File 'lib/legion/cli/chat/tools/search_traces.rb', line 207

def self.truncate(text, max)
  text.length > max ? "#{text[0..(max - 3)]}..." : text
end