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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +1 -0
- data/Gemfile +4 -4
- data/README.md +12 -3
- data/lib/pwn/ai/agent/dispatch.rb +161 -0
- data/lib/pwn/ai/agent/extrospection.rb +9 -5
- data/lib/pwn/ai/agent/learning.rb +2 -1
- data/lib/pwn/ai/agent/loop.rb +311 -18
- data/lib/pwn/ai/agent/prompt_builder.rb +12 -5
- data/lib/pwn/ai/agent/reflect.rb +1 -0
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/result.rb +3 -3
- data/lib/pwn/ai/agent/swarm.rb +1 -1
- data/lib/pwn/ai/agent/task_summarizer.rb +1 -0
- data/lib/pwn/ai/agent/tools/memory.rb +18 -4
- data/lib/pwn/ai/grok.rb +3 -0
- data/lib/pwn/ai/ollama.rb +131 -78
- data/lib/pwn/ai/open_ai.rb +3 -0
- data/lib/pwn/ai/open_web_ui.rb +715 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +70 -36
- data/lib/pwn/memory.rb +140 -11
- data/lib/pwn/memory_index.rb +65 -19
- data/lib/pwn/plugins/repl.rb +6 -4
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +8 -6
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +78 -0
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +117 -0
- data/spec/lib/pwn/ai/grok_spec.rb +5 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +36 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +302 -0
- data/spec/lib/pwn/config_spec.rb +70 -0
- data/spec/lib/pwn/memory_spec.rb +63 -0
- data/third_party/pwn_rdoc.jsonl +32 -5
- metadata +11 -9
data/lib/pwn/ai.rb
CHANGED
data/lib/pwn/config.rb
CHANGED
|
@@ -67,12 +67,14 @@ module PWN
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
ollama: {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
139
|
-
tool_router: nil, # nil = auto (true when :active
|
|
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
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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?
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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')
|
data/lib/pwn/memory_index.rb
CHANGED
|
@@ -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
|
|
12
|
-
# (PWN::Env[:ai][:ollama][:embed_model], default 'nomic-embed-text')
|
|
13
|
-
#
|
|
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
|
|
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
|
-
!
|
|
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
|
|
126
|
-
# vector per input, nil on per-item
|
|
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
|
-
|
|
131
|
-
return Array.new(texts.length) unless
|
|
132
|
+
ep = embed_endpoint
|
|
133
|
+
return Array.new(texts.length) unless ep && !texts.empty?
|
|
132
134
|
|
|
133
|
-
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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][:
|
|
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
|
data/lib/pwn/plugins/repl.rb
CHANGED
|
@@ -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 :
|
|
1270
|
-
response = PWN::AI::
|
|
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
|
@@ -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 =
|
|
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
|
|
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
|
|
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 =
|
|
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(/
|
|
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
|