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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PWN
4
+ module Plugins
5
+ module REPL
6
+ # Live slash-command parameter completion. Never calls a provider.
7
+ module AIConsoleCommands
8
+ COMMANDS = %w[
9
+ /back /clear /cron /debug /delegate /help /input /learning /mcp /memory /menu /model /system-role
10
+ /sessions /skills /status /steer /swarm /trace /verbose
11
+ ].freeze
12
+ SWARM = %w[dashboard help roster status create use spawn retire ask debate broadcast tail steer cancel].freeze
13
+
14
+ public_class_method def self.complete(opts = {})
15
+ line = opts[:line].to_s
16
+ cursor = opts[:cursor].to_i
17
+ tokens = scan(line: line, cursor: cursor)
18
+ current = tokens.find { |token| token[:current] } || tokens.last || { text: '', start: 0, finish: 0 }
19
+ prefix = line.scan(/\X/)[current[:start]...cursor].to_a.join
20
+ values, hint = if prefix.start_with?('PWN')
21
+ pwn_constants(prefix: prefix)
22
+ elsif path_token?(prefix: prefix)
23
+ paths(prefix: prefix)
24
+ else
25
+ candidates(tokens: tokens, prefix: prefix, pry: opts[:pry], swarm: opts[:swarm])
26
+ end
27
+ items = values.map do |value|
28
+ replacement = line_replace(line: line, token: current, value: value)
29
+ { label: value, text: replacement[:text], cursor: replacement[:cursor] }
30
+ end
31
+ { items: items, hint: hint }
32
+ end
33
+
34
+ public_class_method def self.authors
35
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
36
+ end
37
+
38
+ public_class_method def self.help
39
+ puts "USAGE:
40
+ # Complete the token at a grapheme cursor. Returned text replaces the whole line.
41
+ #{self}.complete(
42
+ line: 'required - current mission text',
43
+ cursor: 'optional - grapheme index, default 0',
44
+ pry: 'optional - Pry instance for Ruby completion',
45
+ swarm: 'optional - controller exposing completion_context'
46
+ )
47
+
48
+ # Return module authors.
49
+ #{self}.authors
50
+ "
51
+ end
52
+
53
+ private_class_method def self.path_token?(opts = {})
54
+ prefix = opts[:prefix].to_s
55
+ return true if prefix.match?(%r{\A(?:~|\./|\.\./)})
56
+ return false unless prefix.start_with?('/')
57
+ return false if COMMANDS.any? { |cmd| cmd.start_with?(prefix) || prefix == cmd }
58
+
59
+ prefix.count('/') > 1 || File.directory?(prefix)
60
+ end
61
+
62
+ private_class_method def self.pwn_constants(opts = {})
63
+ prefix = opts[:prefix].to_s
64
+ parts = prefix.split('::', -1)
65
+ mod = Object
66
+ parts[0..-2].each do |name|
67
+ return [[], 'PWN constant'] unless mod.respond_to?(:const_defined?) && mod.const_defined?(name, false)
68
+
69
+ mod = mod.const_get(name, false)
70
+ end
71
+ return [[], 'PWN constant'] unless mod.is_a?(Module)
72
+
73
+ stem = parts.last.to_s
74
+ parent = parts[0..-2].reject(&:empty?).join('::')
75
+ names = mod.constants(false).sort.select { |name| name.to_s.start_with?(stem) }.filter_map do |name|
76
+ shown = parent.empty? ? name.to_s : "#{parent}::#{name}"
77
+ shown += '::' if mod.autoload?(name) || mod.const_get(name, false).is_a?(Module)
78
+ shown
79
+ rescue StandardError
80
+ nil
81
+ end
82
+ [names, 'PWN constant']
83
+ rescue NameError, LoadError
84
+ [[], 'PWN constant unavailable']
85
+ end
86
+
87
+ private_class_method def self.paths(opts = {})
88
+ prefix = opts[:prefix].to_s
89
+ [REPL.pwn_ai_complete_path(target: prefix, line: prefix).first(12), 'path']
90
+ rescue StandardError
91
+ [[], 'path']
92
+ end
93
+
94
+ private_class_method def self.scan(opts = {})
95
+ graphemes = opts[:line].scan(/\X/)
96
+ cursor = opts[:cursor].clamp(0, graphemes.length)
97
+ tokens = []
98
+ index = 0
99
+ while index < graphemes.length
100
+ index += 1 while index < graphemes.length && graphemes[index].match?(/\s/)
101
+ break if index >= graphemes.length
102
+
103
+ start = index
104
+ quote = nil
105
+ while index < graphemes.length
106
+ char = graphemes[index]
107
+ if quote
108
+ quote = nil if char == quote
109
+ elsif %w[' "].include?(char)
110
+ quote = char
111
+ elsif char.match?(/\s/)
112
+ break
113
+ end
114
+ index += 1
115
+ end
116
+ tokens << { text: graphemes[start...index].join, start: start, finish: index, current: cursor.between?(start, index) }
117
+ end
118
+ tokens << { text: '', start: cursor, finish: cursor, current: true } if tokens.none? { |token| token[:current] }
119
+ tokens
120
+ end
121
+
122
+ private_class_method def self.line_replace(opts = {})
123
+ graphemes = opts[:line].scan(/\X/)
124
+ token = opts[:token]
125
+ value = opts[:value].to_s
126
+ graphemes[token[:start]...token[:finish]] = value.scan(/\X/)
127
+ { text: graphemes.join, cursor: token[:start] + value.scan(/\X/).length }
128
+ end
129
+
130
+ private_class_method def self.candidates(opts = {})
131
+ tokens = opts[:tokens]
132
+ prefix = opts[:prefix]
133
+ command = tokens.first[:text]
134
+ position = tokens.index { |token| token[:current] }.to_i
135
+ return [filter(values: COMMANDS, prefix: prefix), 'command'] if position.zero?
136
+
137
+ case command
138
+ when '/verbose' then [filter(values: %w[on off], prefix: prefix), 'verbosity']
139
+ when '/steer', '/input', '/delegate' then [[], 'free text; no invented values']
140
+ when '/cron' then cron(tokens: tokens, position: position, prefix: prefix)
141
+ when '/memory' then memory(tokens: tokens, position: position, prefix: prefix)
142
+ when '/sessions' then sessions(tokens: tokens, position: position, prefix: prefix)
143
+ when '/skills' then skills(tokens: tokens, position: position, prefix: prefix)
144
+ when '/learning' then learning(tokens: tokens, position: position, prefix: prefix)
145
+ when '/model' then model(tokens: tokens, position: position, prefix: prefix)
146
+ when '/mcp' then mcp(tokens: tokens, position: position, prefix: prefix)
147
+ when '/swarm' then swarm(tokens: tokens, position: position, prefix: prefix, controller: opts[:swarm])
148
+ else [[], 'no further parameters']
149
+ end
150
+ end
151
+
152
+ private_class_method def self.filter(opts = {})
153
+ prefix = opts[:prefix].to_s
154
+ Array(opts[:values]).map(&:to_s).uniq.select { |value| prefix.empty? || value.start_with?(prefix) }
155
+ end
156
+
157
+ private_class_method def self.cron(opts = {})
158
+ tokens = opts[:tokens]
159
+ position = opts[:position]
160
+ prefix = opts[:prefix]
161
+ return [filter(values: %w[list create run remove], prefix: prefix), 'cron action'] if position == 1
162
+ return [[], 'cron schedule, for example 0 * * * *'] if tokens[1][:text] == 'create' && position == 2
163
+ return [[], 'prompt text'] if tokens[1][:text] == 'create'
164
+
165
+ [filter(values: safe { PWN::Cron.list.keys }, prefix: prefix), 'job id']
166
+ end
167
+
168
+ private_class_method def self.memory(opts = {})
169
+ tokens = opts[:tokens]
170
+ position = opts[:position]
171
+ prefix = opts[:prefix]
172
+ return [filter(values: %w[list recall remember forget clear], prefix: prefix), 'memory action'] if position == 1
173
+ return [[], 'memory value'] if tokens[1][:text] == 'remember' && position > 2
174
+
175
+ [filter(values: safe { PWN::Memory.load.keys }, prefix: prefix), 'memory key']
176
+ end
177
+
178
+ private_class_method def self.sessions(opts = {})
179
+ tokens = opts[:tokens]
180
+ position = opts[:position]
181
+ prefix = opts[:prefix]
182
+ return [filter(values: %w[list resume delete stats], prefix: prefix), 'session action'] if position == 1
183
+ return [[], 'no further parameters'] unless %w[resume delete].include?(tokens[1][:text])
184
+
185
+ ids = safe { PWN::Sessions.list.map { |row| row[:id] || row['id'] } }
186
+ [filter(values: ids, prefix: prefix), 'session id']
187
+ end
188
+
189
+ private_class_method def self.skills(opts = {})
190
+ position = opts[:position]
191
+ prefix = opts[:prefix]
192
+ return [filter(values: %w[list recall], prefix: prefix), 'skills action'] if position == 1
193
+
194
+ names = safe { PWN::Skills.keys } if PWN.const_defined?(:Skills)
195
+ [filter(values: names, prefix: prefix), 'skill name']
196
+ end
197
+
198
+ private_class_method def self.learning(opts = {})
199
+ tokens = opts[:tokens]
200
+ position = opts[:position]
201
+ prefix = opts[:prefix]
202
+ return [filter(values: %w[list requeue], prefix: prefix), 'learning action'] if position == 1
203
+ return [filter(values: %w[--conflicted conflicted], prefix: prefix), 'list filter'] if tokens[1][:text] == 'list'
204
+
205
+ [[], 'no further parameters']
206
+ end
207
+
208
+ private_class_method def self.model(opts = {})
209
+ tokens = opts[:tokens]
210
+ position = opts[:position]
211
+ prefix = opts[:prefix]
212
+ engines = safe { REPL.pwn_ai_engines }
213
+ return [filter(values: ['list'] + engines, prefix: prefix), 'engine or list'] if position == 1
214
+ return [filter(values: %w[llms], prefix: prefix), 'remote catalog; not fetched while typing'] if tokens[1][:text] == 'list'
215
+ return [[], 'model id; supported models prompt for effort after Enter (no catalog while typing)'] if position >= 2
216
+
217
+ current = safe { [REPL.pwn_ai_engine_model(engine: tokens[1][:text])] }
218
+ [filter(values: current, prefix: prefix), 'configured model']
219
+ end
220
+
221
+ private_class_method def self.mcp(opts = {})
222
+ tokens = opts[:tokens]
223
+ position = opts[:position]
224
+ prefix = opts[:prefix]
225
+ actions = %w[list backends use current connect disconnect ping tools call status help]
226
+ backends = safe { PWN::AI::MCP.backends.map { |row| row[:name] } }
227
+ return [filter(values: actions + backends, prefix: prefix), 'mcp action or backend'] if position == 1
228
+ return [filter(values: backends, prefix: prefix), 'backend'] if %w[use connect disconnect].include?(tokens[1][:text])
229
+ return [filter(values: tool_names(backends: backends), prefix: prefix), 'tool name'] if tokens[1][:text] == 'call'
230
+
231
+ [[], 'mcp argument']
232
+ end
233
+
234
+ private_class_method def self.tool_names(opts = {})
235
+ Array(opts[:backends]).flat_map do |name|
236
+ row = PWN::AI::MCP.backends.find { |backend| backend[:name].to_s == name.to_s }
237
+ Array(row && row[:tools])
238
+ end
239
+ rescue StandardError
240
+ []
241
+ end
242
+
243
+ private_class_method def self.swarm(opts = {})
244
+ tokens = opts[:tokens]
245
+ position = opts[:position]
246
+ prefix = opts[:prefix]
247
+ controller = opts[:controller]
248
+ return [filter(values: SWARM, prefix: prefix), 'swarm command'] if position == 1
249
+
250
+ context = controller.respond_to?(:completion_context) ? controller.completion_context : {}
251
+ action = tokens[1][:text]
252
+ jobs = Array(context[:jobs]).map { |job| job[:id] || job['id'] }
253
+ agents = Array(context[:agents])
254
+ case action
255
+ when 'status', 'steer', 'cancel' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
256
+ when 'use' then [filter(values: existing_swarms, prefix: prefix), 'swarm id']
257
+ when 'spawn' then [[], position == 2 ? 'persona name' : 'role text']
258
+ when 'retire', 'ask' then [filter(values: agents, prefix: prefix), 'persona']
259
+ when 'debate' then [filter(values: agents, prefix: prefix), 'comma-separated personas, then topic']
260
+ when 'tail' then [[], 'message limit']
261
+ else [[], 'free text']
262
+ end
263
+ end
264
+
265
+ private_class_method def self.existing_swarms
266
+ root = PWN::AI::Agent::Swarm::SWARM_ROOT
267
+ return [] unless Dir.exist?(root)
268
+
269
+ Dir.children(root)
270
+ rescue StandardError
271
+ []
272
+ end
273
+
274
+ private_class_method def self.safe
275
+ Array(yield).map(&:to_s)
276
+ rescue StandardError
277
+ []
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PWN
4
+ module Plugins
5
+ module REPL
6
+ # Provider-reported token totals and explicitly configured USD estimates.
7
+ module AIConsoleUsage
8
+ # Accumulate one observer event per provider completion.
9
+ class Tracker
10
+ def initialize
11
+ @lock = Mutex.new
12
+ @input = 0
13
+ @output = 0
14
+ @cached = 0
15
+ @cache_write = 0
16
+ @calls = 0
17
+ @latest = 0
18
+ @priced = 0.0
19
+ @priced_calls = 0
20
+ @unpriced_calls = 0
21
+ end
22
+
23
+ def record(opts = {})
24
+ usage = AIConsoleUsage.normalize(opts)
25
+ price = AIConsoleUsage.estimate(opts.merge(usage: usage))
26
+ @lock.synchronize do
27
+ @input += usage[:input_tokens]
28
+ @output += usage[:output_tokens]
29
+ @cached += usage[:cached_tokens]
30
+ @cache_write += usage[:cache_write_tokens]
31
+ @calls += 1
32
+ @latest = usage[:total_tokens]
33
+ if price
34
+ @priced += price
35
+ @priced_calls += 1
36
+ else
37
+ @unpriced_calls += 1
38
+ end
39
+ end
40
+ snapshot
41
+ end
42
+
43
+ def snapshot
44
+ @lock.synchronize do
45
+ status = if @calls.zero?
46
+ 'no provider usage yet'
47
+ elsif @unpriced_calls.zero?
48
+ 'estimated from configured USD rates'
49
+ elsif @priced_calls.zero?
50
+ 'unavailable: no configured USD rate'
51
+ else
52
+ "partial: #{@unpriced_calls} call(s) have no configured rate"
53
+ end
54
+ {
55
+ input_tokens: @input, output_tokens: @output, cached_tokens: @cached,
56
+ cache_write_tokens: @cache_write, total_tokens: @input + @output,
57
+ latest_context_tokens: @latest, calls: @calls,
58
+ estimated_cost_usd: @priced_calls.zero? ? nil : @priced,
59
+ cost_status: status
60
+ }
61
+ end
62
+ end
63
+ end
64
+
65
+ # Normalize a provider usage hash without double-counting cached tokens.
66
+ public_class_method def self.normalize(opts = {})
67
+ raw = opts[:usage].is_a?(Hash) ? opts[:usage] : opts
68
+ details = raw[:input_tokens_details] || raw[:prompt_tokens_details] || {}
69
+ cached = integer(value: details[:cached_tokens] || details[:cache_read_input_tokens] || raw[:cache_read_input_tokens])
70
+ written = integer(value: details[:cache_creation_input_tokens] || raw[:cache_creation_input_tokens])
71
+ input = integer(value: raw[:input_tokens] || raw[:prompt_tokens])
72
+ output = integer(value: raw[:output_tokens] || raw[:completion_tokens])
73
+ reasoning = integer(value: (raw[:output_tokens_details] || {})[:reasoning_tokens] || raw[:reasoning_tokens])
74
+ output += reasoning if raw[:completion_tokens].nil? && raw[:output_tokens].nil?
75
+ reported = raw[:total_tokens]
76
+ total = reported.nil? ? input + output : integer(value: reported)
77
+ { input_tokens: input, output_tokens: output, cached_tokens: cached, cache_write_tokens: written, total_tokens: total }
78
+ end
79
+
80
+ # Price only when every used component has an explicit finite USD rate.
81
+ public_class_method def self.estimate(opts = {})
82
+ usage = opts[:usage] || normalize(opts)
83
+ rates = opts[:rates] || rates_for(engine: opts[:engine], model: opts[:model])
84
+ return nil unless rates.is_a?(Hash)
85
+
86
+ components = [
87
+ [usage[:input_tokens] - usage[:cached_tokens] - usage[:cache_write_tokens], rates[:input_per_million]],
88
+ [usage[:cached_tokens], rates[:cache_read_per_million] || rates[:input_per_million]],
89
+ [usage[:cache_write_tokens], rates[:cache_write_per_million]],
90
+ [usage[:output_tokens], rates[:output_per_million]]
91
+ ]
92
+ return nil if components.any? { |tokens, rate| tokens.to_i.positive? && !rate?(value: rate) }
93
+ return nil if components.any? { |tokens, _rate| tokens.to_i.negative? }
94
+
95
+ components.sum { |tokens, rate| tokens.to_i * rate.to_f / 1_000_000.0 }
96
+ end
97
+
98
+ public_class_method def self.authors
99
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
100
+ end
101
+
102
+ public_class_method def self.help
103
+ puts "USAGE:
104
+ # Normalize one provider usage report. Cached tokens are included in input, not added again.
105
+ #{self}.normalize(
106
+ usage: 'required - provider usage hash'
107
+ )
108
+
109
+ # Estimate USD only from explicit nonnegative rates; nil means unavailable, never zero.
110
+ #{self}.estimate(
111
+ usage: 'required - normalized or provider usage hash',
112
+ rates: 'optional - input_per_million, output_per_million, cache_read_per_million, cache_write_per_million',
113
+ engine: 'optional - provider used to read configured rates',
114
+ model: 'optional - model key under that provider pricing hash'
115
+ )
116
+
117
+ # Return module authors.
118
+ #{self}.authors
119
+ "
120
+ end
121
+
122
+ private_class_method def self.integer(opts = {})
123
+ opts[:value].to_i
124
+ end
125
+
126
+ private_class_method def self.rate?(opts = {})
127
+ value = opts[:value]
128
+ value.is_a?(Numeric) && value.finite? && value >= 0
129
+ end
130
+
131
+ private_class_method def self.rates_for(opts = {})
132
+ engine = opts[:engine].to_s.to_sym
133
+ model = opts[:model].to_s
134
+ pricing = PWN::Env.dig(:ai, engine, :pricing) if defined?(PWN::Env)
135
+ return nil unless pricing.is_a?(Hash)
136
+
137
+ pricing[model.to_sym] || pricing[model]
138
+ rescue StandardError
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end