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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/CHANGELOG.md +24 -0
  4. data/Gemfile +1 -0
  5. data/lib/legion/api/inbound_webhooks.rb +47 -0
  6. data/lib/legion/api.rb +2 -0
  7. data/lib/legion/chat/skills.rb +77 -1
  8. data/lib/legion/cli/chat/memory_store.rb +10 -0
  9. data/lib/legion/cli/chat/output_styles.rb +73 -0
  10. data/lib/legion/cli/chat/session.rb +48 -6
  11. data/lib/legion/cli/chat/session_recovery.rb +129 -0
  12. data/lib/legion/cli/chat/session_store.rb +22 -11
  13. data/lib/legion/cli/chat/team_memory.rb +95 -0
  14. data/lib/legion/cli/chat/tools/run_command.rb +49 -6
  15. data/lib/legion/cli/chat_command.rb +221 -50
  16. data/lib/legion/cli/doctor/result.rb +11 -2
  17. data/lib/legion/cli/doctor_command.rb +74 -6
  18. data/lib/legion/cli/marketplace_command.rb +17 -1
  19. data/lib/legion/cli/memory_command.rb +59 -0
  20. data/lib/legion/cli/mode_command.rb +236 -0
  21. data/lib/legion/cli/skill_command.rb +10 -5
  22. data/lib/legion/cli/update_command.rb +6 -1
  23. data/lib/legion/cli.rb +4 -0
  24. data/lib/legion/extensions/gem_source.rb +88 -0
  25. data/lib/legion/extensions.rb +41 -1
  26. data/lib/legion/memory/consolidator.rb +241 -0
  27. data/lib/legion/process.rb +22 -9
  28. data/lib/legion/provider.rb +146 -0
  29. data/lib/legion/service.rb +12 -0
  30. data/lib/legion/task_outcome_observer.rb +101 -0
  31. data/lib/legion/telemetry/open_inference.rb +15 -0
  32. data/lib/legion/trigger/envelope.rb +47 -0
  33. data/lib/legion/trigger/sources/base.rb +59 -0
  34. data/lib/legion/trigger/sources/github.rb +26 -0
  35. data/lib/legion/trigger/sources/linear.rb +26 -0
  36. data/lib/legion/trigger/sources/slack.rb +41 -0
  37. data/lib/legion/trigger.rb +121 -0
  38. data/lib/legion/version.rb +1 -1
  39. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fdb142083ba9c7189a2c1d067d010b1ab0f39f22caa6c138d14fc1ebacd1589
4
- data.tar.gz: 3f462e8d42971068e296464fd1911cd326fcce814cf37c41b4f0cd21c278247e
3
+ metadata.gz: ba1e9bda23249eed2b5999c61cfe654516482285e9a7c485b34535107e591a52
4
+ data.tar.gz: 041a860cec2ee554696c8006006afa5b23ce69e789c7b76e97acc47023577ecd
5
5
  SHA512:
6
- metadata.gz: 5a0d8a882c72675d9b6b4aa9d31f5e7f51c2e7feea21f8bc71080b4f4ebabe95c20e3f51ef1c6bf77c9e87df41ba6dc46809d2081d1b70a7a468728aefa71943
7
- data.tar.gz: be6c28ddffac2b02d22dd98b6133481bd724b26586853d58b8df0035cf1716e27dfedcdbfb53ffd1a9016fe7deff3bceb3755ac013b332f93552a606ca210d2c
6
+ metadata.gz: 2c3a39c3aca59a10712c212d36dfced8d432376622c30469219afe69c95c73c86875388e8c178ddfd74d697f78f3d6abf3056d6763c4509686d486310f463edc
7
+ data.tar.gz: c51e8d1e950a2010a65839db4c823ea1be94d9156efbe2cb01ffceedcea989dea33482b8d9a3dbb09c1f2605467f9c73e715d1e032653d94a87b5f6cdcb7b6d2
data/.rubocop.yml CHANGED
@@ -60,6 +60,7 @@ Metrics/BlockLength:
60
60
  - 'lib/legion/cli/trace_command.rb'
61
61
  - 'lib/legion/cli/features_command.rb'
62
62
  - 'lib/legion/cli/absorb_command.rb'
63
+ - 'lib/legion/cli/mode_command.rb'
63
64
 
64
65
  Metrics/AbcSize:
65
66
  Max: 60
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
@@ -29,6 +29,7 @@ group :test do
29
29
  gem 'rake'
30
30
  gem 'rspec'
31
31
  gem 'rubocop'
32
+ gem 'rubocop-legion'
32
33
  gem 'rubocop-rspec'
33
34
  gem 'ruby_llm'
34
35
  gem 'simplecov'
@@ -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
@@ -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
- @stats[:input_tokens] = (@stats[:input_tokens] || 0) + (response.input_tokens || 0)
69
- @stats[:output_tokens] = (@stats[:output_tokens] || 0) + (response.output_tokens || 0)
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
- input = (@stats[:input_tokens] || 0) * INPUT_RATE
79
- output = (@stats[:output_tokens] || 0) * OUTPUT_RATE
80
- input + output
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: name,
19
- model: session.model_id,
20
- stats: session.stats,
21
- saved_at: Time.now.iso8601,
22
- message_count: messages.size,
23
- summary: generate_summary(messages),
24
- messages: messages
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
- data[:messages].each do |msg|
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