pwn 0.5.666 → 0.5.668

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.
data/lib/pwn/ai.rb CHANGED
@@ -11,6 +11,7 @@ module PWN
11
11
  autoload :Grok, 'pwn/ai/grok'
12
12
  autoload :Ollama, 'pwn/ai/ollama'
13
13
  autoload :OpenAI, 'pwn/ai/open_ai'
14
+ autoload :OpenWebUI, 'pwn/ai/open_web_ui'
14
15
  autoload :RedTeam, 'pwn/ai/red_team'
15
16
 
16
17
  # Display a List of Every PWN::AI Module
data/lib/pwn/config.rb CHANGED
@@ -67,12 +67,14 @@ module PWN
67
67
  }
68
68
  },
69
69
  ollama: {
70
- base_uri: 'required - Base URI for Open WebUI - e.g. https://ollama.local',
71
- key: 'required - Open WebUI API Key Under Settings >> Account >> JWT Token',
72
- model: 'required - Ollama model to use',
70
+ # Direct Ollama server (stock: http://127.0.0.1:11434). No key required.
71
+ base_uri: 'optional - Base URI for Ollama server (default http://127.0.0.1:11434)',
72
+ key: 'optional - bearer token only if a reverse-proxy sits in front of Ollama (stock ollama needs none)',
73
+ model: 'required - Ollama model tag to use',
73
74
  embed_model: 'optional - embedding model for PWN::MemoryIndex (default nomic-embed-text)',
74
75
  system_role_content: 'You are an ethically hacking Ollama agent.',
75
76
  temp: 'optional - Ollama temperature',
77
+ tool_temp: 0.1, # lower temperature when tools are present (chat_with_tools)
76
78
  num_ctx: 32_768,
77
79
  # Cap decode length so thinking models cannot stream forever
78
80
  # (Net::HTTP read_timeout only fires on idle gaps between chunks).
@@ -85,6 +87,22 @@ module PWN
85
87
  result_max: 4_000, # tool-result cap for local models (frontier keeps Result::DEFAULT_MAX)
86
88
  max_prompt_length: 32_000
87
89
  },
90
+ openwebui: {
91
+ # Open WebUI gateway in front of one or more model backends.
92
+ base_uri: 'required - Base URI for Open WebUI - e.g. https://openwebui.local',
93
+ key: 'required - Open WebUI API Key Under Settings >> Account >> JWT Token',
94
+ model: 'required - model id/tag exposed by Open WebUI',
95
+ embed_model: 'optional - embedding model for PWN::MemoryIndex when ollama direct is unset (default nomic-embed-text)',
96
+ system_role_content: 'You are an ethically hacking Open WebUI agent.',
97
+ temp: 'optional - Open WebUI temperature',
98
+ tool_temp: 0.1, # lower temperature when tools are present (chat_with_tools)
99
+ num_ctx: 32_768,
100
+ num_predict: 4_096,
101
+ keep_alive: '30m',
102
+ prompt_budget: { memory: 6, metrics: 3, mistakes: 3, learning: 2, extro: false },
103
+ result_max: 4_000,
104
+ max_prompt_length: 32_000
105
+ },
88
106
  anthropic: {
89
107
  base_uri: 'optional - Base URI for Anthropic - Use private base OR defaults to https://api.anthropic.com/v1',
90
108
  key: 'required - Anthropic API Key',
@@ -135,8 +153,8 @@ module PWN
135
153
  # (host/repo/env probes only — no toolchain/GUI/net side-effects)
136
154
  auto_extrospect: true,
137
155
  # engine-agnostic scaffolding (defaults tuned for local models)
138
- plan_first: nil, # nil = auto (true when :active == :ollama)
139
- tool_router: nil, # nil = auto (true when :active == :ollama) — cuts ~11k→~3k schema tokens
156
+ plan_first: nil, # nil = auto (true when :active is :ollama or :openwebui)
157
+ tool_router: nil, # nil = auto (true when :active is local :ollama/:openwebui) — cuts ~11k→~3k schema tokens
140
158
  escalation_persona: 'escalator', # Swarm persona for frontier corrective hints when a local model is stuck
141
159
  # sample E3 verify_as_reward: true|false|nil(auto: ~10% local / always frontier when CLAIM_RX hits)
142
160
  verify_as_reward: nil,
@@ -360,37 +378,33 @@ module PWN
360
378
  target
361
379
  end
362
380
 
363
- # Inject Ollama/RL scaffolding defaults into a vault-loaded env hash
364
- # before freeze. Never overwrites operator choices already in the vault.
381
+ # Inject AI engine + agent scaffolding defaults into a vault-loaded env
382
+ # hash before freeze. Never overwrites operator choices already in the vault.
383
+ # Also seeds a missing engine block when ai.active points at a known engine
384
+ # that is absent from older vaults (e.g. active: ollama with no ai.ollama
385
+ # hash) so refresh_env does not NoMethodError on env[:ai][engine][:key].
365
386
  private_class_method def self.merge_ai_defaults!(opts = {})
366
387
  env = opts[:env]
367
388
  return env unless env.is_a?(Hash) && env[:ai].is_a?(Hash)
368
389
 
369
- # Pull the in-code default template without writing a file.
370
- template = {
371
- ollama: {
372
- result_max: 4_000
373
- },
374
- agent: {
375
- max_iters: 75,
376
- tool_router: nil,
377
- escalation_persona: 'escalator',
378
- verify_as_reward: nil,
379
- local_introspect: :failure_only,
380
- introspect_every_n: 3,
381
- critic: nil,
382
- counterfactual: nil,
383
- red_team_plan: nil,
384
- hindsight: true,
385
- reward_llm: nil,
386
- history_keep_tool_pairs: 6,
387
- history_tool_max_chars: 2_000
388
- }
389
- }
390
- env[:ai][:ollama] = {} unless env[:ai][:ollama].is_a?(Hash)
391
- env[:ai][:agent] = {} unless env[:ai][:agent].is_a?(Hash)
392
- deep_fill!(target: env[:ai][:ollama], defaults: template[:ollama])
393
- deep_fill!(target: env[:ai][:agent], defaults: template[:agent])
390
+ ai_tmpl = env_template[:ai]
391
+ return env unless ai_tmpl.is_a?(Hash)
392
+
393
+ # Deep-fill every Hash-shaped ai.* slot from the release template
394
+ # (engines + :agent). Scalars like :active / :module_reflection are
395
+ # left untouched — only ABSENT nested keys are supplied.
396
+ ai_tmpl.each do |slot, defaults|
397
+ next unless defaults.is_a?(Hash)
398
+
399
+ env[:ai][slot] = {} unless env[:ai][slot].is_a?(Hash)
400
+ deep_fill!(target: env[:ai][slot], defaults: defaults)
401
+ end
402
+
403
+ # If active names a supported engine symbol and the slot is still
404
+ # somehow non-Hash, force an empty Hash so callers can index safely.
405
+ engine = env[:ai][:active].to_s.downcase.to_sym
406
+ env[:ai][engine] = {} if !engine.empty? && engine != :agent && !env[:ai][engine].is_a?(Hash)
407
+
394
408
  env
395
409
  rescue StandardError => e
396
410
  warn "[pwn/config] merge_ai_defaults! swallowed: #{e.class}: #{e.message}"
@@ -430,9 +444,17 @@ module PWN
430
444
 
431
445
  valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
432
446
 
447
+ raise "ERROR: PWN Environment (#{pwn_env_path}) is missing ai: Hash" unless env.is_a?(Hash) && env[:ai].is_a?(Hash)
448
+
433
449
  engine = env[:ai][:active].to_s.downcase.to_sym
434
450
  raise "ERROR: Unsupported AI Engine: #{engine} in #{pwn_env_path}. Supported AI Engines:\n#{valid_ai_engines.inspect}" unless valid_ai_engines.include?(engine)
435
451
 
452
+ # Backfill missing ai.<engine> / ai.agent keys from env_template BEFORE
453
+ # indexing env[:ai][engine][:key]. Older vaults may set ai.active to a
454
+ # newly supported engine (e.g. ollama) without shipping that engine's
455
+ # config block yet.
456
+ merge_ai_defaults!(env: env)
457
+
436
458
  # Determine whether the active engine already has usable auth
437
459
  # material so the pwn / pwn-ai REPL driver does not prompt for an
438
460
  # API key when OAuth is configured via pwn-vault.
@@ -442,7 +464,10 @@ module PWN
442
464
  # written by PWN::Config.default_env into a fresh ~/.pwn/pwn.yaml.
443
465
  real_cfg = lambda do |v|
444
466
  s = v.to_s.strip
445
- !s.empty? && !s.match?(/\A(optional|required)\b/i)
467
+ !(s.empty? ||
468
+ s.match?(/\A(optional|required)\b/i) ||
469
+ s.match?(/REDACTED/i) ||
470
+ s.match?(/\A<{3}.*>{3}\z/))
446
471
  end
447
472
 
448
473
  key = env[:ai][engine][:key]
@@ -475,9 +500,18 @@ module PWN
475
500
  anthropic: 'or store ai.anthropic.oauth.refresh_token via pwn-vault -- run PWN::AI::Anthropic.obtain_oauth_bearer_token to enroll'
476
501
  }
477
502
  enroll_hint = enroll_hints[engine]
478
- prompt = enroll_hint ? "#{engine} API Key (#{enroll_hint})" : "#{engine} API Key"
479
- key = PWN::Plugins::AuthenticationHelper.mask_password(prompt: prompt)
480
- env[:ai][engine][:key] = key
503
+ # engine is a Symbol (e.g. :ollama). Compare as Symbol — the old
504
+ # String check `!= 'ollama'` was ALWAYS true, so active: ollama still
505
+ # prompted for an API key even though stock ollama needs none.
506
+ # openwebui still requires a JWT/API key and is NOT skipped here.
507
+ # Keep this skip list engine-symbol based only. Do not gate on base_uri
508
+ # hostnames — reverse-proxied ollama may still need no key.
509
+
510
+ if engine != :ollama
511
+ prompt = enroll_hint ? "#{engine} API Key (#{enroll_hint})" : "#{engine} API Key"
512
+ key = PWN::Plugins::AuthenticationHelper.mask_password(prompt: prompt)
513
+ env[:ai][engine][:key] = key
514
+ end
481
515
  end
482
516
 
483
517
  model = env[:ai][engine][:model]
data/lib/pwn/memory.rb CHANGED
@@ -121,23 +121,150 @@ module PWN
121
121
  # Supported Method Parameters::
122
122
  # results = PWN::Memory.recall(
123
123
  # query: 'optional - string to search keys/values/categories (simple match)',
124
- # category: 'optional - filter by category',
125
- # limit: 'optional - max results (default 50)'
124
+ # category: 'optional - filter by category (:session = transcript only)',
125
+ # limit: 'optional - max results (default 50)',
126
+ # session_id: 'optional - current session (default Env/Pry active id)'
126
127
  # )
128
+ # Returns session turns first (previous assistant response -> older, current
129
+ # session only, empty/invalid skipped), then durable memory newest-first.
130
+
127
131
  public_class_method def self.recall(opts = {})
128
132
  query = opts[:query].to_s.downcase
129
133
  category = opts[:category]
130
- limit = opts[:limit] || 50
134
+ limit = (opts[:limit] || 50).to_i
135
+ limit = 50 if limit <= 0
136
+ session_id = opts[:session_id]
137
+ session_id = current_session_id if session_id.to_s.empty?
138
+ # Default ON for memory_recall tool UX. to_context passes false so the
139
+ # injected MEMORY block stays durable cross-session facts only.
140
+ include_session = if opts.key?(:include_session)
141
+ opts[:include_session] ? true : false
142
+ else
143
+ true
144
+ end
145
+
146
+ ordered = {}
147
+
148
+ # 1) Current-session turns first: start at previous response, walk older.
149
+ # Skips empty/invalid turns. Stays scoped to this session only.
150
+ if include_session && !(category && category.to_sym != :session)
151
+ session_turns(session_id: session_id, query: query, limit: limit).each do |entry|
152
+ break if ordered.length >= limit
153
+
154
+ key = entry[:key]
155
+ ordered[key] = entry.except(:key)
156
+ end
157
+ end
131
158
 
132
- mem = load
133
- results = mem.select do |k, v|
134
- match = true
135
- match &&= k.to_s.downcase.include?(query) || v[:value].to_s.downcase.include?(query) || v[:category].to_s.downcase.include?(query) if query && !query.empty?
136
- match &&= (v[:category] == category.to_sym) if category
137
- match
159
+ # 2) Durable ~/.pwn/memory.json (newest timestamp first) fills remaining slots.
160
+ if ordered.length < limit && !(category && category.to_sym == :session)
161
+ mem = load
162
+ durable = mem.select do |k, v|
163
+ match = true
164
+ if query && !query.empty?
165
+ match &&= k.to_s.downcase.include?(query) ||
166
+ v[:value].to_s.downcase.include?(query) ||
167
+ v[:category].to_s.downcase.include?(query)
168
+ end
169
+ match &&= (v[:category].to_s == category.to_s) if category
170
+ match
171
+ end
172
+ durable_sorted = durable.sort_by do |k, v|
173
+ [-Time.parse(v[:timestamp].to_s).to_i, k.to_s]
174
+ rescue StandardError
175
+ [0, k.to_s]
176
+ end
177
+ durable_sorted.each do |k, v|
178
+ break if ordered.length >= limit
179
+ next if ordered.key?(k)
180
+
181
+ ordered[k] = v
182
+ end
138
183
  end
139
184
 
140
- results.to_a.first(limit).to_h
185
+ ordered
186
+ end
187
+
188
+ # Resolve the active pwn-ai session id (Env, then Pry config).
189
+ public_class_method def self.current_session_id(opts = {})
190
+ sid = opts[:session_id].to_s
191
+ return sid unless sid.empty?
192
+
193
+ if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
194
+ sid = PWN::Env.dig(:ai, :session_id).to_s
195
+ return sid unless sid.empty?
196
+ end
197
+
198
+ return Pry.config.pwn_ai_session_id.to_s if defined?(Pry) && Pry.respond_to?(:config) && Pry.config.respond_to?(:pwn_ai_session_id)
199
+
200
+ ''
201
+ end
202
+
203
+ # Walk the current session transcript from the previous assistant response
204
+ # backward (previous -> older). Empty/invalid turns are skipped.
205
+ # Returns Array of hashes with :key plus entry fields (role/value/...).
206
+ public_class_method def self.session_turns(opts = {})
207
+ sid = opts[:session_id].to_s
208
+ sid = current_session_id if sid.empty?
209
+ return [] if sid.empty?
210
+ return [] unless defined?(PWN::Sessions)
211
+
212
+ query = opts[:query].to_s.downcase
213
+ limit = (opts[:limit] || 50).to_i
214
+ limit = 50 if limit <= 0
215
+
216
+ rows = PWN::Sessions.load(session_id: sid)
217
+ return [] if rows.nil? || rows.empty?
218
+
219
+ valid = []
220
+ rows.each_with_index do |e, idx|
221
+ next unless e.is_a?(Hash)
222
+
223
+ role = e[:role].to_s
224
+ content = e[:content].to_s
225
+ next if content.strip.empty?
226
+ next unless %w[assistant user tool system observation].include?(role)
227
+ # Skip the bootstrap system line when it has no useful body.
228
+ next if role == 'system' && content.match?(/\ASession started:/i)
229
+
230
+ valid << {
231
+ idx: idx,
232
+ role: role,
233
+ content: content,
234
+ timestamp: e[:timestamp]
235
+ }
236
+ end
237
+ return [] if valid.empty?
238
+
239
+ # Previous response = most recent non-empty assistant turn in-session.
240
+ last_asst = valid.rindex { |e| e[:role] == 'assistant' }
241
+ # If no assistant yet, still walk backward from the latest valid turn.
242
+ start_at = last_asst || (valid.length - 1)
243
+ backward = valid[0..start_at].reverse
244
+
245
+ hits = []
246
+ backward.each do |e|
247
+ break if hits.length >= limit
248
+
249
+ content = e[:content]
250
+ role = e[:role]
251
+ if query && !query.empty? && !(content.downcase.include?(query) ||
252
+ role.include?(query) ||
253
+ "session_#{sid}".include?(query))
254
+ next
255
+ end
256
+
257
+ hits << {
258
+ key: :"session_turn_#{sid}_#{e[:idx]}",
259
+ value: content,
260
+ category: :session,
261
+ role: role,
262
+ timestamp: e[:timestamp],
263
+ session_id: sid,
264
+ source: 'session_backward'
265
+ }
266
+ end
267
+ hits
141
268
  end
142
269
 
143
270
  # Supported Method Parameters::
@@ -196,7 +323,7 @@ module PWN
196
323
  # (used internally by pwn-ai hook to inject into system prompt)
197
324
  public_class_method def self.to_context(opts = {})
198
325
  limit = opts[:limit] || 20
199
- mem = recall(limit: limit)
326
+ mem = recall(limit: limit, include_session: false)
200
327
  return '' if mem.empty?
201
328
 
202
329
  ctx = "\n\nPERSISTENT MEMORY (cross-session facts, prefs, lessons - use PWN::Memory.remember to store new ones):\n"
@@ -290,6 +417,8 @@ module PWN
290
417
  mem = PWN::Memory.load
291
418
  PWN::Memory.remember(key: :user_prefers_ruby, value: 'Always prefer pure Ruby + RestClient patterns', category: :preference)
292
419
  facts = PWN::Memory.recall(query: 'recon', category: :fact, limit: 10)
420
+ # session-first (previous response → older), then durable newest-first:
421
+ sess = PWN::Memory.recall(limit: 10) # or session_id: '20260811_...'
293
422
  hits = PWN::Memory.recall_semantic(query: 'recon', limit: 6) # embedding-ranked
294
423
  PWN::Memory.forget(key: :some_key)
295
424
  PWN::Memory.forget(key: :operator_pref_x) rescue puts('protected')
@@ -8,9 +8,10 @@ module PWN
8
8
  # PWN::MemoryIndex is a lightweight local embedding index over
9
9
  # PWN::Memory (~/.pwn/memory.json) so PromptBuilder can inject the N
10
10
  # MOST-RELEVANT memories for the current request instead of the N
11
- # newest. Embeddings come from the local Ollama instance
12
- # (PWN::Env[:ai][:ollama][:embed_model], default 'nomic-embed-text') via
13
- # its native /api/embed endpoint — everything stays on-box.
11
+ # newest. Embeddings prefer a direct Ollama /api/embed endpoint
12
+ # (PWN::Env[:ai][:ollama][:embed_model], default 'nomic-embed-text').
13
+ # When only Open WebUI is configured, embeddings go through its
14
+ # /ollama/api/embed proxy with the openwebui key/base_uri.
14
15
  #
15
16
  # Index layout (~/.pwn/memory.idx):
16
17
  # { "<key>": { "sha": "<sha16 of value>", "vec": [Float,…] }, … }
@@ -24,11 +25,11 @@ module PWN
24
25
  # Supported Method Parameters::
25
26
  # bool = PWN::MemoryIndex.available?
26
27
  #
27
- # True when a local Ollama base_uri is configured. All public methods
28
- # degrade to substring recall when this is false.
28
+ # True when a local Ollama or Open WebUI base_uri is configured. All
29
+ # public methods degrade to substring recall when this is false.
29
30
 
30
31
  public_class_method def self.available?
31
- !ollama_base.nil?
32
+ !embed_endpoint.nil?
32
33
  rescue StandardError
33
34
  false
34
35
  end
@@ -122,26 +123,27 @@ module PWN
122
123
  # Supported Method Parameters::
123
124
  # vecs = PWN::MemoryIndex.embed(texts: ['a', 'b'])
124
125
  #
125
- # POST /api/embed on the local Ollama; returns Array<Array<Float>> (one
126
- # vector per input, nil on per-item failure). Batches of 32.
126
+ # POST /api/embed on direct Ollama, or /ollama/api/embed via Open WebUI.
127
+ # Returns Array<Array<Float>> (one vector per input, nil on per-item
128
+ # failure). Batches of 32.
127
129
 
128
130
  public_class_method def self.embed(opts = {})
129
131
  texts = Array(opts[:texts]).map(&:to_s)
130
- base = ollama_base
131
- return Array.new(texts.length) unless base && !texts.empty?
132
+ ep = embed_endpoint
133
+ return Array.new(texts.length) unless ep && !texts.empty?
132
134
 
133
- model = (PWN::Env.dig(:ai, :ollama, :embed_model) if defined?(PWN::Env)) || DEFAULT_EMBED_MODEL
135
+ model = ep[:model]
134
136
  browser = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
135
137
  rest = browser[:browser]::Request
136
138
  headers = { content_type: 'application/json; charset=UTF-8' }
137
- token = PWN::Env.dig(:ai, :ollama, :key) if defined?(PWN::Env)
138
- headers[:authorization] = "Bearer #{token}" if token
139
+ token = ep[:token]
140
+ headers[:authorization] = "Bearer #{token}" if token && !token.to_s.empty?
139
141
 
140
142
  out = []
141
143
  texts.each_slice(32) do |batch|
142
144
  resp = rest.execute(
143
145
  method: :post,
144
- url: "#{base}/ollama/api/embed",
146
+ url: ep[:url],
145
147
  headers: headers,
146
148
  payload: { model: model, input: batch }.to_json,
147
149
  verify_ssl: false,
@@ -201,11 +203,50 @@ module PWN
201
203
  Digest::SHA256.hexdigest(opts[:text].to_s)[0, 16]
202
204
  end
203
205
 
204
- private_class_method def self.ollama_base
206
+ # Prefer direct Ollama (native /api/embed). Fall back to Open WebUI's
207
+ # /ollama/api/embed proxy when only openwebui is configured.
208
+ private_class_method def self.embed_endpoint
205
209
  return nil unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
206
210
 
207
- b = PWN::Env.dig(:ai, :ollama, :base_uri).to_s
208
- b.start_with?('http') ? b.chomp('/') : nil
211
+ ollama_b = PWN::Env.dig(:ai, :ollama, :base_uri).to_s.strip
212
+ if ollama_b.start_with?('http')
213
+ model = PWN::Env.dig(:ai, :ollama, :embed_model).to_s
214
+ model = DEFAULT_EMBED_MODEL if model.empty? || model.match?(/\A(optional|required)\b/i)
215
+ token = PWN::Env.dig(:ai, :ollama, :key)
216
+ token = nil if token.to_s.strip.empty? || token.to_s.match?(/\A(optional|required)\b/i)
217
+ return {
218
+ url: "#{ollama_b.chomp('/')}/api/embed",
219
+ model: model,
220
+ token: token,
221
+ via: :ollama
222
+ }
223
+ end
224
+
225
+ owu_b = PWN::Env.dig(:ai, :openwebui, :base_uri).to_s.strip
226
+ return nil unless owu_b.start_with?('http')
227
+
228
+ model = PWN::Env.dig(:ai, :openwebui, :embed_model).to_s
229
+ model = PWN::Env.dig(:ai, :ollama, :embed_model).to_s if model.empty?
230
+ model = DEFAULT_EMBED_MODEL if model.empty? || model.match?(/\A(optional|required)\b/i)
231
+ token = PWN::Env.dig(:ai, :openwebui, :key)
232
+ token = nil if token.to_s.strip.empty? || token.to_s.match?(/\A(optional|required)\b/i)
233
+ {
234
+ url: "#{owu_b.chomp('/')}/ollama/api/embed",
235
+ model: model,
236
+ token: token,
237
+ via: :openwebui
238
+ }
239
+ rescue StandardError
240
+ nil
241
+ end
242
+
243
+ # Backward-compatible private alias (specs / older callers).
244
+ private_class_method def self.ollama_base
245
+ ep = embed_endpoint
246
+ return nil unless ep
247
+
248
+ # Strip trailing path so callers that append still work if any remain.
249
+ ep[:url].sub(%r{/api/embed\z}, '').sub(%r{/ollama\z}, '')
209
250
  end
210
251
 
211
252
  private_class_method def self.fallback(opts = {})
@@ -234,8 +275,13 @@ module PWN
234
275
  PWN::MemoryIndex.embed(texts: ['a', 'b']) # raw vectors
235
276
  PWN::MemoryIndex.reset
236
277
 
237
- Config:
238
- PWN::Env[:ai][:ollama][:embed_model] = '<embed-model-tag>' # any embedding model pulled locally
278
+ Config (prefer direct Ollama; Open WebUI is the fallback proxy):
279
+ PWN::Env[:ai][:ollama][:base_uri] = 'http://127.0.0.1:11434'
280
+ PWN::Env[:ai][:ollama][:embed_model] = '<embed-model-tag>'
281
+ # or:
282
+ PWN::Env[:ai][:openwebui][:base_uri] = 'https://openwebui.local'
283
+ PWN::Env[:ai][:openwebui][:key] = '<jwt>'
284
+ PWN::Env[:ai][:openwebui][:embed_model] = '<embed-model-tag>'
239
285
 
240
286
  #{self}.authors
241
287
  USAGE
@@ -1260,16 +1260,18 @@ module PWN
1260
1260
  chat_opts[:system_role_content] = system_role if system_role
1261
1261
 
1262
1262
  case engine
1263
+ when :anthropic
1264
+ response = PWN::AI::Anthropic.chat(**chat_opts)
1265
+ when :gemini
1266
+ response = PWN::AI::Gemini.chat(**chat_opts)
1263
1267
  when :grok
1264
1268
  response = PWN::AI::Grok.chat(**chat_opts)
1265
1269
  when :ollama
1266
1270
  response = PWN::AI::Ollama.chat(**chat_opts)
1267
1271
  when :openai
1268
1272
  response = PWN::AI::OpenAI.chat(**chat_opts)
1269
- when :anthropic
1270
- response = PWN::AI::Anthropic.chat(**chat_opts)
1271
- when :gemini
1272
- response = PWN::AI::Gemini.chat(**chat_opts)
1273
+ when :openwebui
1274
+ response = PWN::AI::OpenWebUI.chat(**chat_opts)
1273
1275
  else
1274
1276
  raise "ERROR: Unsupported AI Engine: #{engine}"
1275
1277
  end
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.666'
4
+ VERSION = '0.5.668'
5
5
  end
@@ -1468,13 +1468,13 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
1468
1468
  end
1469
1469
 
1470
1470
  describe 'P0 · Budget exhaust deepen (last-iter / no-CF-hot / exhaust Learning)' do
1471
- it 'tightens budget-hot max_iters: 24 ollama / 75 remote (long-goal runway)' do
1471
+ it 'tightens budget-hot max_iters: 24 local (ollama/openwebui) / 75 remote (long-goal runway)' do
1472
1472
  src = File.read(loop_mod.method(:run).source_location.first)
1473
1473
  # max_iters is private_class_method — read surrounding source
1474
1474
  # P17 long-autonomy: local stays 24; remote keeps multi-step runway 75.
1475
1475
  # always-24-for-ALL starved long-lived goals after the hot text-only tail.
1476
1476
  expect(src).to match(/budget_exhaustion_hot\?/)
1477
- expect(src).to match(/hot_cap = active_engine == :ollama \? 24 : 75/)
1477
+ expect(src).to match(/hot_cap = local_engine\? \? 24 : 75/)
1478
1478
  expect(src).to match(/n = \[n, hot_cap\]\.min/)
1479
1479
  expect(src).not_to match(/n = \[n, 24\]\.min/)
1480
1480
  end
@@ -1506,25 +1506,27 @@ RSpec.describe 'PWN::AI::Agent reinforced feedback loop', :aggregate_failures do
1506
1506
  end
1507
1507
 
1508
1508
  describe 'P28 · autonomy (remote overconf runway + incomplete-final)' do
1509
- it 'sets W3 overconf max_iters_cap to 120 on remote, 24 on ollama' do
1509
+ it 'sets W3 overconf max_iters_cap to 120 on remote, 24 on local engines' do
1510
1510
  src = File.read(loop_mod.method(:run).source_location.first)
1511
1511
  expect(src).to match(/P28/)
1512
1512
  expect(src).to match(/remote_cap = 120/)
1513
1513
  expect(src).to match(/local_cap\s*=\s*24/)
1514
- expect(src).to match(/eng == :ollama \? local_cap : remote_cap/)
1514
+ expect(src).to match(/local_engine\?\(engine: eng\) \? local_cap : remote_cap/)
1515
1515
  # P17 budget-hot: 24 ollama / 75 remote (not always-24)
1516
- expect(src).to match(/hot_cap = active_engine == :ollama \? 24 : 75/)
1516
+ expect(src).to match(/hot_cap = local_engine\? \? 24 : 75/)
1517
1517
  end
1518
1518
 
1519
1519
  it 'defines incomplete_final? and continues on mid-goal handoff' do
1520
1520
  src = File.read(loop_mod.method(:run).source_location.first)
1521
1521
  expect(src).to match(/incomplete_final\?/)
1522
1522
  expect(src).to match(/INCOMPLETE_FINAL_RX/)
1523
+ expect(src).to match(/MONOLOGUE_TOOL_INTENT_RX/)
1523
1524
  expect(src).to match(%r{\[pwn-ai/p28\]})
1524
1525
  expect(src).to match(/continuing autonomously/)
1525
1526
  # last-iter wording no longer coaches "next single step" handoffs
1526
1527
  expect(src).not_to match(/next single step/)
1527
- expect(src).to match(/do NOT ask the/)
1528
+ expect(src).to match(/Do NOT monologue/)
1529
+ expect(src).to match(/Emit NATIVE tool_calls NOW/)
1528
1530
  end
1529
1531
 
1530
1532
  it 'PromptBuilder injects AUTONOMY block' do
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'json'
4
5
 
5
6
  describe PWN::AI::Agent::Dispatch do
6
7
  it 'should display information for authors' do
@@ -12,4 +13,81 @@ describe PWN::AI::Agent::Dispatch do
12
13
  help_response = PWN::AI::Agent::Dispatch
13
14
  expect(help_response).to respond_to :help
14
15
  end
16
+
17
+ describe '.tool_calls_from_text' do
18
+ before { PWN::AI::Agent::Registry.discover }
19
+
20
+ def args_hash(call)
21
+ raw = call.dig(:function, :arguments)
22
+ raw.is_a?(String) ? JSON.parse(raw, symbolize_names: true) : raw
23
+ end
24
+
25
+ it 'parses shell(command="...") kwarg form' do
26
+ calls = described_class.tool_calls_from_text(
27
+ text: 'shell(command="ip addr | head -n 1")'
28
+ )
29
+ expect(calls.length).to eq(1)
30
+ expect(calls[0].dig(:function, :name)).to eq('shell')
31
+ expect(calls[0].dig(:function, :arguments)).to be_a(String)
32
+ expect(args_hash(calls[0])[:command]).to include('ip addr')
33
+ end
34
+
35
+ it 'parses shell({JSON}) and bare JSON name/arguments forms' do
36
+ a = described_class.tool_calls_from_text(text: 'shell({"command":"id"})')
37
+ b = described_class.tool_calls_from_text(
38
+ text: '{"name":"shell","arguments":{"command":"uname -a"}}'
39
+ )
40
+ expect(a[0].dig(:function, :arguments)).to be_a(String)
41
+ expect(args_hash(a[0])[:command]).to eq('id')
42
+ expect(args_hash(b[0])[:command]).to eq('uname -a')
43
+ end
44
+
45
+ it 'returns [] when no registered tool call is present' do
46
+ expect(
47
+ described_class.tool_calls_from_text(text: 'use hping3 like this: hping3 -1 host')
48
+ ).to eq([])
49
+ end
50
+
51
+ it 'parses call:name{...} and tool:name{...} colon-brace forms' do
52
+ a = described_class.tool_calls_from_text(text: 'call:shell{command: "uname -s"}')
53
+ b = described_class.tool_calls_from_text(text: 'tool:shell{"command":"id"}')
54
+ c = described_class.tool_calls_from_text(text: 'call:shell{command="whoami"}')
55
+ expect(a.length).to eq(1)
56
+ expect(a[0].dig(:function, :name)).to eq('shell')
57
+ expect(args_hash(a[0])[:command]).to eq('uname -s')
58
+ expect(args_hash(b[0])[:command]).to eq('id')
59
+ expect(args_hash(c[0])[:command]).to eq('whoami')
60
+ end
61
+
62
+ it 'parses JSON objects keyed by tool/call as well as name/function' do
63
+ tool_key = described_class.tool_calls_from_text(
64
+ text: '{"tool":"shell","arguments":{"command":"id"}}'
65
+ )
66
+ call_key = described_class.tool_calls_from_text(
67
+ text: '{"call":"shell","arguments":{"command":"uname -a"}}'
68
+ )
69
+ typed = described_class.tool_calls_from_text(
70
+ text: '{"type":"call","name":"shell","arguments":{"command":"pwd"}}'
71
+ )
72
+ expect(args_hash(tool_key[0])[:command]).to eq('id')
73
+ expect(args_hash(call_key[0])[:command]).to eq('uname -a')
74
+ expect(args_hash(typed[0])[:command]).to eq('pwd')
75
+ end
76
+
77
+ it 'ignores prose that only mentions call: without a registered tool body' do
78
+ expect(
79
+ described_class.tool_calls_from_text(text: 'please call:help later')
80
+ ).to eq([])
81
+ end
82
+
83
+ it 'parses multi-arg call:name{k=v,...} without swallowing trailing braces' do
84
+ calls = described_class.tool_calls_from_text(
85
+ text: 'call:shell{command=id, timeout=5}'
86
+ )
87
+ expect(calls.length).to eq(1)
88
+ h = args_hash(calls[0])
89
+ expect(h[:command]).to eq('id')
90
+ expect(h[:timeout].to_s).to eq('5')
91
+ end
92
+ end
15
93
  end