pwn 0.5.749 → 0.5.751

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require 'stringio'
5
+ require 'securerandom'
6
+
7
+ module PWN
8
+ module Plugins
9
+ module REPL
10
+ # Console-owned controller over the existing Swarm persona APIs.
11
+ module AISwarm
12
+ # One cancellable persona job. Model waits can be interrupted; tools finish.
13
+ class JobControl < PWN::AI::Agent::Loop::Steering
14
+ class Stopped < Exception; end # rubocop:disable Lint/InheritException
15
+
16
+ def stop
17
+ @stopping = true
18
+ submit('Stop this swarm job.')
19
+ end
20
+
21
+ def checkpoint(messages:, phase: :boundary, &block)
22
+ if @stopping
23
+ block&.call
24
+ raise Stopped, 'Swarm job cancelled at a safe boundary; completed work is not undone.'
25
+ end
26
+ super
27
+ end
28
+
29
+ def check_stop
30
+ raise Stopped, 'Swarm job cancelled at a safe boundary; completed work is not undone.' if @stopping
31
+ end
32
+ end
33
+
34
+ # Session-scoped swarm jobs. Workers never draw the terminal.
35
+ class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
36
+ MAX_JOBS = 8
37
+ ACTIVE = %w[queued running steering cancelling].freeze
38
+
39
+ def initialize(session_id:)
40
+ @session_id = session_id.to_s
41
+ @mutex = Mutex.new
42
+ @jobs = {}
43
+ @swarm_id = nil
44
+ @closed = false
45
+ end
46
+
47
+ def execute(opts = {})
48
+ line = opts[:line].to_s.strip
49
+ tokens = Shellwords.split(line)
50
+ tokens.shift if tokens.first == '/swarm'
51
+ command = tokens.shift || 'help'
52
+ dispatch(command, tokens, opts)
53
+ rescue StandardError => e
54
+ { ok: false, error: "#{e.class}: #{e.message}" }
55
+ end
56
+
57
+ def snapshot
58
+ @mutex.synchronize do
59
+ {
60
+ swarm_id: @swarm_id,
61
+ jobs: @jobs.values.map { |job| job.slice(:id, :agent, :state, :command, :result, :error, :last_tool).transform_values { |value| value.is_a?(String) ? value.dup : value } }
62
+ }
63
+ end
64
+ end
65
+
66
+ def completion_context
67
+ snap = snapshot
68
+ { swarm_id: snap[:swarm_id], jobs: snap[:jobs], agents: agents }
69
+ end
70
+
71
+ def busy?
72
+ snapshot[:jobs].any? { |job| ACTIVE.include?(job[:state]) }
73
+ end
74
+
75
+ def roster
76
+ PWN::AI::Agent::Swarm.personas(swarm_id: @swarm_id).map do |name, persona|
77
+ { name: name.to_s, role: persona[:role].to_s, engine: (persona[:engine] || 'inherit').to_s, model: (persona[:model] || 'provider default').to_s }
78
+ end
79
+ end
80
+
81
+ def cancel_all
82
+ ids = snapshot[:jobs].select { |job| ACTIVE.include?(job[:state]) }.map { |job| job[:id] }
83
+ ids.each { |id| cancel(id) }
84
+ { ok: true, cancelled: ids.length }
85
+ end
86
+
87
+ def close
88
+ @closed = true
89
+ cancel_all
90
+ workers.each { |worker| worker.join(30) }
91
+ { ok: true }
92
+ end
93
+
94
+ private
95
+
96
+ def dispatch(command, tokens, opts)
97
+ case command
98
+ when 'help', 'dashboard' then help_text
99
+ when 'roster' then { ok: true, agents: roster, swarm_id: @swarm_id }
100
+ when 'status' then status(tokens.first)
101
+ when 'create' then create(tokens.join(' '))
102
+ when 'use' then use(tokens.first)
103
+ when 'spawn' then spawn(tokens)
104
+ when 'retire' then retire(tokens.first)
105
+ when 'ask' then ask(tokens, opts)
106
+ when 'debate' then debate(tokens, opts)
107
+ when 'broadcast' then broadcast(tokens, opts)
108
+ when 'tail' then tail(tokens.first)
109
+ when 'steer' then steer(tokens, opts)
110
+ when 'cancel' then cancel(tokens.first)
111
+ else { ok: false, error: "unknown /swarm command: #{command}", hint: help_text[:usage] }
112
+ end
113
+ end
114
+
115
+ def help_text
116
+ {
117
+ ok: true,
118
+ usage: '/swarm roster|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|ask NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|cancel JOB|all'
119
+ }
120
+ end
121
+
122
+ def create(topic)
123
+ return { ok: false, error: 'cannot create a swarm while jobs are active' } if busy?
124
+
125
+ created = PWN::AI::Agent::Swarm.create(topic: topic.empty? ? @session_id : topic)
126
+ @mutex.synchronize { @swarm_id = created[:swarm_id] }
127
+ { ok: true, swarm_id: created[:swarm_id] }
128
+ end
129
+
130
+ def use(swarm_id)
131
+ return { ok: false, error: 'swarm id required' } unless identifier?(swarm_id)
132
+ return { ok: false, error: 'cannot switch swarm while jobs are active' } if busy?
133
+
134
+ @mutex.synchronize { @swarm_id = swarm_id }
135
+ { ok: true, swarm_id: swarm_id }
136
+ end
137
+
138
+ def spawn(tokens)
139
+ flags, positional = split_flags(tokens)
140
+ name = positional[0]
141
+ role = positional[1..]&.join(' ')
142
+ return { ok: false, error: 'usage: spawn NAME ROLE [--engine E --model M --toolsets a,b]' } if name.to_s.empty? || role.to_s.empty?
143
+ return { ok: false, error: 'invalid persona name' } unless identifier?(name)
144
+
145
+ ensure_swarm
146
+ result = PWN::AI::Agent::Swarm.spawn(
147
+ swarm_id: @swarm_id, name: name, role: role, engine: flags['engine'], model: flags['model'],
148
+ toolsets: flags['toolsets']&.split(','), ephemeral: true
149
+ )
150
+ { ok: true, persona: result[:name], swarm_id: @swarm_id, scope: :swarm }
151
+ end
152
+
153
+ def retire(name)
154
+ return { ok: false, error: 'invalid persona name' } unless identifier?(name)
155
+
156
+ result = PWN::AI::Agent::Swarm.retire(name: name, swarm_id: @swarm_id, local: true)
157
+ { ok: result[:removed], scope: result[:scope], name: name }
158
+ end
159
+
160
+ def ask(tokens, opts)
161
+ name = tokens.shift
162
+ request = tokens.join(' ')
163
+ return { ok: false, error: 'usage: ask NAME REQUEST' } if name.to_s.empty? || request.empty?
164
+
165
+ start_job(agent: name, command: 'ask', opts: opts) do |job|
166
+ PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
167
+ end
168
+ end
169
+
170
+ def debate(tokens, opts)
171
+ flags, positional = split_flags(tokens)
172
+ names = positional.shift.to_s.split(',')
173
+ topic = positional.join(' ')
174
+ rounds = (flags['rounds'] || 1).to_i
175
+ return { ok: false, error: 'usage: debate NAME,NAME TOPIC [--rounds N]' } if names.length < 2 || topic.empty?
176
+ return { ok: false, error: 'rounds must be between 1 and 10' } unless rounds.between?(1, 10)
177
+
178
+ start_job(agent: names.join(','), command: 'debate', opts: opts) do |job|
179
+ transcript = []
180
+ rounds.times do
181
+ names.each do |name|
182
+ request = transcript.empty? ? topic : "#{topic}\n\nPrevious speaker: #{transcript.last}\nRespond, critique, or advance the objective."
183
+ job[:control].check_stop
184
+ result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: @swarm_id, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
185
+ transcript << "#{name}: #{result[:reply]}"
186
+ end
187
+ end
188
+ { reply: transcript.join("\n") }
189
+ end
190
+ end
191
+
192
+ def broadcast(tokens, opts)
193
+ flags, positional = split_flags(tokens)
194
+ request = positional.join(' ')
195
+ names = flags['names']&.split(',') || agents
196
+ return { ok: false, error: 'usage: broadcast REQUEST [--names a,b]' } if request.empty? || names.empty?
197
+
198
+ start_job(agent: names.join(','), command: 'broadcast', opts: opts) do |job|
199
+ replies = names.map do |name|
200
+ job[:control].check_stop
201
+ result = PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
202
+ "#{name}: #{result[:reply]}"
203
+ end
204
+ { reply: replies.join("\n") }
205
+ end
206
+ end
207
+
208
+ def steer(tokens, opts)
209
+ id = tokens.shift
210
+ instruction = tokens.join(' ')
211
+ job = find_job(id)
212
+ return { ok: false, error: 'job not found' } unless job
213
+ return { ok: false, error: 'instruction required' } if instruction.empty?
214
+ return { ok: false, error: 'job is no longer active' } unless update_active(job, 'steering')
215
+
216
+ job[:control].submit(instruction)
217
+ opts[:on_event]&.call(:notice, "Steering #{job[:id]} (#{job[:agent]}): waiting for a safe boundary.")
218
+ { ok: true, job_id: job[:id] }
219
+ end
220
+
221
+ def cancel(id)
222
+ return cancel_all if id.nil? || id == 'all'
223
+
224
+ job = find_job(id)
225
+ return { ok: false, error: 'job not found' } unless job
226
+
227
+ return { ok: false, error: 'job is no longer active' } unless update_active(job, 'cancelling')
228
+
229
+ job[:control].stop
230
+ { ok: true, job_id: job[:id] }
231
+ end
232
+
233
+ def status(id)
234
+ jobs = snapshot[:jobs]
235
+ jobs = jobs.select { |job| job[:id] == id } if id
236
+ { ok: true, swarm_id: @swarm_id, jobs: jobs }
237
+ end
238
+
239
+ def tail(limit)
240
+ return { ok: false, error: 'no swarm selected' } if @swarm_id.to_s.empty?
241
+
242
+ { ok: true, messages: PWN::AI::Agent::Swarm.bus_tail(swarm_id: @swarm_id, limit: (limit || 8).to_i) }
243
+ end
244
+
245
+ def start_job(agent:, command:, opts:)
246
+ return { ok: false, error: 'swarm controller is closed' } if @closed
247
+ return { ok: false, error: "job limit #{MAX_JOBS} reached" } if busy_count >= MAX_JOBS
248
+
249
+ ensure_swarm
250
+ id = SecureRandom.hex(3)
251
+ locals = Thread.current.keys.grep(/^pwn_/).to_h { |key| [key, Thread.current[key]] }
252
+ variables = Thread.current.thread_variables.grep(/^pwn_/).to_h { |key| [key, Thread.current.thread_variable_get(key)] }
253
+ ready = Queue.new
254
+ job = { id: id, agent: agent, command: command, state: 'queued' }
255
+ @mutex.synchronize { @jobs[id] = job }
256
+ job[:worker] = Thread.new do
257
+ locals.each { |key, value| Thread.current[key] = value }
258
+ variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
259
+ job[:control] = JobControl.new(input: StringIO.new, output: StringIO.new)
260
+ Thread.current[:pwn_steering_input] = job[:control]
261
+ ready << true
262
+ update(job, 'running')
263
+ result = yield(job)
264
+ job[:control].check_stop
265
+ update(job, 'completed', result: summarize(result))
266
+ opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} complete\n#{summarize(result)}")
267
+ result
268
+ rescue JobControl::Stopped => e
269
+ update(job, 'cancelled', error: e.message)
270
+ opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.message}")
271
+ rescue StandardError => e
272
+ update(job, 'failed', error: "#{e.class}: #{e.message}")
273
+ opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.class}: #{e.message}")
274
+ end
275
+ job[:worker].report_on_exception = false
276
+ ready.pop
277
+ { ok: true, job_id: id, state: 'queued' }
278
+ end
279
+
280
+ def tool_callback(job, opts)
281
+ lambda do |name, args, result|
282
+ update(job, job[:state], last_tool: name)
283
+ opts[:on_event]&.call(:tool, "#{job[:agent]} [#{job[:id]}] #{name}\n#{args.inspect}")
284
+ opts[:on_event]&.call(:result, result.to_s)
285
+ end
286
+ end
287
+
288
+ def summarize(result)
289
+ return result[:reply].to_s if result.is_a?(Hash) && result[:reply]
290
+ return result[:transcript].map { |row| "#{row[:name]}: #{row[:reply]}" }.join("\n") if result.is_a?(Hash) && result[:transcript]
291
+
292
+ result.to_s
293
+ end
294
+
295
+ def update(job, state, **outcome)
296
+ clean = outcome.transform_values { |value| PWN::Redaction.redact(value: value.to_s) }
297
+ @mutex.synchronize { job.merge!(clean).merge!(state: state) }
298
+ end
299
+
300
+ def update_active(job, state)
301
+ @mutex.synchronize do
302
+ return false unless ACTIVE.include?(job[:state])
303
+
304
+ job[:state] = state
305
+ end
306
+ end
307
+
308
+ def find_job(id)
309
+ @mutex.synchronize { @jobs[id] }
310
+ end
311
+
312
+ def controls
313
+ @mutex.synchronize { @jobs.values.map { |job| job[:control] } }
314
+ end
315
+
316
+ def workers
317
+ @mutex.synchronize { @jobs.values.filter_map { |job| job[:worker] } }
318
+ end
319
+
320
+ def busy_count
321
+ snapshot[:jobs].count { |job| ACTIVE.include?(job[:state]) }
322
+ end
323
+
324
+ def ensure_swarm
325
+ @swarm_id || create('console')[:swarm_id]
326
+ end
327
+
328
+ def agents
329
+ roster.map { |row| row[:name] }
330
+ end
331
+
332
+ def identifier?(value)
333
+ value.to_s.match?(/\A[A-Za-z0-9][A-Za-z0-9_.-]{0,63}\z/)
334
+ end
335
+
336
+ def split_flags(tokens)
337
+ flags = {}
338
+ positional = []
339
+ index = 0
340
+ while index < tokens.length
341
+ token = tokens[index]
342
+ if token == '--'
343
+ positional.concat(tokens[(index + 1)..])
344
+ break
345
+ elsif %w[--engine --model --toolsets --names --rounds].include?(token)
346
+ flags[token.delete_prefix('--')] = tokens[index + 1].to_s
347
+ index += 2
348
+ else
349
+ positional << token
350
+ index += 1
351
+ end
352
+ end
353
+ [flags, positional]
354
+ end
355
+ end
356
+
357
+ public_class_method def self.authors
358
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
359
+ end
360
+
361
+ public_class_method def self.help
362
+ puts "USAGE:
363
+ # Build a session-scoped controller. Jobs call the existing Swarm APIs.
364
+ controller = #{self}::Controller.new(
365
+ session_id: 'required - console session id'
366
+ )
367
+
368
+ # Return module authors.
369
+ #{self}.authors
370
+ "
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
@@ -16,6 +16,10 @@ module PWN
16
16
  module REPL
17
17
  autoload :ASM, 'pwn/plugins/repl/asm'
18
18
  autoload :AI, 'pwn/plugins/repl/ai'
19
+ autoload :AIConsole, 'pwn/plugins/repl/ai_console'
20
+ autoload :AIConsoleCommands, 'pwn/plugins/repl/ai_console_commands'
21
+ autoload :AIConsoleUsage, 'pwn/plugins/repl/ai_console_usage'
22
+ autoload :AISwarm, 'pwn/plugins/repl/ai_swarm'
19
23
  autoload :Mesh, 'pwn/plugins/repl/mesh'
20
24
  autoload :Vault, 'pwn/plugins/repl/vault'
21
25
 
@@ -579,7 +583,7 @@ module PWN
579
583
  puts
580
584
  mirror.call("#{header}\n#{argv_out}#{res_header}\n#{res_out}\n")
581
585
  end
582
- final = PWN::AI::Agent::Loop.run(
586
+ final = PWN::Plugins::REPL.pwn_ai_run_steerable(
583
587
  request: orig_request,
584
588
  session_id: sess_id,
585
589
  enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
@@ -1104,13 +1108,31 @@ module PWN
1104
1108
 
1105
1109
  # Run pwn ai run model and return its result
1106
1110
  #{self}.pwn_ai_run_model(
1107
- args: 'optional - Array args value consumed by #pwn_ai_run_model'
1111
+ args: 'optional - Array args value consumed by #pwn_ai_run_model',
1112
+ prompt: 'optional - callable receiving selection metadata; return effort, nil to cancel, or :deferred'
1113
+ )
1114
+
1115
+ # Resolve provider-supported effort options for a submitted model selection.
1116
+ #{self}.pwn_ai_reasoning_selection(
1117
+ engine: 'required - provider name',
1118
+ model: 'required - exact model identifier'
1119
+ )
1120
+
1121
+ # Prompt for reasoning effort in the legacy line interface.
1122
+ #{self}.pwn_ai_prompt_reasoning(
1123
+ selection: 'required - model selection with efforts and default'
1124
+ )
1125
+
1126
+ # Apply accepted engine, model and reasoning effort as one selection.
1127
+ #{self}.pwn_ai_apply_model(
1128
+ selection: 'required - engine, model and optional reasoning_effort'
1108
1129
  )
1109
1130
 
1110
1131
  # Run persist ai selection and return its result
1111
1132
  #{self}.persist_ai_selection(
1112
1133
  engine: 'required - engine value consumed by #persist_ai_selection',
1113
- model: 'required - model value consumed by #persist_ai_selection'
1134
+ model: 'required - model value consumed by #persist_ai_selection',
1135
+ reasoning_effort: 'optional - accepted reasoning effort to merge into this engine'
1114
1136
  )
1115
1137
 
1116
1138
  # Run pwn ai run cron and return its result
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.749'
4
+ VERSION = '0.5.751'
5
5
  end