pwn 0.5.627 → 0.5.628

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.
@@ -18,6 +18,15 @@ module PWN
18
18
  # tool selection accordingly. This is one half of the closed
19
19
  # feedback loop that lets pwn-ai continuously make itself smarter
20
20
  # (the other half is PWN::AI::Agent::Learning).
21
+ #
22
+ # PER-ENGINE SEGMENTATION
23
+ # -----------------------
24
+ # A local qwen3.6:35b and a frontier model do NOT have the same
25
+ # per-tool success rate — blending them mis-advises the local model
26
+ # about itself. Every record now also increments an :engines[<engine>]
27
+ # sub-bucket; summary/to_context accept engine: to surface only that
28
+ # engine's telemetry so the TOOL EFFECTIVENESS block becomes a
29
+ # genuine per-engine learned policy.
21
30
  module Metrics
22
31
  METRICS_FILE = File.join(Dir.home, '.pwn', 'metrics.json')
23
32
 
@@ -51,7 +60,8 @@ module PWN
51
60
  # name: 'required - tool name that was dispatched',
52
61
  # success: 'required - Boolean, did the handler complete without error',
53
62
  # duration: 'optional - Float seconds the dispatch took',
54
- # error: 'optional - String error message when success is false'
63
+ # error: 'optional - String error message when success is false',
64
+ # engine: 'optional - Symbol/String AI engine that chose this tool (segments telemetry)'
55
65
  # )
56
66
 
57
67
  public_class_method def self.record(opts = {})
@@ -59,65 +69,70 @@ module PWN
59
69
  success = opts[:success] ? true : false
60
70
  duration = opts[:duration].to_f
61
71
  error = opts[:error]
72
+ engine = opts[:engine].to_s
62
73
  return if name.empty?
63
74
 
64
75
  metrics = load
65
76
  metrics[:tools] ||= {}
66
77
  key = name.to_sym
67
- t = metrics[:tools][key] ||= {
68
- calls: 0, ok: 0, fail: 0, total_duration: 0.0,
69
- last_error: nil, last_at: nil
70
- }
71
- t[:calls] += 1
72
- t[:ok] += 1 if success
73
- t[:fail] += 1 unless success
74
- t[:total_duration] += duration
75
- t[:last_error] = error.to_s[0, 300] if error && !success
76
- t[:last_at] = Time.now.utc.iso8601
78
+ t = metrics[:tools][key] ||= blank_bucket
79
+ bump(bucket: t, success: success, duration: duration, error: error)
80
+ unless engine.empty?
81
+ t[:engines] ||= {}
82
+ e = t[:engines][engine.to_sym] ||= blank_bucket
83
+ bump(bucket: e, success: success, duration: duration, error: error)
84
+ end
77
85
  save(metrics: metrics)
78
86
  t
79
87
  end
80
88
 
81
89
  # Supported Method Parameters::
82
90
  # rows = PWN::AI::Agent::Metrics.summary(
83
- # limit: 'optional - cap number of tools returned (default 25)'
91
+ # limit: 'optional - cap number of tools returned (default 25)',
92
+ # engine: 'optional - only that engine\'s sub-bucket (falls back to global when absent)'
84
93
  # )
85
94
 
86
95
  public_class_method def self.summary(opts = {})
87
- limit = opts[:limit] || 25
88
- tools = load[:tools] || {}
96
+ limit = opts[:limit] || 25
97
+ engine = opts[:engine].to_s
98
+ tools = load[:tools] || {}
89
99
  rows = tools.map do |name, t|
90
- calls = t[:calls].to_i
91
- ok = t[:ok].to_i
100
+ b = engine.empty? ? t : (t.dig(:engines, engine.to_sym) || t)
101
+ calls = b[:calls].to_i
102
+ ok = b[:ok].to_i
92
103
  rate = calls.positive? ? (ok.to_f / calls).round(3) : 0.0
93
- avg = calls.positive? ? (t[:total_duration].to_f / calls).round(3) : 0.0
104
+ avg = calls.positive? ? (b[:total_duration].to_f / calls).round(3) : 0.0
94
105
  {
95
106
  name: name.to_s,
96
107
  calls: calls,
97
108
  success_rate: rate,
98
109
  avg_duration: avg,
99
- last_error: t[:last_error],
100
- last_at: t[:last_at]
110
+ last_error: b[:last_error],
111
+ last_at: b[:last_at]
101
112
  }
102
113
  end
103
- rows.sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
114
+ rows.reject { |r| r[:calls].zero? }
115
+ .sort_by { |r| [-r[:calls], -r[:success_rate]] }.first(limit)
104
116
  end
105
117
 
106
118
  # Supported Method Parameters::
107
119
  # ctx = PWN::AI::Agent::Metrics.to_context(
108
- # limit: 'optional - cap number of tools included (default 8)'
120
+ # limit: 'optional - cap number of tools included (default 8)',
121
+ # engine: 'optional - restrict to one engine\'s telemetry'
109
122
  # )
110
123
 
111
124
  public_class_method def self.to_context(opts = {})
112
- limit = opts[:limit] || 8
113
- rows = summary(limit: limit)
125
+ limit = opts[:limit] || 8
126
+ engine = opts[:engine]
127
+ rows = summary(limit: limit, engine: engine)
114
128
  return '' if rows.empty?
115
129
 
130
+ scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}"
116
131
  lines = rows.map do |r|
117
132
  err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
118
133
  " - #{r[:name]}: calls=#{r[:calls]} success=#{(r[:success_rate] * 100).round(1)}% avg=#{r[:avg_duration]}s#{err}"
119
134
  end
120
- "TOOL EFFECTIVENESS (historical, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
135
+ "TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
121
136
  end
122
137
 
123
138
  # Supported Method Parameters::
@@ -128,6 +143,24 @@ module PWN
128
143
  { tools: {}, updated_at: nil }
129
144
  end
130
145
 
146
+ private_class_method def self.blank_bucket
147
+ { calls: 0, ok: 0, fail: 0, total_duration: 0.0, last_error: nil, last_at: nil }
148
+ end
149
+
150
+ private_class_method def self.bump(opts = {})
151
+ b = opts[:bucket]
152
+ success = opts[:success]
153
+ duration = opts[:duration].to_f
154
+ error = opts[:error]
155
+ b[:calls] += 1
156
+ b[:ok] += 1 if success
157
+ b[:fail] += 1 unless success
158
+ b[:total_duration] = b[:total_duration].to_f + duration
159
+ b[:last_error] = error.to_s[0, 300] if error && !success
160
+ b[:last_at] = Time.now.utc.iso8601
161
+ b
162
+ end
163
+
131
164
  # Author(s):: 0day Inc. <support@0dayinc.com>
132
165
 
133
166
  public_class_method def self.authors
@@ -139,9 +172,9 @@ module PWN
139
172
  public_class_method def self.help
140
173
  puts <<~USAGE
141
174
  USAGE:
142
- PWN::AI::Agent::Metrics.record(name: 'shell', success: true, duration: 0.42)
143
- PWN::AI::Agent::Metrics.summary(limit: 10)
144
- PWN::AI::Agent::Metrics.to_context(limit: 8) # injected by PromptBuilder
175
+ PWN::AI::Agent::Metrics.record(name: 'shell', success: true, duration: 0.42, engine: :ollama)
176
+ PWN::AI::Agent::Metrics.summary(limit: 10, engine: :ollama)
177
+ PWN::AI::Agent::Metrics.to_context(limit: 8, engine: :ollama) # injected by PromptBuilder
145
178
  PWN::AI::Agent::Metrics.reset
146
179
  PWN::AI::Agent::Metrics.load
147
180
  PWN::AI::Agent::Metrics.save(metrics: hash)
@@ -10,15 +10,33 @@ module PWN
10
10
  # Re-injection IS the persistence mechanism: this is rebuilt fresh on
11
11
  # every user turn, so a memory_remember / skill_create from the prior
12
12
  # turn shows up here with no extra wiring.
13
+ #
14
+ # ENGINE-AWARE BUDGETING
15
+ # ----------------------
16
+ # Local models (Ollama) drown when handed the same 6-8 KB of MEMORY /
17
+ # METRICS / MISTAKES / EXTROSPECTION context that a frontier model
18
+ # shrugs off. .budget shrinks each block for :ollama (or whatever
19
+ # PWN::Env[:ai][<engine>][:prompt_budget] says) so the small model
20
+ # spends its attention on the request, not the harness.
21
+ #
22
+ # RELEVANCE-RANKED MEMORY
23
+ # -----------------------
24
+ # When Loop.run passes request: through, the MEMORY block is populated
25
+ # by PWN::MemoryIndex.recall_semantic (embedding cosine over
26
+ # ~/.pwn/memory.idx) instead of a recency dump — the 6 memories a
27
+ # small model can afford are the 6 that actually matter for THIS turn.
13
28
  module PromptBuilder
14
29
  # Supported Method Parameters::
15
30
  # system_prompt = PWN::AI::Agent::PromptBuilder.build(
16
- # session_id: 'optional - PWN::Sessions id to embed in the ENV block'
31
+ # session_id: 'optional - PWN::Sessions id to embed in the ENV block',
32
+ # request: 'optional - user request; enables relevance-ranked MEMORY when provided'
17
33
  # )
18
34
 
19
35
  public_class_method def self.build(opts = {})
20
36
  session_id = opts[:session_id]
37
+ request = opts[:request]
21
38
  engine = active_engine
39
+ b = budget
22
40
  base = (PWN::Env.dig(:ai, engine, :system_role_content) if defined?(PWN::Env)) || 'You are a world-class introspective offensive cyber security and research engineer. You specialize in discovering zero day vulnerabilities focused on responsible disclosure prior to threat actors discovering and exploiting. You are self-aware of your harness, pwn which begins with the ruby namespace `PWN` operating inside the pwn REPL. For every request you first begin by determining if PWN has a module capable of satisfying the request.'
23
41
 
24
42
  "
@@ -31,7 +49,7 @@ module PWN
31
49
  pwn : #{pwn_version}
32
50
  session_id : #{session_id || '(none)'}
33
51
 
34
- #{memory_block}#{skills_block}#{learning_block}#{mistakes_block}#{metrics_block}#{extrospection_block}TOOL USE
52
+ #{memory_block(limit: b[:memory], request: request)}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes])}#{metrics_block(limit: b[:metrics], engine: engine)}#{extrospection_block if b[:extro]}TOOL USE
35
53
  Use the provided function tools to act on the host. A reply with
36
54
  no tool_calls is treated as your FINAL answer to the user.
37
55
  Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
@@ -39,6 +57,29 @@ module PWN
39
57
  "
40
58
  end
41
59
 
60
+ # Supported Method Parameters::
61
+ # b = PWN::AI::Agent::PromptBuilder.budget
62
+ #
63
+ # Per-engine caps for each injected block. Override any key via
64
+ # PWN::Env[:ai][<engine>][:prompt_budget][:memory|:metrics|:mistakes|
65
+ # :learning|:extro]. :extro is a Boolean gate — Extrospection is the
66
+ # heaviest block and rarely useful to a local model.
67
+
68
+ public_class_method def self.budget
69
+ eng = active_engine
70
+ b = (PWN::Env.dig(:ai, eng, :prompt_budget) if defined?(PWN::Env)) || {}
71
+ local = eng == :ollama
72
+ {
73
+ memory: (b[:memory] || (local ? 6 : 25)).to_i,
74
+ metrics: (b[:metrics] || (local ? 3 : 8)).to_i,
75
+ mistakes: (b[:mistakes] || (local ? 3 : 6)).to_i,
76
+ learning: (b[:learning] || (local ? 2 : 5)).to_i,
77
+ extro: b[:extro].nil? ? !local : b[:extro]
78
+ }
79
+ rescue StandardError
80
+ { memory: 25, metrics: 8, mistakes: 6, learning: 5, extro: true }
81
+ end
82
+
42
83
  private_class_method def self.active_engine
43
84
  return :openai unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
44
85
 
@@ -57,11 +98,17 @@ module PWN
57
98
  defined?(PWN::VERSION) ? PWN::VERSION : '?'
58
99
  end
59
100
 
60
- private_class_method def self.memory_block
101
+ private_class_method def self.memory_block(opts = {})
61
102
  return '' unless defined?(PWN::Memory) && PWN::Memory.respond_to?(:to_context)
62
103
 
63
- ctx = PWN::Memory.to_context(limit: 25).to_s
64
- ctx.strip.empty? ? '' : "MEMORY#{ctx}\n\n"
104
+ limit = opts[:limit] || 25
105
+ req = opts[:request]
106
+ ctx = if req && defined?(PWN::MemoryIndex) && PWN::MemoryIndex.available?
107
+ PWN::MemoryIndex.to_context(query: req, limit: limit)
108
+ else
109
+ PWN::Memory.to_context(limit: limit)
110
+ end
111
+ ctx.to_s.strip.empty? ? '' : "MEMORY#{ctx}\n\n"
65
112
  rescue StandardError
66
113
  ''
67
114
  end
@@ -81,28 +128,28 @@ module PWN
81
128
  ''
82
129
  end
83
130
 
84
- private_class_method def self.learning_block
131
+ private_class_method def self.learning_block(opts = {})
85
132
  return '' unless defined?(PWN::AI::Agent::Learning)
86
133
 
87
- ctx = PWN::AI::Agent::Learning.to_context(limit: 5).to_s
134
+ ctx = PWN::AI::Agent::Learning.to_context(limit: opts[:limit] || 5).to_s
88
135
  ctx.strip.empty? ? '' : "LEARNING\n#{ctx}"
89
136
  rescue StandardError
90
137
  ''
91
138
  end
92
139
 
93
- private_class_method def self.mistakes_block
140
+ private_class_method def self.mistakes_block(opts = {})
94
141
  return '' unless defined?(PWN::AI::Agent::Mistakes)
95
142
 
96
- ctx = PWN::AI::Agent::Mistakes.to_context(limit: 6).to_s
143
+ ctx = PWN::AI::Agent::Mistakes.to_context(limit: opts[:limit] || 6).to_s
97
144
  ctx.strip.empty? ? '' : ctx
98
145
  rescue StandardError
99
146
  ''
100
147
  end
101
148
 
102
- private_class_method def self.metrics_block
149
+ private_class_method def self.metrics_block(opts = {})
103
150
  return '' unless defined?(PWN::AI::Agent::Metrics)
104
151
 
105
- ctx = PWN::AI::Agent::Metrics.to_context(limit: 8).to_s
152
+ ctx = PWN::AI::Agent::Metrics.to_context(limit: opts[:limit] || 8, engine: opts[:engine]).to_s
106
153
  ctx.strip.empty? ? '' : ctx
107
154
  rescue StandardError
108
155
  ''
@@ -128,7 +175,11 @@ module PWN
128
175
  public_class_method def self.help
129
176
  puts <<~USAGE
130
177
  USAGE:
131
- system_prompt = PWN::AI::Agent::PromptBuilder.build(session_id: 'abc')
178
+ system_prompt = PWN::AI::Agent::PromptBuilder.build(session_id: 'abc', request: 'nmap sweep 10/8')
179
+ PWN::AI::Agent::PromptBuilder.budget # => {memory:, metrics:, mistakes:, learning:, extro:}
180
+
181
+ Override per-engine via:
182
+ PWN::Env[:ai][:ollama][:prompt_budget] = { memory: 4, extro: false }
132
183
 
133
184
  #{self}.authors
134
185
  USAGE
@@ -21,11 +21,21 @@ module PWN
21
21
  # through when it wants an LLM opinion on locally-produced data, and
22
22
  # it is also what PWN::AI::Agent::Learning.reflect uses to distill
23
23
  # session transcripts into durable PWN::Memory lessons.
24
+ #
25
+ # TEACHER-STUDENT REFLECTION
26
+ # --------------------------
27
+ # When PWN::Env[:ai][:reflect_engine] (or opts[:engine]) names a
28
+ # different provider than :active, Reflect.on temporarily flips
29
+ # :active for the duration of the introspection call. This lets a
30
+ # local Ollama model EXECUTE the task while a frontier model WRITES
31
+ # the durable lessons about it — the local model then reads back
32
+ # distilled reasoning it could never have produced itself.
24
33
  module Reflect
25
34
  # Supported Method Parameters::
26
35
  # response = PWN::AI::Agent::Reflect.on(
27
36
  # request: 'required - String - What you want the AI to reflect on',
28
37
  # system_role_content: 'optional - context to set up the model behavior for reflection',
38
+ # engine: 'optional - override engine for THIS reflection only (Symbol/String); defaults to PWN::Env[:ai][:reflect_engine] || :active',
29
39
  # spinner: 'optional - Boolean - Display spinner during operation (default: false)',
30
40
  # suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
31
41
  # )
@@ -45,17 +55,20 @@ module PWN
45
55
  ai_module_reflection = PWN::Env[:ai][:module_reflection]
46
56
 
47
57
  if ai_module_reflection && request.length.positive?
48
- engine = PWN::Env[:ai][:active].to_s.downcase.to_sym
58
+ override = opts[:engine] || PWN::Env.dig(:ai, :reflect_engine)
59
+ engine = (override || PWN::Env[:ai][:active]).to_s.downcase.to_sym
49
60
  valid_ai_engines = PWN::AI.help.reject { |e| e.downcase == :agent }.map(&:downcase)
50
61
  raise "ERROR: Unsupported AI engine. Supported engines are: #{valid_ai_engines}" unless valid_ai_engines.include?(engine)
51
62
 
52
63
  warn "AI Reflection is enabled. Ensure #{engine} has been authorized for use and/or requests are sanitized properly." unless suppress_pii_warning
53
- response = PWN::AI::Agent::Loop.run(
54
- request: request.chomp,
55
- system_role_content: system_role_content,
56
- enabled_toolsets: [],
57
- spinner: spinner
58
- )
64
+ response = with_engine(engine: override) do
65
+ PWN::AI::Agent::Loop.run(
66
+ request: request.chomp,
67
+ system_role_content: system_role_content,
68
+ enabled_toolsets: [],
69
+ spinner: spinner
70
+ )
71
+ end
59
72
  end
60
73
 
61
74
  response
@@ -63,6 +76,23 @@ module PWN
63
76
  raise e
64
77
  end
65
78
 
79
+ # Temporarily override PWN::Env[:ai][:active] so Loop.run routes to
80
+ # the teacher engine, restoring afterwards even on raise. No-op when
81
+ # engine is nil/blank or PWN::Env[:ai] is frozen.
82
+ private_class_method def self.with_engine(opts = {})
83
+ engine = opts[:engine].to_s
84
+ ai = defined?(PWN::Env) && PWN::Env.is_a?(Hash) ? PWN::Env[:ai] : nil
85
+ return yield if engine.empty? || !ai.is_a?(Hash) || ai.frozen?
86
+
87
+ prev = ai[:active]
88
+ ai[:active] = engine
89
+ begin
90
+ yield
91
+ ensure
92
+ ai[:active] = prev
93
+ end
94
+ end
95
+
66
96
  # Author(s):: 0day Inc. <support@0dayinc.com>
67
97
 
68
98
  public_class_method def self.authors
@@ -78,10 +108,14 @@ module PWN
78
108
  #{self}.on(
79
109
  request: 'required - String - What you want the AI to reflect on',
80
110
  system_role_content: 'optional - context to set up the model behavior for reflection',
111
+ engine: 'optional - override engine (Symbol) for teacher-student reflection; defaults to PWN::Env[:ai][:reflect_engine]',
81
112
  spinner: 'optional - Boolean - Display spinner during operation (default: false)',
82
113
  suppress_pii_warning: 'optional - Boolean - Suppress PII Warnings (default: false)'
83
114
  )
84
115
 
116
+ Teacher-student config:
117
+ PWN::Env[:ai][:reflect_engine] = :anthropic # execute on :active, critique on :anthropic
118
+
85
119
  #{self}.authors
86
120
  "
87
121
  end
@@ -18,6 +18,16 @@ module PWN
18
18
  # agent/tools/*.rb (require registry, call .register at top level)
19
19
  # ^
20
20
  # agent/loop.rb (calls Registry.discover then .definitions)
21
+ #
22
+ # DYNAMIC TOOL-SET SLIMMING (local-model scaffolding)
23
+ # ---------------------------------------------------
24
+ # Shipping all ~47 tool schemas on every call overwhelms a 35B local
25
+ # model — it mis-routes (extro_rf_tune for a git question) because the
26
+ # choice space is huge. When PWN::Env[:ai][:agent][:tool_router] is
27
+ # truthy AND definitions(relevance: request) is passed, the pool is
28
+ # reduced to CORE_TOOLS + the top-K keyword-ranked matches. Routing
29
+ # accuracy is fed back into Metrics under name:'tool_router' so the
30
+ # router itself becomes a learned component.
21
31
  module Registry
22
32
  Entry = Struct.new(
23
33
  :name, # String - tool name exposed to the model
@@ -29,6 +39,9 @@ module PWN
29
39
  keyword_init: true
30
40
  )
31
41
 
42
+ CORE_TOOLS = %w[shell pwn_eval memory_remember memory_recall
43
+ mistakes_record mistakes_resolve learning_note_outcome].freeze
44
+
32
45
  @entries = {}
33
46
  @discovered = false
34
47
 
@@ -84,15 +97,51 @@ module PWN
84
97
 
85
98
  # Supported Method Parameters::
86
99
  # tools = PWN::AI::Agent::Registry.definitions(
87
- # enabled: 'optional - Array of toolset names to include; nil = all whose check passes'
100
+ # enabled: 'optional - Array of toolset names to include; nil = all whose check passes',
101
+ # relevance: 'optional - user request; when set AND :tool_router is enabled, slim to CORE + top-K keyword matches',
102
+ # top_k: 'optional - keyword-ranked tools to keep beyond CORE (default 10)'
88
103
  # )
89
104
 
90
105
  public_class_method def self.definitions(opts = {})
91
106
  enabled = opts[:enabled]
92
107
  enabled = enabled.map(&:to_s) if enabled
93
- @entries.values
94
- .select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
95
- .map { |e| { type: 'function', function: e.schema } }
108
+ pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
109
+
110
+ if opts[:relevance] && router_enabled?
111
+ keep = rank(query: opts[:relevance], entries: pool).first(opts[:top_k] || 10).map(&:name)
112
+ names = (CORE_TOOLS + keep).uniq
113
+ pool = pool.select { |e| names.include?(e.name) }
114
+ end
115
+
116
+ pool.map { |e| { type: 'function', function: e.schema } }
117
+ end
118
+
119
+ # Supported Method Parameters::
120
+ # ranked = PWN::AI::Agent::Registry.rank(
121
+ # query: 'required - user request text',
122
+ # entries: 'optional - Entry pool to rank (default .all)'
123
+ # )
124
+ #
125
+ # Lightweight keyword TF router: scores each tool by the number of
126
+ # query tokens (≥ 3 chars, downcased) appearing in its name /
127
+ # description / property names. Zero-score entries drop off; ties
128
+ # break on historical Metrics success_rate so the router learns.
129
+
130
+ public_class_method def self.rank(opts = {})
131
+ query = opts[:query].to_s.downcase
132
+ entries = opts[:entries] || all
133
+ return entries if query.strip.empty?
134
+
135
+ tokens = query.scan(/[a-z0-9_]{3,}/).uniq
136
+ rates = metrics_rates
137
+ scored = entries.map do |e|
138
+ hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
139
+ score = tokens.count { |t| hay.include?(t) }
140
+ [e, score, rates[e.name] || 0.0]
141
+ end
142
+ scored.reject { |_, s, _| s.zero? }
143
+ .sort_by { |_, s, r| [-s, -r] }
144
+ .map(&:first)
96
145
  end
97
146
 
98
147
  # Supported Method Parameters::
@@ -123,6 +172,20 @@ module PWN
123
172
  false
124
173
  end
125
174
 
175
+ private_class_method def self.router_enabled?
176
+ defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :agent, :tool_router)
177
+ rescue StandardError
178
+ false
179
+ end
180
+
181
+ private_class_method def self.metrics_rates
182
+ return {} unless defined?(Metrics)
183
+
184
+ Metrics.summary(limit: 200).to_h { |r| [r[:name], r[:success_rate]] }
185
+ rescue StandardError
186
+ {}
187
+ end
188
+
126
189
  # Author(s):: 0day Inc. <support@0dayinc.com>
127
190
 
128
191
  public_class_method def self.authors
@@ -136,6 +199,8 @@ module PWN
136
199
  USAGE:
137
200
  PWN::AI::Agent::Registry.discover
138
201
  PWN::AI::Agent::Registry.definitions(enabled: %w[terminal pwn])
202
+ PWN::AI::Agent::Registry.definitions(relevance: 'nmap sweep 10/8') # slim (needs :tool_router)
203
+ PWN::AI::Agent::Registry.rank(query: 'run a shell command')
139
204
  PWN::AI::Agent::Registry.lookup(name: 'shell') # => Entry
140
205
  PWN::AI::Agent::Registry.toolsets # => ["memory","pwn","skills","terminal"]
141
206
  PWN::AI::Agent::Registry.register(name:, toolset:, schema:, handler:)
data/lib/pwn/ai/ollama.rb CHANGED
@@ -143,11 +143,25 @@ module PWN
143
143
  # spinner: 'optional - display spinner (default false)'
144
144
  # )
145
145
  #
146
- # Returns the raw /v1/chat/completions response Hash with :choices intact
147
- # (including :message[:tool_calls]) — used by PWN::AI::Agent::Loop.
148
- # Ollama's OpenAI-compat endpoint supports tools since 0.3.x; tool_calls
149
- # come back with function.arguments as a JSON object (not string), which
150
- # PWN::AI::Agent::Dispatch.parse_args handles transparently.
146
+ # Returns a Hash with :choices / :assistant_message intact (including
147
+ # :message[:tool_calls]) — used by PWN::AI::Agent::Loop.
148
+ #
149
+ # LOCAL-MODEL SCAFFOLDING
150
+ # -----------------------
151
+ # This hits Ollama's NATIVE /api/chat (not the OpenAI-compat shim) so
152
+ # the following actually take effect:
153
+ # options.num_ctx - Ollama defaults to 2048; the pwn-ai system
154
+ # prompt alone blows that. Defaults here to
155
+ # PWN::Env[:ai][:ollama][:num_ctx] || 32768.
156
+ # options.temperature - forced to 0.1 on tool-bearing turns for
157
+ # deterministic tool selection; engine[:temp]
158
+ # (creative) on the final text-only turn.
159
+ # format: 'json' - constrains the sampler to valid JSON when
160
+ # tools are present so Dispatch never sees
161
+ # malformed function.arguments.
162
+ # keep_alive: '30m' - avoids reload latency between iterations.
163
+ # tool_calls come back with function.arguments as a Hash (not a JSON
164
+ # string), which PWN::AI::Agent::Dispatch.parse_args handles.
151
165
 
152
166
  public_class_method def self.chat_with_tools(opts = {})
153
167
  engine = PWN::Env[:ai][:ollama]
@@ -160,18 +174,30 @@ module PWN
160
174
  temp = opts[:temp].to_f
161
175
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
162
176
 
177
+ tools_present = opts[:tools] && !opts[:tools].empty?
178
+ tool_temp = (engine[:tool_temp] || 0.1).to_f
179
+ num_ctx = (engine[:num_ctx] || 32_768).to_i
180
+ keep_alive = engine[:keep_alive] || '30m'
181
+
163
182
  http_body = {
164
183
  model: model,
165
184
  messages: messages,
166
- temperature: temp,
167
- stream: false
185
+ stream: false,
186
+ keep_alive: keep_alive,
187
+ options: {
188
+ num_ctx: num_ctx,
189
+ temperature: tools_present ? tool_temp : temp
190
+ }
168
191
  }
169
- http_body[:tools] = opts[:tools] if opts[:tools] && !opts[:tools].empty?
192
+ if tools_present
193
+ http_body[:tools] = opts[:tools]
194
+ http_body[:format] = engine[:format] || 'json'
195
+ end
170
196
  http_body[:tool_choice] = opts[:tool_choice] if opts[:tool_choice]
171
197
 
172
198
  response = ollama_rest_call(
173
199
  http_method: :post,
174
- rest_call: 'ollama/v1/chat/completions',
200
+ rest_call: 'ollama/api/chat',
175
201
  http_body: http_body,
176
202
  timeout: opts[:timeout],
177
203
  spinner: opts[:spinner]
@@ -179,7 +205,10 @@ module PWN
179
205
  return nil if response.nil?
180
206
 
181
207
  json_resp = JSON.parse(response, symbolize_names: true)
182
- json_resp[:assistant_message] = json_resp.dig(:choices, 0, :message)
208
+ # Normalise native /api/chat shape to what Loop.normalize_llm expects.
209
+ msg = json_resp[:message] || json_resp.dig(:choices, 0, :message)
210
+ json_resp[:choices] = [{ message: msg }] if msg && !json_resp.key?(:choices)
211
+ json_resp[:assistant_message] = msg
183
212
  json_resp
184
213
  rescue StandardError => e
185
214
  raise e
data/lib/pwn/config.rb CHANGED
@@ -65,8 +65,13 @@ module PWN
65
65
  base_uri: 'required - Base URI for Open WebUI - e.g. https://ollama.local',
66
66
  key: 'required - Open WebUI API Key Under Settings >> Account >> JWT Token',
67
67
  model: 'required - Ollama model to use',
68
+ embed_model: 'optional - embedding model for PWN::MemoryIndex (default nomic-embed-text)',
68
69
  system_role_content: 'You are an ethically hacking Ollama agent.',
69
70
  temp: 'optional - Ollama temperature',
71
+ num_ctx: 32_768,
72
+ keep_alive: '30m',
73
+ # tighten each PromptBuilder block for the local model (nil = engine defaults)
74
+ prompt_budget: { memory: 6, metrics: 3, mistakes: 3, learning: 2, extro: false },
70
75
  max_prompt_length: 32_000
71
76
  },
72
77
  anthropic: {
@@ -86,6 +91,8 @@ module PWN
86
91
  temp: 'optional - Gemini temperature',
87
92
  max_prompt_length: 1_000_000
88
93
  },
94
+ # teacher-student reflection: execute on :active, write durable lessons via this engine (nil = same as :active)
95
+ reflect_engine: nil,
89
96
  agent: {
90
97
  native_tools: true,
91
98
  max_iters: 25,
@@ -95,6 +102,10 @@ module PWN
95
102
  auto_introspect: true,
96
103
  # also run PWN::AI::Agent::Extrospection.auto_extrospect from auto_introspect
97
104
  auto_extrospect: false,
105
+ # engine-agnostic scaffolding (defaults tuned for local models)
106
+ plan_first: nil, # nil = auto (true when :active == :ollama)
107
+ tool_router: false, # slim Registry.definitions to CORE + top-K relevant tools
108
+ escalation_persona: nil, # Swarm persona name for frontier corrective hints when a local model is stuck
98
109
  toolsets: nil
99
110
  # multi-agent personas : ~/.pwn/agents.yml (see PWN::AI::Agent::Swarm.help)
100
111
  # swarm bus : ~/.pwn/swarm/<swarm_id>/bus.jsonl