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.
- checksums.yaml +4 -4
- data/Gemfile +3 -3
- data/Rakefile +19 -1
- data/bin/pwn_setup +1 -1
- data/build_gem.sh +1 -1
- data/documentation/Backup-Retention.md +27 -0
- data/documentation/Cron.md +64 -35
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Installation.md +280 -15
- data/documentation/Persistence.md +1 -1
- data/documentation/Reinforcement-Learning.md +5 -6
- data/documentation/diagrams/cron-scheduling.svg +130 -128
- data/documentation/diagrams/dot/cron-scheduling.dot +3 -3
- data/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +1544 -44
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/extrospection.rb +6 -7
- data/lib/pwn/ai/agent/learning.rb +59 -11
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/policy.rb +5 -0
- data/lib/pwn/ai/agent/swarm.rb +31 -16
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +48 -3
- data/lib/pwn/cron.rb +120 -49
- data/lib/pwn/memory.rb +62 -6
- data/lib/pwn/migrate.rb +292 -15
- data/lib/pwn/plugins/binary_parser.rb +5 -4
- data/lib/pwn/plugins/ghidra.rb +4 -3
- data/lib/pwn/plugins/log.rb +18 -2
- data/lib/pwn/plugins/repl/ai.rb +25 -4
- data/lib/pwn/plugins/repl/ai_console.rb +41 -21
- data/lib/pwn/plugins/repl/ai_swarm.rb +4 -4
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +359 -92
- data/lib/pwn/setup.rb +9 -17
- data/lib/pwn/version.rb +1 -1
- data/scripts/build_gate.rb +58 -0
- data/spec/integration/build_gate_spec.rb +73 -0
- data/spec/integration/persistence_roundtrip_spec.rb +1 -7
- data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/policy_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/ai/router_spec.rb +30 -2
- data/spec/lib/pwn/cron_spec.rb +113 -6
- data/spec/lib/pwn/memory_spec.rb +58 -1
- data/spec/lib/pwn/migrate_spec.rb +160 -5
- data/spec/lib/pwn/plugins/log_spec.rb +7 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +203 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +11 -0
- data/spec/lib/pwn/redaction_spec.rb +13 -10
- data/spec/lib/pwn/session_cleanup_concurrency_spec.rb +201 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +166 -3
- data/spec/lib/pwn/session_cleanup_legacy_spec.rb +101 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +800 -36
- data/spec/lib/pwn/session_cleanup_recovery_spec.rb +389 -0
- data/spec/lib/pwn/sessions_spec.rb +228 -16
- data/spec/lib/pwn/setup_spec.rb +3 -1
- data/spec/support/gate_failure_formatter.rb +14 -0
- data/third_party/pwn_rdoc.jsonl +27 -1
- 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
|
-
#
|
|
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
|
-
|
|
3576
|
-
File.write(tmp, text)
|
|
3575
|
+
require_relative '../../../../etc/default_cron/session_cleanup' unless defined?(PWN::SessionCleanup)
|
|
3577
3576
|
begin
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -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
|
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
56
|
-
'(id, title,
|
|
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 (
|
|
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
|
|
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
|
data/lib/pwn/ai/http_retry.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|