pwn 0.5.782 → 0.5.784

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/Rakefile +19 -1
  4. data/bin/pwn_setup +1 -1
  5. data/build_gem.sh +1 -1
  6. data/documentation/Backup-Retention.md +27 -0
  7. data/documentation/Cron.md +64 -35
  8. data/documentation/How-PWN-Works.md +1 -1
  9. data/documentation/Installation.md +280 -15
  10. data/documentation/Persistence.md +1 -1
  11. data/documentation/Reinforcement-Learning.md +5 -6
  12. data/documentation/diagrams/cron-scheduling.svg +130 -128
  13. data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
  14. data/documentation/pwn-REPL.md +31 -3
  15. data/documentation/pwn-ai-Agent.md +62 -2
  16. data/etc/default_cron/session_cleanup.rb +1544 -44
  17. data/lib/pwn/ai/agent/curriculum.rb +2 -2
  18. data/lib/pwn/ai/agent/extrospection.rb +6 -7
  19. data/lib/pwn/ai/agent/learning.rb +59 -11
  20. data/lib/pwn/ai/agent/mistakes.rb +22 -3
  21. data/lib/pwn/ai/agent/policy.rb +5 -0
  22. data/lib/pwn/ai/agent/swarm.rb +31 -16
  23. data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
  24. data/lib/pwn/ai/http_retry.rb +3 -0
  25. data/lib/pwn/ai/router.rb +48 -3
  26. data/lib/pwn/cron.rb +120 -49
  27. data/lib/pwn/memory.rb +62 -6
  28. data/lib/pwn/migrate.rb +292 -15
  29. data/lib/pwn/plugins/binary_parser.rb +5 -4
  30. data/lib/pwn/plugins/ghidra.rb +4 -3
  31. data/lib/pwn/plugins/log.rb +18 -2
  32. data/lib/pwn/plugins/repl/ai.rb +25 -4
  33. data/lib/pwn/plugins/repl/ai_console.rb +41 -21
  34. data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
  35. data/lib/pwn/session_trace.rb +1 -1
  36. data/lib/pwn/sessions.rb +359 -92
  37. data/lib/pwn/setup.rb +9 -17
  38. data/lib/pwn/version.rb +1 -1
  39. data/scripts/build_gate.rb +58 -0
  40. data/spec/integration/build_gate_spec.rb +73 -0
  41. data/spec/integration/persistence_roundtrip_spec.rb +1 -7
  42. data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
  43. data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
  44. data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
  45. data/spec/lib/pwn/ai/router_spec.rb +30 -2
  46. data/spec/lib/pwn/cron_spec.rb +113 -6
  47. data/spec/lib/pwn/memory_spec.rb +58 -1
  48. data/spec/lib/pwn/migrate_spec.rb +160 -5
  49. data/spec/lib/pwn/plugins/log_spec.rb +7 -0
  50. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +203 -6
  51. data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
  52. data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
  53. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/redaction_spec.rb +13 -10
  55. data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
  56. data/spec/lib/pwn/session_cleanup_default_spec.rb +166 -3
  57. data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
  58. data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
  59. data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
  60. data/spec/lib/pwn/sessions_spec.rb +228 -16
  61. data/spec/lib/pwn/setup_spec.rb +3 -1
  62. data/spec/support/gate_failure_formatter.rb +14 -0
  63. data/third_party/pwn_rdoc.jsonl +27 -1
  64. metadata +14 -7
@@ -229,9 +229,9 @@ module PWN
229
229
  commit = opts.key?(:commit) ? opts[:commit] : true
230
230
  cutoff = Time.now.utc - (since_h * 3_600)
231
231
 
232
- # PWN::Sessions.list is arity-0 (no kwargs). Cap/sort after the call.
232
+ # Offline evidence discovery must include sessions hidden from operator lists.
233
233
  sids = if PWN::Sessions.respond_to?(:list)
234
- Array(PWN::Sessions.list)
234
+ Array(PWN::Sessions.list(include_hidden: true))
235
235
  .sort_by { |s| s.is_a?(Hash) ? s[:mtime].to_s : '' }
236
236
  .reverse
237
237
  .first(limit * 2)
@@ -3572,16 +3572,15 @@ module PWN
3572
3572
  [true, log]
3573
3573
  when :festival
3574
3574
  # Use plugin if text file available
3575
- tmp = File.join(Dir.tmpdir, "pwn_tts_#{Process.pid}.txt")
3576
- File.write(tmp, text)
3575
+ require_relative '../../../../etc/default_cron/session_cleanup' unless defined?(PWN::SessionCleanup)
3577
3576
  begin
3578
- require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3579
- PWN::Plugins::Voice.text_to_speech(text_path: tmp)
3580
- [true, 'festival via PWN::Plugins::Voice']
3577
+ PWN::SessionCleanup.temporary(bytes: text) do |tmp|
3578
+ require 'pwn/plugins/voice' unless defined?(PWN::Plugins::Voice)
3579
+ PWN::Plugins::Voice.text_to_speech(text_path: tmp)
3580
+ [true, 'festival via PWN::Plugins::Voice']
3581
+ end
3581
3582
  rescue StandardError => e
3582
3583
  [false, "#{e.class}: #{e.message}"]
3583
- ensure
3584
- FileUtils.rm_f(tmp)
3585
3584
  end
3586
3585
  else
3587
3586
  [false, "unsupported TTS engine: #{engine}"]
@@ -101,7 +101,14 @@ module PWN
101
101
  # Plan by default; no provider calls, vault reads, transcript logging or deletion.
102
102
  public_class_method def self.migrate_evidence(opts = {})
103
103
  dry = opts[:dry_run] != false
104
- lock = learning_lock unless dry
104
+ max_sessions = opts[:max_sessions]
105
+ stop = opts[:stop]
106
+ raise ArgumentError, 'max_sessions must be positive' if max_sessions && (!max_sessions.is_a?(Integer) || max_sessions <= 0)
107
+
108
+ leases = []
109
+ lock = learning_lock(nonblocking: opts[:nonblocking]) unless dry
110
+ return { dry_run: false, deferred: 'learning_writer_active', migrated_rows: 0 } if lock == :busy
111
+
105
112
  original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
106
113
  malformed = 0
107
114
  rows = original.lines.filter_map do |line|
@@ -116,9 +123,13 @@ module PWN
116
123
 
117
124
  report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
118
125
  missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
119
- estimated_source_bytes: 0, migrated_rows: 0 }
126
+ estimated_source_bytes: 0, migrated_rows: 0, deferred_active_sessions: 0 }
120
127
  snapshots = {}
121
128
  rows.each do |row|
129
+ if stop&.call || (max_sessions && report[:migratable_sessions] >= max_sessions)
130
+ report[:bounded] = true
131
+ break
132
+ end
122
133
  sid = row[:session_id]
123
134
  next if sid.nil? || sid == ''
124
135
 
@@ -140,6 +151,22 @@ module PWN
140
151
  next
141
152
  end
142
153
  begin
154
+ # Keep the permanent exclusive lease through index publication;
155
+ # a point-in-time active? check alone races subsequent activation.
156
+ if dry
157
+ if PWN::Sessions.active?(session_id: sid)
158
+ report[:deferred_active_sessions] += 1
159
+ next
160
+ end
161
+ else
162
+ lease = File.open(PWN::Sessions.support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- released together after commit
163
+ leases << lease
164
+ unless lease.flock(File::LOCK_EX | File::LOCK_NB)
165
+ lease.close
166
+ report[:deferred_active_sessions] += 1
167
+ next
168
+ end
169
+ end
143
170
  body = evidence_read(path: path)
144
171
  transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
145
172
  raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
@@ -179,7 +206,8 @@ module PWN
179
206
 
180
207
  evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
181
208
  ensure
182
- learning_unlock(lock: lock) if lock
209
+ leases&.each { |lease| lease.close unless lease.closed? }
210
+ learning_unlock(lock: lock) if lock && lock != :busy
183
211
  end
184
212
 
185
213
  private_class_method def self.evidence_read(opts = {})
@@ -253,6 +281,8 @@ module PWN
253
281
 
254
282
  raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
255
283
  end
284
+ return { dry_run: true, restored: false } if opts[:dry_run] == true
285
+
256
286
  evidence_write(path: LEARNING_FILE, body: backup)
257
287
  evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
258
288
  { restored: true }
@@ -262,7 +292,9 @@ module PWN
262
292
 
263
293
  private_class_method def self.evidence_write(opts = {})
264
294
  path = opts[:path]
265
- body = opts[:body]
295
+ # Verification is byte equality, not Ruby's encoding-sensitive text equality.
296
+ # Retag a copy only: do not transcode, normalize or scrub evidence/backups.
297
+ body = opts[:body].b
266
298
  dir = File.dirname(path)
267
299
  ancestor = dir
268
300
  ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
@@ -330,9 +362,10 @@ module PWN
330
362
  end
331
363
 
332
364
  # Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
333
- private_class_method def self.learning_lock
365
+ private_class_method def self.learning_lock(opts = {})
334
366
  key = :pwn_learning_write_lock
335
- return nil if Thread.current[key]
367
+ owner = Thread.current[key]
368
+ return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
336
369
 
337
370
  root = File.dirname(LEARNING_FILE)
338
371
  FileUtils.mkdir_p(root, mode: 0o700)
@@ -340,9 +373,12 @@ module PWN
340
373
  raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
341
374
 
342
375
  # Ownership transfers to the caller's ensure/learning_unlock.
343
- file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
344
- file.flock(File::LOCK_EX)
345
- Thread.current[key] = file
376
+ file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
377
+ unless file.flock(File::LOCK_EX | (opts[:nonblocking] ? File::LOCK_NB : 0))
378
+ file.close
379
+ return :busy
380
+ end
381
+ Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
346
382
  file
347
383
  end
348
384
 
@@ -1250,6 +1286,8 @@ module PWN
1250
1286
  # stays high-signal.
1251
1287
 
1252
1288
  public_class_method def self.consolidate(opts = {})
1289
+ return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { consolidate(opts) } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
1290
+
1253
1291
  cap = opts[:max_entries] || MAX_MEMORY_ENTRIES
1254
1292
  return { removed: 0 } unless defined?(PWN::Memory)
1255
1293
 
@@ -2221,6 +2259,8 @@ module PWN
2221
2259
  # after upgrading; subsequent writes never produce these.
2222
2260
 
2223
2261
  public_class_method def self.purge_noise
2262
+ return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { purge_noise } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
2263
+
2224
2264
  return { removed: 0 } unless defined?(PWN::Memory)
2225
2265
 
2226
2266
  mem = nil
@@ -2381,10 +2421,18 @@ module PWN
2381
2421
  public_class_method def self.help
2382
2422
  puts "
2383
2423
  # Plan a full-snapshot evidence migration, or explicitly apply it.
2384
- #{self}.migrate_evidence(dry_run: 'optional - Boolean; defaults true, false writes evidence and references')
2424
+ #{self}.migrate_evidence(
2425
+ dry_run: 'optional - Boolean; defaults true, false writes evidence and references',
2426
+ max_sessions: 'optional - Positive maximum source snapshots per transaction',
2427
+ nonblocking: 'optional - Boolean; defer instead of waiting for a cooperating learning writer',
2428
+ stop: 'optional - Cooperative stop callable checked between source snapshots'
2429
+ )
2385
2430
 
2386
2431
  # Restore an unchanged learning ledger before any source-session cleanup.
2387
- #{self}.rollback_evidence(migration: 'required - SHA-256 migration ID from the ledger filename')
2432
+ #{self}.rollback_evidence(
2433
+ migration: 'required - SHA-256 migration ID from the ledger filename',
2434
+ dry_run: 'optional - validate rollback without replacing learning bytes; defaults to false'
2435
+ )
2388
2436
 
2389
2437
  # Persist derived PRM rewards without rewriting immutable supporting content.
2390
2438
  #{self}.annotate_evidence(
@@ -63,15 +63,18 @@ module PWN
63
63
  FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
64
64
  return {} unless File.exist?(MISTAKES_FILE)
65
65
 
66
- JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
67
- rescue StandardError
68
- {}
66
+ store = JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
67
+ raise IOError, 'Invalid mistakes map' unless store.is_a?(Hash)
68
+
69
+ store
69
70
  end
70
71
 
71
72
  # Supported Method Parameters::
72
73
  # PWN::AI::Agent::Mistakes.save(store: hash)
73
74
 
74
75
  public_class_method def self.save(opts = {})
76
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
77
+
75
78
  store = opts[:store] ||= {}
76
79
  FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
77
80
  atomic_write(path: MISTAKES_FILE, body: JSON.pretty_generate(store))
@@ -195,6 +198,8 @@ module PWN
195
198
  # the caller (Loop.run) can drive cross-session repeat detection.
196
199
 
197
200
  public_class_method def self.record(opts = {})
201
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { record(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
202
+
198
203
  tool = opts[:tool].to_s
199
204
  error = opts[:error].to_s
200
205
  return nil if tool.empty? || error.strip.empty?
@@ -272,6 +277,8 @@ module PWN
272
277
  # )
273
278
 
274
279
  public_class_method def self.resolve(opts = {})
280
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { resolve(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
281
+
275
282
  sig = opts[:signature].to_s
276
283
  fix = opts[:fix].to_s
277
284
  raise 'ERROR: signature is required' if sig.empty?
@@ -416,6 +423,8 @@ module PWN
416
423
  }.freeze
417
424
 
418
425
  public_class_method def self.extinguish!(opts = {})
426
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
427
+
419
428
  sig = opts[:signature].to_s
420
429
  return nil if sig.empty?
421
430
 
@@ -457,6 +466,8 @@ module PWN
457
466
  # Auto-resolve parked items that already have a known extinguish recipe
458
467
  # so the operator inbox does not keep scars the loop can close itself.
459
468
  public_class_method def self.extinguish_parked!(opts = {})
469
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish_parked!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
470
+
460
471
  limit = (opts[:limit] || 20).to_i
461
472
  dry = opts[:dry_run] ? true : false
462
473
  rows = load.values.select do |m|
@@ -495,6 +506,8 @@ module PWN
495
506
  end
496
507
 
497
508
  public_class_method def self.park(opts = {})
509
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { park(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
510
+
498
511
  sig = opts[:signature].to_s
499
512
  raise 'ERROR: signature is required' if sig.empty?
500
513
 
@@ -692,6 +705,8 @@ module PWN
692
705
  end
693
706
 
694
707
  public_class_method def self.note_hint_outcome(opts = {})
708
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { note_hint_outcome(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
709
+
695
710
  sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
696
711
  return nil if sig.empty?
697
712
 
@@ -777,6 +792,8 @@ module PWN
777
792
  # drop after Memory already holds mistake_fix_<sig>.
778
793
 
779
794
  public_class_method def self.lean!(opts = {})
795
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
796
+
780
797
  dry = opts[:dry_run] ? true : false
781
798
  max_resolved = (opts[:max_resolved_kept] || MAX_RESOLVED_KEPT).to_i
782
799
  min_age = (opts[:resolved_min_age_days] || RESOLVED_MIN_AGE_DAYS).to_f
@@ -878,6 +895,8 @@ module PWN
878
895
  # PWN::AI::Agent::Mistakes.reset
879
896
 
880
897
  public_class_method def self.reset
898
+ return PWN::Memory.transaction(path: MISTAKES_FILE) { reset } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
899
+
881
900
  FileUtils.rm_f(MISTAKES_FILE)
882
901
  {}
883
902
  end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'fileutils'
5
5
  require 'time'
6
6
  require 'digest'
7
+ require_relative '../../memory'
7
8
 
8
9
  module PWN
9
10
  module AI
@@ -696,6 +697,8 @@ module PWN
696
697
 
697
698
  public_class_method def self.lean!(opts = {})
698
699
  dry = opts[:dry_run] ? true : false
700
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { lean!(opts) } unless dry || PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
701
+
699
702
  return { skipped: true } unless File.exist?(TRAJECTORY_FILE)
700
703
 
701
704
  rows = File.readlines(TRAJECTORY_FILE)
@@ -1385,6 +1388,8 @@ module PWN
1385
1388
  end
1386
1389
 
1387
1390
  private_class_method def self.persist_episode!(opts = {})
1391
+ return PWN::Memory.transaction(path: TRAJECTORY_FILE) { persist_episode!(opts) } unless PWN::Memory.transaction_owned?(path: TRAJECTORY_FILE)
1392
+
1388
1393
  ep = opts[:episode]
1389
1394
  return unless ep.is_a?(Hash)
1390
1395
 
@@ -120,12 +120,19 @@ module PWN
120
120
  public_class_method def self.retire(opts = {})
121
121
  name = opts[:name].to_s
122
122
  sid = opts[:swarm_id].to_s
123
+ continuation = nil
124
+ if opts[:continuation]
125
+ raise ArgumentError, 'Continuation retirement requires local and swarm_id' unless opts[:local] == true && !sid.empty?
126
+
127
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
128
+ continuation = PWN::SessionCleanup.retire_continuation(root: SWARM_ROOT, swarm_id: sid, name: name)
129
+ end
123
130
  if opts[:local] == true && !sid.empty?
124
131
  path = File.join(SWARM_ROOT, sid, 'agents.yml')
125
132
  all = load_personas_file(path: path)
126
133
  gone = all.delete(name.to_sym)
127
134
  File.write(path, YAML.dump(deep_stringify(hash: all))) if gone
128
- return { name: name, removed: !gone.nil?, scope: :swarm }
135
+ return { name: name, removed: !gone.nil?, scope: :swarm, continuation: continuation }
129
136
  end
130
137
 
131
138
  all = personas
@@ -414,21 +421,28 @@ module PWN
414
421
  sid = opts[:swarm_id].to_s
415
422
  name = opts[:name].to_s
416
423
  map_path = File.join(SWARM_ROOT, sid, 'personas.json')
417
- map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
418
- if map[name]
419
- opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
420
- return map[name]
421
- end
424
+ require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
425
+ PWN::SessionCleanup.with_continuation_lock(root: File.dirname(SWARM_ROOT)) do
426
+ raise IOError, 'Unsafe continuation map' if File.symlink?(map_path)
422
427
 
423
- sess = PWN::Sessions.create(
424
- title: "swarm:#{sid} persona:#{name}",
425
- source: 'pwn-ai-swarm',
426
- active: !opts[:leases].nil?
427
- )
428
- opts[:leases] << sess[:lease] if opts[:leases]
429
- map[name] = sess[:id]
430
- File.write(map_path, JSON.pretty_generate(map))
431
- sess[:id]
428
+ map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
429
+ raise IOError, 'Unknown continuation map' unless map.is_a?(Hash)
430
+
431
+ if map[name]
432
+ opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
433
+ next map[name]
434
+ end
435
+
436
+ sess = PWN::Sessions.create(
437
+ title: "swarm:#{sid} persona:#{name}",
438
+ source: 'pwn-ai-swarm',
439
+ active: !opts[:leases].nil?
440
+ )
441
+ opts[:leases] << sess[:lease] if opts[:leases]
442
+ map[name] = sess[:id]
443
+ PWN::SessionCleanup.publish(map_path, JSON.pretty_generate(map))
444
+ sess[:id]
445
+ end
432
446
  end
433
447
 
434
448
  private_class_method def self.build_persona_prompt(opts = {})
@@ -759,7 +773,8 @@ module PWN
759
773
  #{self}.retire(
760
774
  name: 'required - persona name',
761
775
  swarm_id: 'optional - swarm directory when local is true',
762
- local: 'optional - true retires only that swarm registry'
776
+ local: 'optional - true retires only that swarm registry',
777
+ continuation: 'optional - explicitly retire the inactive saved conversation association; requires local and swarm_id'
763
778
  )
764
779
 
765
780
  # Swarm lifecycle & bus
@@ -52,23 +52,23 @@ PWN::AI::Agent::Registry.register(
52
52
  toolset: 'sessions',
53
53
  schema: {
54
54
  name: 'sessions_list',
55
- description: 'List every pwn-ai session transcript in ~/.pwn/sessions ' \
56
- '(id, title, path, size, mtime, lines, dependencies, dependency_errors) newest-first. ' \
55
+ description: 'List the current active session and valid summarized or dependency-backed pwn-ai sessions in ~/.pwn/sessions ' \
56
+ 'as paginated concise rows (id, title, mtime, dependencies), oldest-first, with total and global diagnostics. ' \
57
57
  'dependencies lists files referencing this session through structured links, not prose mentions. ' \
58
58
  'Use to discover a session_id for learning_reflect / ' \
59
59
  'learning_distill_skill / sessions_view.',
60
60
  parameters: {
61
61
  type: 'object',
62
62
  properties: {
63
- limit: { type: 'integer', default: 25, description: 'Max sessions to return (newest first).' }
63
+ limit: { type: 'integer', default: 25, description: 'Max sessions to return (oldest first).' },
64
+ offset: { type: 'integer', description: 'Number of retained sessions to skip; omitted selects newest page. Current active session is also included.' }
64
65
  },
65
66
  required: []
66
67
  }
67
68
  },
68
69
  check: -> { defined?(PWN::Sessions) },
69
70
  handler: lambda { |args|
70
- limit = (args[:limit] || 25).to_i
71
- PWN::Sessions.list.sort_by { |s| s[:mtime].to_s }.reverse.first(limit)
71
+ PWN::Sessions.catalog(limit: args[:limit] || 25, offset: args[:offset], current_session_id: Thread.current[:pwn_session_id])
72
72
  }
73
73
  )
74
74
 
@@ -152,7 +152,7 @@ PWN::AI::Agent::Registry.register(
152
152
  },
153
153
  check: -> { defined?(PWN::Sessions) },
154
154
  handler: lambda { |_args|
155
- listing = PWN::Sessions.list
155
+ listing = PWN::Sessions.list(include_hidden: true)
156
156
  PWN::Sessions.stats.merge(
157
157
  disk_bytes: listing.sum { |s| s[:size].to_i },
158
158
  dir: PWN::Sessions.sessions_dir
@@ -31,6 +31,9 @@ module PWN
31
31
  # Resolve provider defaults from the active request without mutating Env.
32
32
  public_class_method def self.provider_config(opts = {})
33
33
  engine = opts[:engine]
34
+ title_config = Thread.current[:pwn_session_title_config]
35
+ return title_config[engine] if title_config
36
+
34
37
  owner = Thread.current[:pwn_request_runtime]
35
38
  return owner.provider_config(engine) if owner.respond_to?(:provider_config)
36
39
 
data/lib/pwn/ai/router.rb CHANGED
@@ -59,7 +59,7 @@ module PWN
59
59
 
60
60
  extractive(text: text)
61
61
  rescue StandardError
62
- return '' if session
62
+ raise if session
63
63
 
64
64
  extractive(text: text)
65
65
  end
@@ -163,14 +163,57 @@ module PWN
163
163
  return nil unless mod.respond_to?(:chat)
164
164
 
165
165
  resp = mod.chat(request: opts[:request], model: endpoint[:model], spinner: false, quiet: true, timeout: 8)
166
- text = resp.is_a?(Hash) ? (resp[:content] || resp['content'] || resp.dig(:choices, -1, :content)).to_s : resp.to_s
166
+ text = response_text(response: resp)
167
167
  text.strip.empty? ? nil : text
168
168
  rescue StandardError
169
+ raise if opts[:session]
170
+
169
171
  nil
170
172
  ensure
171
173
  Thread.current[:pwn_session_title] = previous_title_worker
172
174
  end
173
175
 
176
+ # Only decode documented assistant-content locations, never container inspect
177
+ # strings, reasoning blocks, tool arguments or echoed user messages.
178
+ private_class_method def self.response_text(opts = {})
179
+ response = opts[:response]
180
+ return response if response.is_a?(String)
181
+ return '' unless response.is_a?(Hash)
182
+
183
+ row = response.transform_keys(&:to_s)
184
+ return JSON.generate(row) if row['title'].is_a?(String) && row['summary'].is_a?(String)
185
+ return content_text(content: row['content']) if row.key?('content')
186
+
187
+ choice = Array(row['choices']).last
188
+ if choice.is_a?(Hash)
189
+ choice = choice.transform_keys(&:to_s)
190
+ message = choice['message'] || choice
191
+ if message.is_a?(Hash)
192
+ message = message.transform_keys(&:to_s)
193
+ return '' if message['role'] && message['role'] != 'assistant'
194
+
195
+ return content_text(content: message['content'])
196
+ end
197
+ end
198
+ message = row['message']
199
+ return content_text(content: message[:content] || message['content']) if message.is_a?(Hash)
200
+
201
+ ''
202
+ end
203
+
204
+ private_class_method def self.content_text(opts = {})
205
+ content = opts[:content]
206
+ return content if content.is_a?(String)
207
+ return '' unless content.is_a?(Array)
208
+
209
+ content.filter_map do |block|
210
+ next unless block.is_a?(Hash)
211
+
212
+ block = block.transform_keys(&:to_s)
213
+ block['text'] if %w[text output_text].include?(block['type']) && block['text'].is_a?(String)
214
+ end.join
215
+ end
216
+
174
217
  private_class_method def self.active_session_endpoint
175
218
  engine = env_dig(keys: %i[ai active]).to_s.downcase
176
219
  { engine: engine, model: env_dig(keys: [:ai, engine.to_sym, :model]) }
@@ -226,7 +269,9 @@ module PWN
226
269
  private_class_method def self.env_dig(opts = {})
227
270
  return {} unless defined?(PWN::Env) && PWN::Env.respond_to?(:dig)
228
271
 
229
- val = PWN::Env.dig(*opts[:keys])
272
+ keys = opts[:keys]
273
+ config = Thread.current[:pwn_session_title_config]
274
+ val = config && keys.first == :ai ? config.dig(*keys.drop(1)) : PWN::Env.dig(*keys)
230
275
  if val.is_a?(Hash)
231
276
  val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
232
277
  else