Class: Legion::CLI::Chat::Tools::SearchTraces
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
-
.call(query:, person: nil, domain: nil, trace_type: nil, limit: nil) ⇒ Object
rubocop:disable Metrics/ParameterLists.
-
.collect_traces(person:, domain:, trace_type:, limit:) ⇒ Object
-
.compute_score(text:, keywords:, trace:) ⇒ Object
-
.extract_searchable_text(trace) ⇒ Object
-
.flatten_to_text(obj) ⇒ Object
-
.format_age(created_at) ⇒ Object
-
.format_payload(payload) ⇒ Object
-
.format_results(traces) ⇒ Object
-
.format_structured(data) ⇒ Object
-
.fuzzy_person_search(person, limit: 60) ⇒ Object
-
.load_trace_gem ⇒ Object
-
.parse_payload(payload) ⇒ Object
-
.person_name_variants(name) ⇒ Object
-
.rank_by_query(traces:, query:) ⇒ Object
-
.recency_score(created_at) ⇒ Object
-
.store ⇒ Object
-
.trace_store_available? ⇒ Boolean
-
.truncate(text, max) ⇒ Object
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, **) 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
|
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.(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
|
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
|
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
|
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
|
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_gem ⇒ Object
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 = (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
|
.store ⇒ Object
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
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
|