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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL::AIConsoleUsage do
6
+ it 'counts provider tokens once and estimates only configured USD rates' do
7
+ tracker = described_class::Tracker.new
8
+ tracker.record(engine: :openai, model: 'priced', usage: { input_tokens: 1_000_000, output_tokens: 1_000, input_tokens_details: { cached_tokens: 100 } }, rates: { input_per_million: 2, output_per_million: 8, cache_read_per_million: 1 })
9
+ priced = tracker.snapshot
10
+ expect(priced[:input_tokens]).to eq(1_000_000)
11
+ expect(priced[:cached_tokens]).to eq(100)
12
+ expect(priced[:total_tokens]).to eq(1_001_000)
13
+ expect(priced[:estimated_cost_usd]).to be_within(0.001).of((2 * 0.9999) + 0.0001 + 0.008)
14
+ tracker.record(engine: :openai, model: 'unknown', usage: { prompt_tokens: 10, completion_tokens: 5 })
15
+ mixed = tracker.snapshot
16
+ expect(mixed[:calls]).to eq(2)
17
+ expect(mixed[:cost_status]).to include('partial')
18
+ expect(described_class.estimate(usage: { input_tokens: 5 }, rates: nil)).to be_nil
19
+ end
20
+
21
+ it 'receives one Loop completion even when provider config is frozen' do
22
+ tracker = described_class::Tracker.new
23
+ Thread.current[:pwn_usage_observer] = tracker.method(:record)
24
+ PWN::AI::Agent::Loop.send(:publish_usage, response: { model: 'offline', usage: { input_tokens: 3, output_tokens: 4 } }, engine: :missing)
25
+ expect(tracker.snapshot[:total_tokens]).to eq(7)
26
+ ensure
27
+ Thread.current[:pwn_usage_observer] = nil
28
+ end
29
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe PWN::Plugins::REPL::AISwarm::Controller do
6
+ include_context 'pwn tmp sandbox'
7
+
8
+ it 'returns visible errors for malformed commands and synchronous backend failures' do
9
+ controller = described_class.new(session_id: 'fixture')
10
+ expect(controller.execute(line: '/swarm ask scout "unfinished')[:ok]).to be(false)
11
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_raise(IOError, 'fixture storage unavailable')
12
+ expect(controller.execute(line: '/swarm create fixture')).to include(ok: false, error: 'IOError: fixture storage unavailable')
13
+ ensure
14
+ controller&.close
15
+ end
16
+
17
+ it 'exposes roster metadata and retains outcomes without reviving finished jobs' do
18
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect evidence', engine: 'ollama', model: 'fixture' })
19
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
20
+ allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Observed fixture result')
21
+ controller = described_class.new(session_id: 'fixture')
22
+ expect(controller.roster).to include(include(name: 'scout', role: 'Inspect evidence', engine: 'ollama', model: 'fixture'))
23
+ queued = controller.execute(line: '/swarm ask scout inspect')
24
+ controller.send(:workers).each(&:join)
25
+ expect(controller.snapshot[:jobs].first).to include(state: 'completed', result: 'Observed fixture result')
26
+ expect(controller.execute(line: "/swarm steer #{queued[:job_id]} again")[:ok]).to be(false)
27
+ expect(controller.execute(line: "/swarm cancel #{queued[:job_id]}")[:ok]).to be(false)
28
+ expect(controller.busy?).to be(false)
29
+ ensure
30
+ controller&.close
31
+ end
32
+
33
+ it 'owns model interrupts on the job thread and copies request-local routing' do
34
+ entered = Queue.new
35
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
36
+ Thread.current[:pwn_workspace_fixture] = :copied
37
+ allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
38
+ control = opts[:steering]
39
+ entered << [control.instance_variable_get(:@owner), Thread.current, Thread.current[:pwn_workspace_fixture], Thread.current[:pwn_steering_input]]
40
+ { reply: 'done' }
41
+ end
42
+ controller = described_class.new(session_id: 'fixture')
43
+ controller.execute(line: '/swarm ask scout inspect')
44
+ owner, worker, local, marker = entered.pop
45
+ expect(owner).to eq(worker)
46
+ expect(local).to eq(:copied)
47
+ expect(marker).to be_a(PWN::Plugins::REPL::AISwarm::JobControl)
48
+ ensure
49
+ controller&.close
50
+ Thread.current[:pwn_workspace_fixture] = nil
51
+ end
52
+
53
+ it 'passes prior speakers to debate, preserves literal flag-like missions, and retains failures' do
54
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
55
+ calls = []
56
+ allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
57
+ calls << opts
58
+ raise IOError, 'fixture unavailable' if opts[:name] == 'broken'
59
+
60
+ { reply: "reply from #{opts[:name]}" }
61
+ end
62
+ controller = described_class.new(session_id: 'fixture')
63
+ controller.execute(line: '/swarm debate scout,critic "--literal mission"')
64
+ controller.send(:workers).each(&:join)
65
+ expect(calls.first[:request]).to eq('--literal mission')
66
+ expect(calls.last[:request]).to include('reply from scout', '--literal mission')
67
+ controller.execute(line: '/swarm ask broken inspect')
68
+ controller.send(:workers).each(&:join)
69
+ expect(controller.snapshot[:jobs].last).to include(state: 'failed', error: 'IOError: fixture unavailable')
70
+ ensure
71
+ controller&.close
72
+ end
73
+
74
+ it 'cancels only its owned blocked model and cannot switch swarms while it runs' do
75
+ entered = Queue.new
76
+ foreign = Thread.new { sleep }
77
+ allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
78
+ allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
79
+ opts[:steering].model(messages: []) do
80
+ entered << true
81
+ Queue.new.pop
82
+ end
83
+ opts[:steering].checkpoint(messages: [])
84
+ end
85
+ controller = described_class.new(session_id: 'fixture')
86
+ job = controller.execute(line: '/swarm ask scout inspect')
87
+ entered.pop
88
+ expect(controller.execute(line: '/swarm create other')[:ok]).to be(false)
89
+ expect(controller.execute(line: '/swarm use other')[:ok]).to be(false)
90
+ controller.execute(line: "/swarm cancel #{job[:job_id]}")
91
+ expect(controller.send(:workers).first.join(2)).not_to be_nil
92
+ expect(controller.snapshot[:jobs].first).to include(state: 'cancelled')
93
+ expect(foreign).to be_alive
94
+ controller.close
95
+ expect(controller.execute(line: '/swarm ask scout again')[:ok]).to be(false)
96
+ ensure
97
+ controller&.close
98
+ foreign&.kill&.join
99
+ end
100
+
101
+ it 'runs, steers, and cancels a persona through the existing Swarm API' do
102
+ entered = Queue.new
103
+ allow(PWN::AI::Agent::Swarm).to receive(:ask) do |opts|
104
+ entered << opts[:steering]
105
+ opts[:on_tool].call('shell', { command: 'true' }, '{"success":true}')
106
+ sleep 0.01 until opts[:steering].instance_variable_get(:@stopping)
107
+ raise PWN::Plugins::REPL::AISwarm::JobControl::Stopped, 'cancelled'
108
+ end
109
+ events = Queue.new
110
+ controller = described_class.new(session_id: 'console-session')
111
+ allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'scout' })
112
+ allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
113
+ controller.execute(line: '/swarm spawn scout inspect evidence')
114
+ queued = controller.execute(line: '/swarm ask scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
115
+ steering = entered.pop
116
+ expect(queued[:ok]).to eq(true)
117
+ expect(controller.snapshot[:jobs].first[:state]).to eq('running')
118
+ controller.execute(line: "/swarm steer #{queued[:job_id]} stop the scan")
119
+ expect(steering.instance_variable_get(:@pending)).to include('stop the scan')
120
+ controller.execute(line: "/swarm cancel #{queued[:job_id]}")
121
+ sleep 0.05
122
+ expect(controller.snapshot[:jobs].first[:state]).to eq('cancelled')
123
+ expect(events.pop[0]).to eq(:tool)
124
+ ensure
125
+ controller&.close
126
+ end
127
+ end
@@ -189,11 +189,13 @@ describe 'pwn-ai debug final answer placement' do
189
189
  end
190
190
 
191
191
  it 'prints the green final after Loop.run and does not pp the session after it' do
192
- expect(hook).to match(/Loop\.run\(/)
192
+ expect(hook).to include('PWN::Plugins::REPL.pwn_ai_run_steerable(')
193
+ wrapper = File.read(PWN::Plugins::REPL.method(:pwn_ai_run_steerable).source_location.first)
194
+ expect(wrapper).to include('control.with_reader { PWN::AI::Agent::Loop.run(')
193
195
  expect(hook).to match(/\\e\[32m#\{final\}/)
194
196
  expect(hook).not_to include('[0, 280]')
195
197
  expect(hook).not_to include('[0, 700]')
196
- native_start = hook.index('final = PWN::AI::Agent::Loop.run')
198
+ native_start = hook.index('final = PWN::Plugins::REPL.pwn_ai_run_steerable')
197
199
  native_stop = hook.index("request.replace('nil')", native_start)
198
200
  native = hook[native_start..native_stop]
199
201
  expect(native).not_to include('\001')
@@ -208,7 +210,7 @@ describe 'pwn-ai debug final answer placement' do
208
210
  end
209
211
 
210
212
  it 'does not fall through to regex-ReAct after a native Loop.run error' do
211
- native_start = hook.index('final = PWN::AI::Agent::Loop.run')
213
+ native_start = hook.index('final = PWN::Plugins::REPL.pwn_ai_run_steerable')
212
214
  rescue_at = hook.index('rescue StandardError', native_start)
213
215
  expect(rescue_at).not_to be_nil
214
216
  chunk = hook[rescue_at, 900]
@@ -3,6 +3,162 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
6
+ describe 'reasoning selection' do
7
+ include_context 'pwn tmp sandbox'
8
+
9
+ it 'persists system role before updating Env and preserves unrelated vault fields' do
10
+ previous_driver_opts = PWN::Env[:driver_opts]
11
+ path = File.join(@tmp, 'role.yaml')
12
+ key = Base64.strict_encode64('k' * 32)
13
+ iv = Base64.strict_encode64('i' * 16)
14
+ original = { ai: { active: 'openai', openai: { model: 'old', system_role_content: 'original', key: 'fixture' }, ollama: { model: 'keep' } }, unrelated: 'keep' }
15
+ PWN::Env[:ai] = Marshal.load(Marshal.dump(original[:ai]))
16
+ File.write(path, YAML.dump(original))
17
+ File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
18
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
19
+ PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
20
+ expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: "new role\nsecond line")).to eq(:saved)
21
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq("new role\nsecond line")
22
+ expect(File.read(path)).not_to include('new role')
23
+ PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
24
+ original[:ai][:openai][:system_role_content] = "new role\nsecond line"
25
+ expect(YAML.load_file(path, symbolize_names: true)).to eq(original)
26
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
27
+ expect(described_class.pwn_ai_apply_system_role(engine: 'openai', content: '')).to eq(:saved)
28
+ original[:ai][:openai][:system_role_content] = ''
29
+ expect(PWN::Plugins::Vault.dump(file: path, key: key, iv: iv)).to eq(original)
30
+ ensure
31
+ PWN::Env[:driver_opts] = previous_driver_opts
32
+ end
33
+
34
+ it 'leaves ciphertext and Env untouched when role encryption fails' do
35
+ previous_driver_opts = PWN::Env[:driver_opts]
36
+ path = File.join(@tmp, 'role.yaml')
37
+ key = Base64.strict_encode64('k' * 32)
38
+ iv = Base64.strict_encode64('i' * 16)
39
+ PWN::Env[:ai] = { active: 'openai', openai: { model: 'old', system_role_content: 'original' } }
40
+ File.write(path, YAML.dump(ai: PWN::Env[:ai]))
41
+ File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
42
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
43
+ ciphertext = File.binread(path)
44
+ PWN::Env[:driver_opts] = { pwn_env_path: path, pwn_dec_path: "#{path}.decryptor" }
45
+ allow(PWN::Plugins::Vault).to receive(:encrypt).and_raise(IOError, 'fixture encryption failure')
46
+ expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
47
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
48
+ expect(File.binread(path)).to eq(ciphertext)
49
+ expect(Dir.glob(File.join(@tmp, '.pwn-ai-selection*'))).to be_empty
50
+ ensure
51
+ PWN::Env[:driver_opts] = previous_driver_opts
52
+ end
53
+
54
+ it 'rejects role saves without a decryptor or after switching the active engine' do
55
+ previous_driver_opts = PWN::Env[:driver_opts]
56
+ PWN::Env[:driver_opts] = { pwn_env_path: File.join(@tmp, 'missing.yaml'), pwn_dec_path: File.join(@tmp, 'missing.decryptor') }
57
+ PWN::Env[:ai] = { active: 'openai', openai: { system_role_content: 'original' } }
58
+ expect { described_class.pwn_ai_apply_system_role(engine: 'openai', content: 'unsaved') }.to raise_error(/not saved/)
59
+ expect(PWN::Env.dig(:ai, :openai, :system_role_content)).to eq('original')
60
+ expect(described_class).not_to receive(:persist_ai_selection)
61
+ expect { described_class.pwn_ai_apply_system_role(engine: 'ollama', content: 'unsaved') }.to raise_error(/Active engine changed/)
62
+ ensure
63
+ PWN::Env[:driver_opts] = previous_driver_opts
64
+ end
65
+
66
+ it 'forwards explicit none through the agent rather than silently using provider defaults' do
67
+ PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-5.5', reasoning_effort: 'none' } }
68
+ expect(PWN::AI::OpenAI).to receive(:chat_with_tools).with(hash_including(reasoning_effort: 'none', think: false)).and_return(choices: [{ message: { role: 'assistant', content: 'fixture' } }])
69
+ PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [], tools: [])
70
+ end
71
+
72
+ it 'forwards OpenAI effort through the agent into Chat Completions and Responses payloads' do
73
+ PWN::Env[:ai] = { active: 'openai', openai: { model: 'gpt-5', reasoning_effort: 'high' } }
74
+ expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call).with(hash_including(rest_call: 'chat/completions', http_body: hash_including(reasoning_effort: 'high'))).and_return({ choices: [{ message: { role: 'assistant', content: 'offline fixture' } }] }.to_json)
75
+ PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [{ role: 'user', content: 'fixture' }], tools: [])
76
+ PWN::Env[:ai][:openai][:model] = 'gpt-6-astra'
77
+ expect(PWN::AI::OpenAI).to receive(:open_ai_rest_call).with(hash_including(rest_call: 'responses', http_body: hash_including(reasoning: { effort: 'high', summary: 'auto' }))).and_return({ output: [{ type: 'message', content: [{ type: 'output_text', text: 'offline fixture' }] }] }.to_json)
78
+ PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :openai, messages: [{ role: 'user', content: 'fixture' }], tools: [])
79
+ end
80
+
81
+ it 'forwards the Grok effort through the agent into the HTTP payload' do
82
+ PWN::Env[:ai] = { active: 'grok', grok: { model: 'grok-4.6', reasoning_effort: 'high' } }
83
+ expect(PWN::AI::Grok).to receive(:grok_rest_call).with(hash_including(http_body: hash_including(model: 'grok-4.6', reasoning_effort: 'high'))).and_return({ choices: [{ message: { role: 'assistant', content: 'offline fixture' } }] }.to_json)
84
+ PWN::AI::Agent::Loop.send(:invoke_provider_chat, engine: :grok, messages: [{ role: 'user', content: 'fixture' }], tools: [])
85
+ end
86
+
87
+ it 'uses Enter for the current effort and permits cancellation in the legacy prompt' do
88
+ input = StringIO.new("\nq\n")
89
+ allow(input).to receive(:tty?).and_return(true)
90
+ previous = $stdin
91
+ $stdin = input
92
+ selection = { engine: 'grok', model: 'grok-4.6', efforts: %w[low medium high], default: 'high' }
93
+ expect(described_class.pwn_ai_prompt_reasoning(selection: selection)).to eq('high')
94
+ expect(described_class.pwn_ai_prompt_reasoning(selection: selection)).to be_nil
95
+ ensure
96
+ $stdin = previous
97
+ end
98
+
99
+ it 'does not offer effort controls for unsupported models' do
100
+ expect(described_class.pwn_ai_reasoning_selection(engine: 'grok', model: 'grok-4')[:efforts]).to eq([])
101
+ expect(described_class.pwn_ai_reasoning_selection(engine: 'openai', model: 'gpt-4o')[:efforts]).to eq([])
102
+ %w[anthropic gemini ollama openwebui].each do |engine|
103
+ expect(described_class.pwn_ai_reasoning_selection(engine: engine, model: 'fixture')[:efforts]).to eq([])
104
+ end
105
+ end
106
+
107
+ it 'cancels without changing any configuration or persisting' do
108
+ PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old' }, grok: { model: 'old', reasoning_effort: 'high' } }
109
+ before = Marshal.dump(PWN::Env[:ai])
110
+ expect(described_class).not_to receive(:persist_ai_selection)
111
+ described_class.pwn_ai_run_model(args: %w[grok grok-4.6], prompt: ->(_selection) {})
112
+ expect(Marshal.dump(PWN::Env[:ai])).to eq(before)
113
+ end
114
+
115
+ it 'skips effort prompts and catalogs for engines without effort support' do
116
+ PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old', think: false } }
117
+ allow(described_class).to receive(:persist_ai_selection).and_return(false)
118
+ expect(PWN::AI::OpenAI).not_to receive(:get_models)
119
+ prompt = double('prompt')
120
+ expect(prompt).not_to receive(:call)
121
+ described_class.pwn_ai_run_model(args: %w[ollama fixture], prompt: prompt)
122
+ expect(PWN::Env[:ai][:ollama]).to eq(model: 'fixture', think: false)
123
+ end
124
+
125
+ it 'merges reasoning and model into an encrypted sandbox vault without replacing other values' do
126
+ require 'yaml'
127
+ previous_driver_opts = PWN::Env[:driver_opts]
128
+ PWN::Env[:driver_opts] = previous_driver_opts.to_h.dup
129
+ path = File.join(@tmp, 'selection.yaml')
130
+ key = Base64.strict_encode64('k' * 32)
131
+ iv = Base64.strict_encode64('i' * 16)
132
+ original = { ai: { active: 'ollama', openai: { model: 'old', key: 'fixture', reasoning_effort: 'low', oauth: { account_id: 'fixture' } }, ollama: { model: 'keep' } }, unrelated: 'keep' }
133
+ File.write(path, YAML.dump(original))
134
+ File.write("#{path}.decryptor", YAML.dump(key: key, iv: iv))
135
+ PWN::Plugins::Vault.encrypt(file: path, key: key, iv: iv)
136
+ PWN::Env[:driver_opts][:pwn_env_path] = path
137
+ PWN::Env[:driver_opts][:pwn_dec_path] = "#{path}.decryptor"
138
+ expect(described_class.persist_ai_selection(engine: 'openai', model: 'gpt-6-astra', reasoning_effort: 'high')).to be(true)
139
+ expect(File.read(path)).not_to include('reasoning_effort')
140
+ PWN::Plugins::Vault.decrypt(file: path, key: key, iv: iv)
141
+ original[:ai][:active] = 'openai'
142
+ original[:ai][:openai].merge!(model: 'gpt-6-astra', reasoning_effort: 'high')
143
+ expect(YAML.load_file(path, symbolize_names: true)).to eq(original)
144
+ ensure
145
+ PWN::Env[:driver_opts] = previous_driver_opts
146
+ end
147
+
148
+ it 'offers catalog efforts before applying either model or effort' do
149
+ PWN::Env[:ai] = { active: 'ollama', ollama: { model: 'old' }, openai: { model: 'old', reasoning_effort: 'high' } }
150
+ allow(PWN::AI::OpenAI).to receive(:get_models).and_return(data: [{ id: 'gpt-6-astra', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], default_reasoning_level: 'low' }])
151
+ allow(described_class).to receive(:persist_ai_selection).and_return(false)
152
+ described_class.pwn_ai_run_model(args: %w[openai gpt-6-astra], prompt: lambda { |selection|
153
+ expect(PWN::Env[:ai][:active]).to eq('ollama')
154
+ expect(selection).to include(efforts: %w[low high], default: 'high')
155
+ 'low'
156
+ })
157
+ expect(PWN::Env[:ai]).to include(active: 'openai', openai: { model: 'gpt-6-astra', reasoning_effort: 'low' })
158
+ expect(described_class).to have_received(:persist_ai_selection).with(engine: 'openai', model: 'gpt-6-astra', reasoning_effort: 'low')
159
+ end
160
+ end
161
+
6
162
  def command_src
7
163
  [
8
164
  described_class,
@@ -193,6 +349,16 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
193
349
  end
194
350
 
195
351
  describe 'pwn-ai completion menus' do
352
+ it 'handles idle steering and empty steering locally without eval or a model call' do
353
+ pry = double('local pry')
354
+ expect(pry).not_to receive(:eval)
355
+ expect(PWN::AI::Agent::Loop).not_to receive(:run)
356
+ expect { expect(described_class.pwn_ai_dispatch_slash!(request: '/steer', pry: pry)).to be(true) }.to output(%r{Usage: /steer <instruction>}).to_stdout
357
+ expect { expect(described_class.pwn_ai_dispatch_slash!(request: '/steer stop', pry: pry)).to be(true) }.to output(/no active request/).to_stdout
358
+ expect(described_class.pwn_ai_complete(target: '/st', line: '/st')).to include('/steer')
359
+ expect { described_class.pwn_ai_dispatch_slash!(request: '/help', pry: pry) }.to output(%r{/steer <instruction>}).to_stdout
360
+ end
361
+
196
362
  it 'classifies leading slash as command, other slash as path, else ruby' do
197
363
  expect(described_class).to respond_to(:pwn_ai_complete_kind)
198
364
  expect(described_class.pwn_ai_complete_kind(line: '/cron')).to eq(:command)
@@ -207,7 +373,7 @@ describe PWN::Plugins::REPL do # rubocop:disable Metrics/BlockLength
207
373
  hits = described_class.pwn_ai_complete(target: '/sk', line: '/sk')
208
374
  expect(hits).to include('/skills')
209
375
  hits = described_class.pwn_ai_complete(target: '/', line: '/')
210
- %w[/cron /skills /sessions /memory /debug /trace /back /help /model /learning /mcp].each do |cmd|
376
+ %w[/cron /skills /sessions /memory /debug /trace /back /help /model /learning /mcp /system-role].each do |cmd|
211
377
  expect(hits).to include(cmd)
212
378
  end
213
379
  hits = described_class.pwn_ai_complete(target: 'li', line: '/cron li')