legionio 1.6.20 → 1.6.21

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dd4d5c0f258dd9199195ac95f89dbcaaeccc3ccbf844924f463dfecffe6304c
4
- data.tar.gz: 453721f4e03bf0c9ff3387575749241cb3bc9c8093b5c366d7b3434e823558be
3
+ metadata.gz: 4d50e1efc9398c0e2d380c7a921e2ccc8b6e6db3a142d4f4a700ff536555a2e4
4
+ data.tar.gz: e44d47e0c4d04362d9a35c6ad6953d0b4a42a5f0f2225dd0420e7f2d1c544ad4
5
5
  SHA512:
6
- metadata.gz: 440288ee7de2bf883920879b3658c1cdf1c2a27546e39be8641f7d92dc91956c0ed6042e3ab4400103de06be0adb1fbca4738d215d9e6e0b8f26c05a617d2a2c
7
- data.tar.gz: cb62010a5b8062fbef95bf39efc642d02251897ae9447901bbafbf230a8ec4012c695ca12f87000c24374b0c073c4caa90a380118b12fa02759e8856ae9a4e0f
6
+ metadata.gz: f4cc63f869d21abc423f7836a25b91f62a815d01d1972004b85728be0b969e332a5f95c9172ab6177f3759e837b6e40bbb30afba1ecd3a817cd5088fff4c675b
7
+ data.tar.gz: 719f9a8e3d316937be26ed610b7ba86c653317b476b271d821591fecf5ff8157c02788b03df1468c7fe3af19d76ec1a55cf48312d911a502e30873c51d378bb0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.21] - 2026-03-27
6
+
7
+ ### Added
8
+ - `legionio knowledge capture transcript` CLI command: ingests Claude Code session transcripts into Apollo knowledge store
9
+ - Stop hook for automatic transcript capture at session end (installed via `legion setup claude-code`)
10
+
5
11
  ## [1.6.20] - 2026-03-27
6
12
 
7
13
  ### Changed
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'shellwords'
4
+
3
5
  module Legion
4
6
  module CLI
5
7
  class MonitorCommand < Thor
@@ -173,10 +175,130 @@ module Legion
173
175
  end
174
176
  end
175
177
 
176
- no_commands do
178
+ desc 'transcript', 'Capture a Claude Code session transcript as knowledge'
179
+ option :session_id, type: :string, desc: 'Session ID (defaults to CLAUDE_SESSION_ID env)'
180
+ option :cwd, type: :string, desc: 'Working directory (defaults to CLAUDE_CWD env)'
181
+ option :max_chunks, type: :numeric, default: 50, desc: 'Max conversation turn chunks to ingest'
182
+ def transcript
183
+ session_id = options[:session_id] || ENV.fetch('CLAUDE_SESSION_ID', nil)
184
+ cwd = options[:cwd] || ENV.fetch('CLAUDE_CWD', nil) || ::Dir.pwd
185
+
186
+ unless session_id
187
+ formatter.warn('No session ID provided (set CLAUDE_SESSION_ID or --session-id)')
188
+ return
189
+ end
190
+
191
+ jsonl_path = resolve_transcript_path(session_id, cwd)
192
+ unless jsonl_path && ::File.exist?(jsonl_path)
193
+ formatter.warn("Transcript not found: #{jsonl_path || 'could not resolve path'}")
194
+ return
195
+ end
196
+
197
+ turns = extract_turns(jsonl_path)
198
+ if turns.empty?
199
+ formatter.warn('No conversation turns found in transcript')
200
+ return
201
+ end
202
+
203
+ repo = `git -C #{Shellwords.escape(cwd)} rev-parse --show-toplevel 2>/dev/null`.strip.split('/').last
204
+ base_tags = ['claude-code', 'transcript', "session:#{session_id}", ::Time.now.strftime('%Y-%m-%d')]
205
+ base_tags << "repo:#{repo}" unless repo.to_s.empty?
206
+
207
+ ingested = 0
208
+ turns.first(options[:max_chunks]).each_with_index do |turn, idx|
209
+ content = format_turn(turn, idx + 1)
210
+ next if content.strip.empty?
211
+
212
+ result = ingest_content(
213
+ content: content,
214
+ tags: base_tags + ["turn:#{idx + 1}"],
215
+ source: "claude-code:#{session_id}:turn-#{idx + 1}"
216
+ )
217
+ ingested += 1 if result[:success]
218
+ end
219
+
220
+ out = formatter
221
+ if options[:json]
222
+ out.json(success: true, session_id: session_id, turns: turns.size, ingested: ingested)
223
+ else
224
+ out.success("Captured #{ingested}/#{[turns.size, options[:max_chunks]].min} turns from session #{session_id[0, 8]}")
225
+ end
226
+ end
227
+
228
+ no_commands do # rubocop:disable Metrics/BlockLength
177
229
  def formatter
178
230
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
179
231
  end
232
+
233
+ def resolve_transcript_path(session_id, cwd)
234
+ project_dir = cwd.gsub('/', '-')
235
+ ::File.expand_path("~/.claude/projects/#{project_dir}/#{session_id}.jsonl")
236
+ end
237
+
238
+ def extract_turns(path)
239
+ turns = []
240
+ current_turn = nil
241
+
242
+ ::File.foreach(path) do |line|
243
+ entry = ::JSON.parse(line, symbolize_names: true)
244
+ type = entry[:type]
245
+
246
+ case type
247
+ when 'user'
248
+ turns << current_turn if current_turn
249
+ current_turn = { user: extract_message_text(entry), assistant: +'', timestamp: entry[:timestamp] }
250
+ when 'assistant'
251
+ next unless current_turn
252
+
253
+ text = extract_message_text(entry)
254
+ current_turn[:assistant] << text unless text.empty?
255
+ end
256
+ rescue ::JSON::ParserError
257
+ next
258
+ end
259
+
260
+ turns << current_turn if current_turn
261
+ turns
262
+ end
263
+
264
+ def extract_message_text(entry)
265
+ msg = entry[:message]
266
+ return '' unless msg
267
+
268
+ content = msg[:content]
269
+ case content
270
+ when String then content
271
+ when Array
272
+ content.filter_map { |block| block[:text] if block[:type] == 'text' }.join("\n")
273
+ else ''
274
+ end
275
+ end
276
+
277
+ def format_turn(turn, number)
278
+ text = "## Turn #{number}\n"
279
+ text << "Timestamp: #{turn[:timestamp]}\n\n" if turn[:timestamp]
280
+ text << "### User\n#{truncate_text(turn[:user], 4096)}\n\n"
281
+ text << "### Assistant\n#{truncate_text(turn[:assistant], 4096)}\n"
282
+ text
283
+ end
284
+
285
+ def truncate_text(text, max_bytes)
286
+ return text if text.bytesize <= max_bytes
287
+
288
+ "#{text.byteslice(0, max_bytes - 20)}\n\n[truncated]"
289
+ end
290
+
291
+ def ingest_content(content:, tags:, source:)
292
+ if defined?(Legion::Extensions::Knowledge::Runners::Ingest)
293
+ Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
294
+ content: content, tags: tags, source: source
295
+ )
296
+ elsif defined?(Legion::Apollo)
297
+ Legion::Apollo.ingest(content: content, tags: tags, source: source)
298
+ else
299
+ { success: false, error: 'neither lex-knowledge nor legion-apollo available' }
300
+ end
301
+ end
180
302
  end
181
303
  end
182
304
 
@@ -350,9 +350,9 @@ module Legion
350
350
 
351
351
  hooks = existing['hooks'] || {}
352
352
 
353
- has_commit = Array(hooks['PostToolUse']).any? { |h| h['command']&.include?('knowledge capture commit') }
354
- has_session = Array(hooks['Stop']).any? { |h| h['command']&.include?('knowledge capture session') }
355
- if has_commit && has_session && !options[:force]
353
+ has_commit = Array(hooks['PostToolUse']).any? { |h| h['command']&.include?('knowledge capture commit') }
354
+ has_transcript = Array(hooks['Stop']).any? { |h| h['command']&.include?('knowledge capture transcript') }
355
+ if has_commit && has_transcript && !options[:force]
356
356
  puts ' Write-back hooks already present (use --force to overwrite)' unless options[:json]
357
357
  return
358
358
  end
@@ -368,10 +368,10 @@ module Legion
368
368
  }
369
369
  end
370
370
 
371
- unless has_session
371
+ unless has_transcript
372
372
  hooks['Stop'] << {
373
- 'command' => 'legionio knowledge capture session',
374
- 'timeout' => 15_000
373
+ 'command' => 'legionio knowledge capture transcript',
374
+ 'timeout' => 30_000
375
375
  }
376
376
  end
377
377
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.20'
4
+ VERSION = '1.6.21'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.20
4
+ version: 1.6.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity