legionio 1.6.47 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +24 -0
- data/Gemfile +1 -0
- data/lib/legion/api/inbound_webhooks.rb +47 -0
- data/lib/legion/api.rb +2 -0
- data/lib/legion/chat/skills.rb +77 -1
- data/lib/legion/cli/chat/memory_store.rb +10 -0
- data/lib/legion/cli/chat/output_styles.rb +73 -0
- data/lib/legion/cli/chat/session.rb +48 -6
- data/lib/legion/cli/chat/session_recovery.rb +129 -0
- data/lib/legion/cli/chat/session_store.rb +22 -11
- data/lib/legion/cli/chat/team_memory.rb +95 -0
- data/lib/legion/cli/chat/tools/run_command.rb +49 -6
- data/lib/legion/cli/chat_command.rb +221 -50
- data/lib/legion/cli/doctor/result.rb +11 -2
- data/lib/legion/cli/doctor_command.rb +74 -6
- data/lib/legion/cli/marketplace_command.rb +17 -1
- data/lib/legion/cli/memory_command.rb +59 -0
- data/lib/legion/cli/mode_command.rb +236 -0
- data/lib/legion/cli/skill_command.rb +10 -5
- data/lib/legion/cli/update_command.rb +6 -1
- data/lib/legion/cli.rb +4 -0
- data/lib/legion/extensions/gem_source.rb +88 -0
- data/lib/legion/extensions.rb +41 -1
- data/lib/legion/memory/consolidator.rb +241 -0
- data/lib/legion/process.rb +22 -9
- data/lib/legion/provider.rb +146 -0
- data/lib/legion/service.rb +12 -0
- data/lib/legion/task_outcome_observer.rb +101 -0
- data/lib/legion/telemetry/open_inference.rb +15 -0
- data/lib/legion/trigger/envelope.rb +47 -0
- data/lib/legion/trigger/sources/base.rb +59 -0
- data/lib/legion/trigger/sources/github.rb +26 -0
- data/lib/legion/trigger/sources/linear.rb +26 -0
- data/lib/legion/trigger/sources/slack.rb +41 -0
- data/lib/legion/trigger.rb +121 -0
- data/lib/legion/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba1e9bda23249eed2b5999c61cfe654516482285e9a7c485b34535107e591a52
|
|
4
|
+
data.tar.gz: 041a860cec2ee554696c8006006afa5b23ce69e789c7b76e97acc47023577ecd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c3a39c3aca59a10712c212d36dfced8d432376622c30469219afe69c95c73c86875388e8c178ddfd74d697f78f3d6abf3056d6763c4509686d486310f463edc
|
|
7
|
+
data.tar.gz: c51e8d1e950a2010a65839db4c823ea1be94d9156efbe2cb01ffceedcea989dea33482b8d9a3dbb09c1f2605467f9c73e715d1e032653d94a87b5f6cdcb7b6d2
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.7.0] - 2026-03-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Legion::Provider` base class with DAG-ordered registry for boot lifecycle (#71)
|
|
9
|
+
- `TaskOutcomeObserver` wires task completion to reflection and learning persistence (#70)
|
|
10
|
+
- GenAI semantic convention attributes (`gen_ai.*`) on OpenInference spans (#69)
|
|
11
|
+
- `legionio doctor` scored audit report with weighted health score and letter grades (#77)
|
|
12
|
+
- Local skill drop-in directory with `.rb` and `.md` support and execution (#76)
|
|
13
|
+
- Dynamic gem sources for extension installs via `extensions.sources` setting (#52)
|
|
14
|
+
- `legionio mode` CLI command for profile and process role switching (#72)
|
|
15
|
+
- Cross-project session resume with CWD context and `--resume-latest` flag (#105)
|
|
16
|
+
- Away summary recap via LLM when user returns after idle period (#100)
|
|
17
|
+
- Wire `LexCliManifest.write_manifest` into extension autobuild pipeline (#97)
|
|
18
|
+
- Inbound webhook normalizer and HTTP-to-AMQP event bridge (`Legion::Trigger`) (#74)
|
|
19
|
+
- Interrupt detection and session recovery for chat resume (#98)
|
|
20
|
+
- Configurable output styles for LLM responses via `.legionio/output-styles/` (#103)
|
|
21
|
+
- Route RunCommand through lex-exec sandbox when `chat.sandboxed_commands.enabled` is true (#96)
|
|
22
|
+
- Cross-session memory consolidation with 3-gate trigger system (#99)
|
|
23
|
+
- Per-model `/cost` breakdown with token counts, cache hits, and `CostEstimator` pricing (#102)
|
|
24
|
+
- Team memory sync via Apollo knowledge store with repo-scoped tags (#104)
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Puma no longer steals SIGINT/SIGTERM traps, preventing graceful shutdown (#91)
|
|
28
|
+
|
|
5
29
|
## [1.6.47] - 2026-03-31
|
|
6
30
|
|
|
7
31
|
### Added
|
data/Gemfile
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module InboundWebhooks
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.post '/api/webhooks/:source' do
|
|
9
|
+
require 'legion/trigger'
|
|
10
|
+
|
|
11
|
+
source_name = params[:source]
|
|
12
|
+
body_raw = request.body.read
|
|
13
|
+
body = begin
|
|
14
|
+
Legion::JSON.load(body_raw)
|
|
15
|
+
rescue StandardError
|
|
16
|
+
halt 400, json_error('invalid_body', 'request body must be valid JSON', status_code: 400)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
headers = request.env.select { |k, _| k.start_with?('HTTP_') }
|
|
20
|
+
|
|
21
|
+
result = Legion::Trigger.process(
|
|
22
|
+
source_name: source_name,
|
|
23
|
+
headers: headers,
|
|
24
|
+
body_raw: body_raw,
|
|
25
|
+
body: body
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if result[:success]
|
|
29
|
+
json_response(result, status_code: 202)
|
|
30
|
+
elsif result[:reason] == :duplicate
|
|
31
|
+
json_response(result, status_code: 200)
|
|
32
|
+
elsif result[:reason] == :unknown_source
|
|
33
|
+
halt 404, json_error('unknown_source', result[:error], status_code: 404)
|
|
34
|
+
else
|
|
35
|
+
halt 500, json_error('trigger_error', result[:error] || 'processing failed', status_code: 500)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
app.get '/api/webhooks/sources' do
|
|
40
|
+
require 'legion/trigger'
|
|
41
|
+
json_response({ sources: Legion::Trigger.registered_sources })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -54,6 +54,7 @@ require_relative 'api/library_routes'
|
|
|
54
54
|
require_relative 'api/sync_dispatch'
|
|
55
55
|
require_relative 'api/lex_dispatch'
|
|
56
56
|
require_relative 'api/tbi_patterns'
|
|
57
|
+
require_relative 'api/inbound_webhooks'
|
|
57
58
|
require_relative 'api/graphql' if defined?(GraphQL)
|
|
58
59
|
|
|
59
60
|
module Legion
|
|
@@ -180,6 +181,7 @@ module Legion
|
|
|
180
181
|
register Routes::Knowledge
|
|
181
182
|
register Routes::Logs
|
|
182
183
|
register Routes::TbiPatterns
|
|
184
|
+
register Routes::InboundWebhooks
|
|
183
185
|
register Routes::GraphQL if defined?(Routes::GraphQL)
|
|
184
186
|
|
|
185
187
|
use Legion::API::Middleware::RequestLogger
|
data/lib/legion/chat/skills.rb
CHANGED
|
@@ -13,7 +13,9 @@ module Legion
|
|
|
13
13
|
expanded = File.expand_path(dir)
|
|
14
14
|
next [] unless Dir.exist?(expanded)
|
|
15
15
|
|
|
16
|
-
Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
|
|
16
|
+
md_skills = Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
|
|
17
|
+
rb_skills = Dir.glob(File.join(expanded, '*.rb')).filter_map { |f| parse_rb(f) }
|
|
18
|
+
md_skills + rb_skills
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
|
|
@@ -34,6 +36,7 @@ module Legion
|
|
|
34
36
|
{
|
|
35
37
|
name: frontmatter['name'] || File.basename(path, '.md'),
|
|
36
38
|
description: frontmatter['description'] || '',
|
|
39
|
+
type: :prompt,
|
|
37
40
|
model: frontmatter['model'],
|
|
38
41
|
tools: Array(frontmatter['tools']),
|
|
39
42
|
prompt: body,
|
|
@@ -43,6 +46,79 @@ module Legion
|
|
|
43
46
|
Legion::Logging.warn "Skill parse error #{path}: #{e.message}" if defined?(Legion::Logging)
|
|
44
47
|
nil
|
|
45
48
|
end
|
|
49
|
+
|
|
50
|
+
def parse_rb(path)
|
|
51
|
+
content = File.read(path)
|
|
52
|
+
|
|
53
|
+
name = File.basename(path, '.rb')
|
|
54
|
+
description = content.match(/^\s*#\s*description:\s*(.+)$/i)&.captures&.first || ''
|
|
55
|
+
model = content.match(/^\s*#\s*model:\s*(.+)$/i)&.captures&.first
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
name: name,
|
|
59
|
+
description: description.strip,
|
|
60
|
+
type: :ruby,
|
|
61
|
+
model: model&.strip,
|
|
62
|
+
tools: [],
|
|
63
|
+
prompt: nil,
|
|
64
|
+
path: path
|
|
65
|
+
}
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Legion::Logging.warn "Skill parse_rb error #{path}: #{e.message}" if defined?(Legion::Logging)
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execute(skill, input: nil)
|
|
72
|
+
case skill[:type]
|
|
73
|
+
when :ruby
|
|
74
|
+
execute_rb(skill, input: input)
|
|
75
|
+
when :prompt
|
|
76
|
+
execute_prompt(skill, input: input)
|
|
77
|
+
else
|
|
78
|
+
{ success: false, error: "unknown skill type: #{skill[:type]}" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def execute_prompt(skill, input: nil)
|
|
85
|
+
return { success: false, error: 'Legion::LLM not available' } unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat_direct)
|
|
86
|
+
|
|
87
|
+
prompt = skill[:prompt]
|
|
88
|
+
prompt = "#{prompt}\n\nUser input: #{input}" if input
|
|
89
|
+
|
|
90
|
+
session = Legion::LLM.chat_direct(model: skill[:model], provider: nil)
|
|
91
|
+
response = session.ask(prompt)
|
|
92
|
+
content = response.respond_to?(:content) ? response.content : response.to_s
|
|
93
|
+
|
|
94
|
+
{ success: true, output: content }
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
{ success: false, error: e.message }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def execute_rb(skill, input: nil)
|
|
100
|
+
begin
|
|
101
|
+
real_path = File.realpath(skill[:path])
|
|
102
|
+
rescue Errno::ENOENT
|
|
103
|
+
return { success: false, error: "skill file not found: #{skill[:path]}" }
|
|
104
|
+
end
|
|
105
|
+
allowed = SKILL_DIRS.filter_map do |dir|
|
|
106
|
+
expanded = File.expand_path(dir)
|
|
107
|
+
File.realpath(expanded) if Dir.exist?(expanded)
|
|
108
|
+
end
|
|
109
|
+
unless allowed.any? { |dir| real_path.start_with?("#{dir}/") }
|
|
110
|
+
return { success: false, error: "skill path outside allowed directories: #{real_path}" }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
mod = Module.new
|
|
114
|
+
mod.module_eval(File.read(real_path), real_path)
|
|
115
|
+
return { success: false, error: "#{skill[:name]}.rb must define a module-level `self.call` method" } unless mod.respond_to?(:call)
|
|
116
|
+
|
|
117
|
+
result = mod.call(input: input)
|
|
118
|
+
{ success: true, output: result }
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
{ success: false, error: e.message }
|
|
121
|
+
end
|
|
46
122
|
end
|
|
47
123
|
end
|
|
48
124
|
end
|
|
@@ -54,6 +54,8 @@ module Legion
|
|
|
54
54
|
header = scope == :global ? "# Global Memory\n" : "# Project Memory\n"
|
|
55
55
|
File.write(path, "#{header}#{entry}", encoding: 'utf-8')
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
sync_to_team(text)
|
|
57
59
|
path
|
|
58
60
|
end
|
|
59
61
|
|
|
@@ -102,6 +104,14 @@ module Legion
|
|
|
102
104
|
FileUtils.mkdir_p(File.dirname(path))
|
|
103
105
|
end
|
|
104
106
|
private_class_method :ensure_dir
|
|
107
|
+
|
|
108
|
+
def sync_to_team(text)
|
|
109
|
+
require 'legion/cli/chat/team_memory'
|
|
110
|
+
Chat::TeamMemory.sync_add(text)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
Legion::Logging.debug("MemoryStore#sync_to_team failed: #{e.message}") if defined?(Legion::Logging)
|
|
113
|
+
end
|
|
114
|
+
private_class_method :sync_to_team
|
|
105
115
|
end
|
|
106
116
|
end
|
|
107
117
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module CLI
|
|
7
|
+
class Chat
|
|
8
|
+
module OutputStyles
|
|
9
|
+
STYLE_DIRS = ['.legionio/output-styles', '~/.legionio/output-styles'].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def discover
|
|
13
|
+
STYLE_DIRS.flat_map do |dir|
|
|
14
|
+
expanded = File.expand_path(dir)
|
|
15
|
+
next [] unless Dir.exist?(expanded)
|
|
16
|
+
|
|
17
|
+
Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def active_styles
|
|
22
|
+
discover.select { |s| s[:active] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def find(name)
|
|
26
|
+
discover.find { |s| s[:name] == name.to_s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def activate(name)
|
|
30
|
+
style = find(name)
|
|
31
|
+
return nil unless style
|
|
32
|
+
|
|
33
|
+
path = style[:path]
|
|
34
|
+
content = File.read(path)
|
|
35
|
+
content.sub!(/^---\s*$/, "---\nactive: true") unless content.match?(/^active:\s/)
|
|
36
|
+
content.gsub!(/^active:\s+\w+/, 'active: true')
|
|
37
|
+
File.write(path, content)
|
|
38
|
+
style[:name]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def system_prompt_injection
|
|
42
|
+
active = active_styles
|
|
43
|
+
return nil if active.empty?
|
|
44
|
+
|
|
45
|
+
active.map { |s| s[:content] }.join("\n\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse(path)
|
|
49
|
+
raw = File.read(path)
|
|
50
|
+
return nil unless raw.start_with?('---')
|
|
51
|
+
|
|
52
|
+
parts = raw.split(/^---\s*$/, 3)
|
|
53
|
+
return nil if parts.size < 3
|
|
54
|
+
|
|
55
|
+
frontmatter = YAML.safe_load(parts[1], permitted_classes: [Symbol])
|
|
56
|
+
body = parts[2]&.strip
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
name: frontmatter['name'] || File.basename(path, '.md'),
|
|
60
|
+
description: frontmatter['description'] || '',
|
|
61
|
+
active: frontmatter['active'] == true,
|
|
62
|
+
content: body,
|
|
63
|
+
path: path
|
|
64
|
+
}
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
Legion::Logging.warn "OutputStyles parse error #{path}: #{e.message}" if defined?(Legion::Logging)
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -13,7 +13,7 @@ module Legion
|
|
|
13
13
|
INPUT_RATE = 0.003 / 1000.0 # $3 per million input tokens
|
|
14
14
|
OUTPUT_RATE = 0.015 / 1000.0 # $15 per million output tokens
|
|
15
15
|
|
|
16
|
-
attr_reader :chat, :stats
|
|
16
|
+
attr_reader :chat, :stats, :cache_hits_tokens
|
|
17
17
|
attr_accessor :budget_usd
|
|
18
18
|
|
|
19
19
|
def initialize(chat:, system_prompt: nil, budget_usd: nil)
|
|
@@ -25,6 +25,8 @@ module Legion
|
|
|
25
25
|
messages_received: 0,
|
|
26
26
|
started_at: Time.now
|
|
27
27
|
}
|
|
28
|
+
@model_usage = Hash.new { |h, k| h[k] = { input_tokens: 0, output_tokens: 0, requests: 0 } }
|
|
29
|
+
@cache_hits_tokens = 0
|
|
28
30
|
@callbacks = Hash.new { |h, k| h[k] = [] }
|
|
29
31
|
@turn = 0
|
|
30
32
|
end
|
|
@@ -65,8 +67,18 @@ module Legion
|
|
|
65
67
|
@stats[:messages_received] += 1
|
|
66
68
|
|
|
67
69
|
if response.respond_to?(:input_tokens)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
in_tok = response.input_tokens || 0
|
|
71
|
+
out_tok = response.output_tokens || 0
|
|
72
|
+
@stats[:input_tokens] = (@stats[:input_tokens] || 0) + in_tok
|
|
73
|
+
@stats[:output_tokens] = (@stats[:output_tokens] || 0) + out_tok
|
|
74
|
+
|
|
75
|
+
resp_model = response.respond_to?(:model_id) ? response.model_id : model_id
|
|
76
|
+
entry = @model_usage[resp_model.to_s]
|
|
77
|
+
entry[:input_tokens] += in_tok
|
|
78
|
+
entry[:output_tokens] += out_tok
|
|
79
|
+
entry[:requests] += 1
|
|
80
|
+
|
|
81
|
+
@cache_hits_tokens += response.cache_read_input_tokens.to_i if response.respond_to?(:cache_read_input_tokens) && response.cache_read_input_tokens
|
|
70
82
|
end
|
|
71
83
|
|
|
72
84
|
emit(:llm_complete, { turn: current_turn, user_message: message })
|
|
@@ -75,9 +87,35 @@ module Legion
|
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
def estimated_cost
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
if cost_estimator_available? && @model_usage.any?
|
|
91
|
+
@model_usage.sum do |model, usage|
|
|
92
|
+
Legion::LLM::CostEstimator.estimate(
|
|
93
|
+
model_id: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
input = (@stats[:input_tokens] || 0) * INPUT_RATE
|
|
98
|
+
output = (@stats[:output_tokens] || 0) * OUTPUT_RATE
|
|
99
|
+
input + output
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def model_usage
|
|
104
|
+
@model_usage.transform_values(&:dup)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def cost_breakdown
|
|
108
|
+
@model_usage.map do |model, usage|
|
|
109
|
+
cost = if cost_estimator_available?
|
|
110
|
+
Legion::LLM::CostEstimator.estimate(
|
|
111
|
+
model_id: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]
|
|
112
|
+
)
|
|
113
|
+
else
|
|
114
|
+
(usage[:input_tokens] * INPUT_RATE) + (usage[:output_tokens] * OUTPUT_RATE)
|
|
115
|
+
end
|
|
116
|
+
{ model: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens],
|
|
117
|
+
requests: usage[:requests], cost: cost }
|
|
118
|
+
end
|
|
81
119
|
end
|
|
82
120
|
|
|
83
121
|
def model_id
|
|
@@ -93,6 +131,10 @@ module Legion
|
|
|
93
131
|
|
|
94
132
|
private
|
|
95
133
|
|
|
134
|
+
def cost_estimator_available?
|
|
135
|
+
defined?(Legion::LLM::CostEstimator)
|
|
136
|
+
end
|
|
137
|
+
|
|
96
138
|
def check_budget!
|
|
97
139
|
return unless @budget_usd
|
|
98
140
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/cli/chat_command'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module CLI
|
|
7
|
+
class Chat
|
|
8
|
+
module SessionRecovery
|
|
9
|
+
STATES = %i[none interrupted_prompt interrupted_turn].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def classify(messages)
|
|
13
|
+
cleaned = filter_artifacts(messages)
|
|
14
|
+
return :none if cleaned.empty?
|
|
15
|
+
|
|
16
|
+
last = cleaned.last
|
|
17
|
+
role = msg_role(last)
|
|
18
|
+
|
|
19
|
+
case role
|
|
20
|
+
when 'user' then :interrupted_prompt
|
|
21
|
+
when 'tool_result', 'tool' then :interrupted_turn
|
|
22
|
+
when 'assistant'
|
|
23
|
+
tool_calls = last.is_a?(Hash) ? (last[:tool_calls] || last['tool_calls']) : nil
|
|
24
|
+
tool_calls.is_a?(Array) && tool_calls.any? ? :interrupted_turn : :none
|
|
25
|
+
else :none
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def recover(messages)
|
|
30
|
+
cleaned = filter_artifacts(messages)
|
|
31
|
+
state = classify(cleaned)
|
|
32
|
+
|
|
33
|
+
case state
|
|
34
|
+
when :none
|
|
35
|
+
{ state: :none, messages: cleaned, recovery_message: nil }
|
|
36
|
+
when :interrupted_prompt
|
|
37
|
+
msg = 'Continue from where you left off. The previous session was interrupted.'
|
|
38
|
+
{ state: :interrupted_prompt, messages: cleaned, recovery_message: msg }
|
|
39
|
+
when :interrupted_turn
|
|
40
|
+
tool_name = detect_interrupted_tool(cleaned)
|
|
41
|
+
msg = 'Continue from where you left off. The previous session was interrupted'
|
|
42
|
+
msg += " during tool execution (#{tool_name})" if tool_name
|
|
43
|
+
msg += '.'
|
|
44
|
+
repaired = repair_orphaned_tool_use(cleaned)
|
|
45
|
+
{ state: :interrupted_turn, messages: repaired, recovery_message: msg }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def filter_artifacts(messages)
|
|
52
|
+
messages.reject do |msg|
|
|
53
|
+
role = msg_role(msg)
|
|
54
|
+
content = msg_content(msg)
|
|
55
|
+
|
|
56
|
+
next true if role == 'assistant' && thinking_only?(msg)
|
|
57
|
+
next true if role == 'assistant' && whitespace_only?(content)
|
|
58
|
+
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def thinking_only?(msg)
|
|
64
|
+
content = msg_content(msg)
|
|
65
|
+
return false unless content.nil? || content.to_s.strip.empty?
|
|
66
|
+
|
|
67
|
+
tool_calls = msg.is_a?(Hash) ? (msg[:tool_calls] || msg['tool_calls']) : nil
|
|
68
|
+
tool_calls.nil? || (tool_calls.is_a?(Array) && tool_calls.empty?)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def whitespace_only?(content)
|
|
72
|
+
return true if content.nil?
|
|
73
|
+
|
|
74
|
+
content.to_s.strip.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def msg_role(msg)
|
|
78
|
+
if msg.is_a?(Hash)
|
|
79
|
+
(msg[:role] || msg['role']).to_s
|
|
80
|
+
elsif msg.respond_to?(:role)
|
|
81
|
+
msg.role.to_s
|
|
82
|
+
else
|
|
83
|
+
''
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def msg_content(msg)
|
|
88
|
+
if msg.is_a?(Hash)
|
|
89
|
+
msg[:content] || msg['content']
|
|
90
|
+
elsif msg.respond_to?(:content)
|
|
91
|
+
msg.content
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_interrupted_tool(messages)
|
|
96
|
+
reversed = messages.reverse
|
|
97
|
+
reversed.each do |msg|
|
|
98
|
+
role = msg_role(msg)
|
|
99
|
+
next unless role == 'assistant'
|
|
100
|
+
|
|
101
|
+
tool_calls = msg.is_a?(Hash) ? (msg[:tool_calls] || msg['tool_calls']) : nil
|
|
102
|
+
next unless tool_calls.is_a?(Array) && tool_calls.any?
|
|
103
|
+
|
|
104
|
+
first_tool = tool_calls.first
|
|
105
|
+
return first_tool[:name] || first_tool['name'] if first_tool.is_a?(Hash)
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def repair_orphaned_tool_use(messages)
|
|
111
|
+
return messages if messages.empty?
|
|
112
|
+
|
|
113
|
+
last = messages.last
|
|
114
|
+
role = msg_role(last)
|
|
115
|
+
|
|
116
|
+
return messages[0...-1] if %w[tool_result tool].include?(role)
|
|
117
|
+
|
|
118
|
+
if role == 'assistant'
|
|
119
|
+
tool_calls = last.is_a?(Hash) ? (last[:tool_calls] || last['tool_calls']) : nil
|
|
120
|
+
return messages[0...-1] if tool_calls.is_a?(Array) && tool_calls.any?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
messages
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -15,13 +15,16 @@ module Legion
|
|
|
15
15
|
|
|
16
16
|
messages = session.chat.messages.map(&:to_h)
|
|
17
17
|
data = {
|
|
18
|
-
name:
|
|
19
|
-
model:
|
|
20
|
-
stats:
|
|
21
|
-
saved_at:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
name: name,
|
|
19
|
+
model: session.model_id,
|
|
20
|
+
stats: session.stats,
|
|
21
|
+
saved_at: Time.now.iso8601,
|
|
22
|
+
cwd: Dir.pwd,
|
|
23
|
+
message_count: messages.size,
|
|
24
|
+
summary: generate_summary(messages),
|
|
25
|
+
model_usage: session.respond_to?(:model_usage) ? session.model_usage : {},
|
|
26
|
+
cache_hits_tokens: session.respond_to?(:cache_hits_tokens) ? session.cache_hits_tokens : 0,
|
|
27
|
+
messages: messages
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
path = session_path(name)
|
|
@@ -37,10 +40,16 @@ module Legion
|
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def restore(session, data)
|
|
43
|
+
require 'legion/cli/chat/session_recovery'
|
|
44
|
+
|
|
45
|
+
recovery = Chat::SessionRecovery.recover(data[:messages] || [])
|
|
40
46
|
session.chat.reset_messages!
|
|
41
|
-
|
|
47
|
+
recovery[:messages].each do |msg|
|
|
42
48
|
session.chat.add_message(msg)
|
|
43
49
|
end
|
|
50
|
+
|
|
51
|
+
data[:recovery_state] = recovery[:state]
|
|
52
|
+
data[:recovery_message] = recovery[:recovery_message]
|
|
44
53
|
data
|
|
45
54
|
end
|
|
46
55
|
|
|
@@ -57,7 +66,8 @@ module Legion
|
|
|
57
66
|
modified: stat.mtime,
|
|
58
67
|
message_count: meta[:message_count],
|
|
59
68
|
summary: meta[:summary],
|
|
60
|
-
model: meta[:model]
|
|
69
|
+
model: meta[:model],
|
|
70
|
+
cwd: meta[:cwd]
|
|
61
71
|
}
|
|
62
72
|
end
|
|
63
73
|
sessions.sort_by { |s| s[:modified] }.reverse
|
|
@@ -101,11 +111,12 @@ module Legion
|
|
|
101
111
|
{
|
|
102
112
|
message_count: data[:message_count] || data[:messages]&.size,
|
|
103
113
|
summary: data[:summary],
|
|
104
|
-
model: data[:model]
|
|
114
|
+
model: data[:model],
|
|
115
|
+
cwd: data[:cwd]
|
|
105
116
|
}
|
|
106
117
|
rescue StandardError => e
|
|
107
118
|
Legion::Logging.debug("SessionStore#read_session_meta failed: #{e.message}") if defined?(Legion::Logging)
|
|
108
|
-
{ message_count: nil, summary: nil, model: nil }
|
|
119
|
+
{ message_count: nil, summary: nil, model: nil, cwd: nil }
|
|
109
120
|
end
|
|
110
121
|
end
|
|
111
122
|
end
|