pwn 0.5.677 → 0.5.679

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/documentation/AI-Integration.md +4 -3
  4. data/documentation/Agent-Tool-Registry.md +27 -5
  5. data/documentation/CLI-Drivers.md +1 -1
  6. data/documentation/Configuration.md +65 -25
  7. data/documentation/Cron.md +12 -2
  8. data/documentation/Home.md +1 -1
  9. data/documentation/How-PWN-Works.md +1 -1
  10. data/documentation/Installation.md +12 -7
  11. data/documentation/Reinforcement-Learning.md +10 -6
  12. data/documentation/Skills-Memory-Learning.md +1 -1
  13. data/documentation/What-is-PWN.md +1 -1
  14. data/documentation/diagrams/agent-tool-registry.svg +156 -147
  15. data/documentation/diagrams/cron-scheduling.svg +149 -126
  16. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -1
  17. data/documentation/diagrams/dot/cron-scheduling.dot +6 -3
  18. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +1 -1
  19. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +14 -13
  20. data/documentation/pwn-REPL.md +1 -1
  21. data/documentation/pwn-ai-Agent.md +16 -9
  22. data/lib/pwn/ai/agent/policy.rb +9 -3
  23. data/lib/pwn/ai/agent/registry.rb +95 -6
  24. data/lib/pwn/ai/anthropic.rb +2 -6
  25. data/lib/pwn/ai/gemini.rb +2 -6
  26. data/lib/pwn/ai/grok.rb +7 -8
  27. data/lib/pwn/ai/ollama.rb +2 -6
  28. data/lib/pwn/ai/open_ai.rb +2 -6
  29. data/lib/pwn/ai/open_web_ui.rb +2 -6
  30. data/lib/pwn/blockchain/btc.rb +2 -6
  31. data/lib/pwn/blockchain/eth.rb +2 -6
  32. data/lib/pwn/config.rb +4 -3
  33. data/lib/pwn/plugins/black_duck_binary_analysis.rb +2 -4
  34. data/lib/pwn/plugins/jira_data_center.rb +2 -4
  35. data/lib/pwn/plugins/repl.rb +12 -6
  36. data/lib/pwn/plugins/tty_spinner.rb +75 -0
  37. data/lib/pwn/plugins.rb +1 -0
  38. data/lib/pwn/version.rb +1 -1
  39. data/spec/lib/pwn/ai/agent/registry_spec.rb +6 -0
  40. data/spec/lib/pwn/ai/grok_spec.rb +7 -0
  41. data/spec/lib/pwn/plugins/tty_spinner_spec.rb +61 -0
  42. data/third_party/pwn_rdoc.jsonl +8 -2
  43. metadata +3 -1
@@ -249,10 +249,10 @@
249
249
  <!-- Dispatch&#45;&gt;Curric -->
250
250
  <g id="edge67" class="edge">
251
251
  <title>Dispatch&#45;&gt;Curric</title>
252
- <path fill="none" stroke="#fb7185" stroke-width="1.3" stroke-dasharray="5,2" d="M982.7,-511.69C962.61,-506.86 940.53,-502.04 920,-498.62 887.88,-493.28 650.15,-494.73 628.25,-470.62 621.97,-463.72 621.66,-456.24 628.25,-449.62 638.21,-439.62 2647.56,-445.93 2661,-441.62 2703.86,-427.9 2744.93,-396.11 2771.77,-371.93"/>
252
+ <path fill="none" stroke="#fb7185" stroke-width="1.3" stroke-dasharray="5,2" d="M982.71,-511.61C962.63,-506.78 940.54,-501.98 920,-498.62 885.68,-493.02 631.72,-496.29 608.25,-470.62 601.95,-463.74 601.66,-456.24 608.25,-449.62 618.31,-439.53 2647.42,-445.97 2661,-441.62 2703.86,-427.9 2744.93,-396.11 2771.77,-371.93"/>
253
253
  <polygon fill="#fb7185" stroke="#fb7185" stroke-width="1.3" points="2773.51,-374.14 2777.51,-366.66 2769.72,-370.01 2773.51,-374.14"/>
254
- <text xml:space="preserve" text-anchor="middle" x="657.12" y="-462.07" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">counterfactual</text>
255
- <text xml:space="preserve" text-anchor="middle" x="657.12" y="-451.57" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">[REPEATING]</text>
254
+ <text xml:space="preserve" text-anchor="middle" x="637.12" y="-462.07" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">counterfactual</text>
255
+ <text xml:space="preserve" text-anchor="middle" x="637.12" y="-451.57" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">[REPEATING]</text>
256
256
  </g>
257
257
  <!-- Policy -->
258
258
  <g id="node17" class="node">
@@ -273,9 +273,9 @@
273
273
  <!-- Dispatch&#45;&gt;Snapshot -->
274
274
  <g id="edge17" class="edge">
275
275
  <title>Dispatch&#45;&gt;Snapshot</title>
276
- <path fill="none" stroke="#f59e0b" stroke-width="1.3" d="M982.79,-511.11C962.71,-506.26 940.6,-501.58 920,-498.62 859.87,-489.99 415.84,-514.67 374,-470.62 367.57,-463.86 367.47,-456.29 374,-449.62 388.54,-434.77 728.61,-449.09 748,-441.62 781.43,-428.75 811.74,-401.68 832.99,-378.87"/>
277
- <polygon fill="#f59e0b" stroke="#f59e0b" stroke-width="1.3" points="834.93,-380.9 838.24,-373.1 830.79,-377.13 834.93,-380.9"/>
278
- <text xml:space="preserve" text-anchor="middle" x="386" y="-456.82" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">probe</text>
276
+ <path fill="none" stroke="#f59e0b" stroke-width="1.3" d="M982.79,-511.08C962.71,-506.24 940.6,-501.57 920,-498.62 857.67,-489.73 397.41,-516.24 354,-470.62 347.57,-463.86 347.47,-456.29 354,-449.62 369.32,-433.99 727.57,-449.48 748,-441.62 781.44,-428.77 811.75,-401.69 832.99,-378.88"/>
277
+ <polygon fill="#f59e0b" stroke="#f59e0b" stroke-width="1.3" points="834.94,-380.91 838.24,-373.11 830.79,-377.15 834.94,-380.91"/>
278
+ <text xml:space="preserve" text-anchor="middle" x="366" y="-456.82" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">probe</text>
279
279
  </g>
280
280
  <!-- Dispatch&#45;&gt;Observe -->
281
281
  <g id="edge23" class="edge">
@@ -354,9 +354,10 @@
354
354
  <!-- Result&#45;&gt;Learning -->
355
355
  <g id="edge13" class="edge">
356
356
  <title>Result&#45;&gt;Learning</title>
357
- <path fill="none" stroke="#34d399" stroke-width="1.3" d="M1436.68,-515.8C1417.86,-509.35 1394.52,-502.34 1373,-498.62 1336.18,-492.27 726.87,-497.59 701,-470.62 694.54,-463.89 694.43,-456.25 701,-449.62 711.32,-439.22 1742.38,-447.04 1756,-441.62 1787.86,-428.95 1815.93,-402.05 1835.46,-379.27"/>
358
- <polygon fill="#34d399" stroke="#34d399" stroke-width="1.3" points="1837.52,-381.18 1840.49,-373.24 1833.22,-377.59 1837.52,-381.18"/>
359
- <text xml:space="preserve" text-anchor="middle" x="732.5" y="-456.82" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">auto_introspect</text>
357
+ <path fill="none" stroke="#34d399" stroke-width="1.3" d="M1436.68,-515.78C1417.86,-509.34 1394.53,-502.33 1373,-498.62 1335.07,-492.1 707.41,-498.39 680.75,-470.62 674.29,-463.89 674.17,-456.25 680.75,-449.62 691.27,-439.03 1742.12,-447.14 1756,-441.62 1787.86,-428.96 1815.93,-402.06 1835.47,-379.27"/>
358
+ <polygon fill="#34d399" stroke="#34d399" stroke-width="1.3" points="1837.52,-381.18 1840.5,-373.24 1833.22,-377.59 1837.52,-381.18"/>
359
+ <text xml:space="preserve" text-anchor="middle" x="722.38" y="-462.07" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">auto_introspect</text>
360
+ <text xml:space="preserve" text-anchor="middle" x="722.38" y="-451.57" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">(deferred after reply)</text>
360
361
  </g>
361
362
  <!-- Reward -->
362
363
  <g id="node15" class="node">
@@ -391,10 +392,10 @@
391
392
  <!-- Result&#45;&gt;Observe -->
392
393
  <g id="edge18" class="edge">
393
394
  <title>Result&#45;&gt;Observe</title>
394
- <path fill="none" stroke="#f59e0b" stroke-width="1.3" d="M1436.69,-515.69C1417.88,-509.22 1394.55,-502.22 1373,-498.62 1322.16,-490.15 481.64,-507.61 445.75,-470.62 439.25,-463.93 439.2,-456.27 445.75,-449.62 456.17,-439.05 966.53,-447.86 980,-441.62 1013.37,-426.19 1039.16,-391.33 1053.96,-367.02"/>
395
- <polygon fill="#f59e0b" stroke="#f59e0b" stroke-width="1.3" points="1056.27,-368.61 1057.91,-360.3 1051.44,-365.78 1056.27,-368.61"/>
396
- <text xml:space="preserve" text-anchor="middle" x="496.38" y="-462.07" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">auto_extrospect</text>
397
- <text xml:space="preserve" text-anchor="middle" x="496.38" y="-451.57" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">(AUTO_SECTIONS only)</text>
395
+ <path fill="none" stroke="#f59e0b" stroke-width="1.3" d="M1436.69,-515.68C1417.89,-509.21 1394.55,-502.21 1373,-498.62 1321.07,-489.99 462.42,-508.4 425.75,-470.62 419.25,-463.93 419.2,-456.27 425.75,-449.62 436.56,-438.66 966.02,-448.09 980,-441.62 1013.37,-426.2 1039.16,-391.34 1053.96,-367.03"/>
396
+ <polygon fill="#f59e0b" stroke="#f59e0b" stroke-width="1.3" points="1056.27,-368.62 1057.91,-360.3 1051.44,-365.78 1056.27,-368.62"/>
397
+ <text xml:space="preserve" text-anchor="middle" x="476.38" y="-462.07" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">auto_extrospect</text>
398
+ <text xml:space="preserve" text-anchor="middle" x="476.38" y="-451.57" font-family="sans-Serif" font-size="9.00" fill="#cbd5e1">(AUTO_SECTIONS only)</text>
398
399
  </g>
399
400
  <!-- Result&#45;&gt;Verify -->
400
401
  <g id="edge49" class="edge">
@@ -19,7 +19,7 @@ commands.
19
19
  | Command | Implemented in | Purpose |
20
20
  |---|---|---|
21
21
  | `pwn-ai` | `Agent::Loop` | Enter the AI agent TUI |
22
- | `pwn-vault` | `PWN::Plugins::Vault` | Decrypt → edit `~/.pwn/pwn.yaml` → re-encrypt |
22
+ | `pwn-vault` | `PWN::Plugins::Vault` | Decrypt, edit `~/.pwn/pwn.yaml`, re-encrypt. Stays in the editor until `PWN::Config` accepts the file. |
23
23
  | `pwn-asm` | `Plugins::Assembly` | Multiline asm ↔ opcodes workbench |
24
24
  | `pwn-mesh` | `Meshtastic` gem | Meshtastic serial / MQTT client (Ruby ≥ 4.0; installed via `pwn setup`) |
25
25
  | `pwn-ai-memory` | `PWN::Memory` | View/edit persistent memory |
@@ -38,9 +38,10 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
38
38
  chosen)` DPO preference pair** in `~/.pwn/preferences.jsonl`.
39
39
  3. **Registry** hands Loop the tool schemas - the full set for frontier
40
40
  engines, or `CORE_TOOLS` + top-K keyword-relevant when
41
- `ai.agent.tool_router` is on. Rank can include a Q-advantage term from
42
- `Policy` after a pair has been visited at least twice. Planning still owns
43
- the task list. *(local)* `Learning.exemplars_for` splices a compressed
41
+ `ai.agent.tool_router` is on (default on for `ollama` / `openwebui`). Rank
42
+ can include a Q-advantage term from `Policy` after a pair has been visited
43
+ at least twice, plus `ai.agent.tool_preference` as a smaller tie-break.
44
+ Planning still owns the task list. *(local)* `Learning.exemplars_for` splices a compressed
44
45
  prior-success trace as few-shot; *(local)* `plan_first` forces a numbered
45
46
  tool plan before the first dispatch (optionally red-teamed by
46
47
  `Curriculum.red_team_plan`).
@@ -80,8 +81,10 @@ $ pwn --ai "run bin/pwn_sast against ./src and push findings to DefectDojo"
80
81
  outside world; a `:refuted` verify is itself recorded as a Mistakes
81
82
  `assumption` fingerprint.
82
83
  7. Results are appended to the message list; go to 5.
83
- 8. When the reply has *no* tool_calls it's the **final answer** →
84
- `Learning.auto_introspect` fires (if enabled): *(local)*
84
+ 8. When the reply has *no* tool_calls it's the **final answer**. With
85
+ `defer_introspect` on (default), the user-visible reply returns first and
86
+ `Learning.auto_introspect` runs on a background thread. Specs and cron stay
87
+ inline. Then *(local)*
85
88
  `fact_check_local_final` auto-`extro_verify`s every CVE / version-shaped
86
89
  claim in the answer; **`Reward.judge`** scores (request, final) with a cheap LLM ORM →
87
90
  `{score, verdict, rationale, source}` (heuristic overlap only if the engine is unavailable); **`Policy.finish`** applies that judge score as
@@ -162,7 +165,7 @@ task_summary_interval_s: 8.0 # or every N seconds (verbose)
162
165
  task_summary_verbose: false # mid-flight Progress: lines
163
166
  task_summary_llm: true # LLM task decompose for autonomous goals (default on)
164
167
  request_kind_llm: null # null = follow task_summary_llm; false = heuristic-only
165
- max_iters: 25 # budget pressure may lower the effective cap (stricter on local engines)
168
+ max_iters: 75 # budget pressure may lower the effective cap (stricter on local engines)
166
169
  ```
167
170
 
168
171
 
@@ -176,8 +179,9 @@ max_iters: 25 # budget pressure may lower the effective cap (s
176
179
  summary turn.
177
180
  - Run `mistakes_list` before retrying something that failed last session -
178
181
  the fix may already be recorded.
179
- - `ai.agent.tool_router: true` + `ai.agent.plan_first: true` when running on
180
- a local model - that cuts mis-routing a lot.
182
+ - Leave `ai.agent.tool_router` and `ai.agent.plan_first` on auto when running
183
+ a local model - that cuts mis-routing a lot. Tune `ai.agent.tool_preference`
184
+ if you want recall / sessions ahead of `shell` on ties.
181
185
  - Set `ai.reflect_engine:` to a frontier provider so lessons written to
182
186
  `~/.pwn/memory.json` stay high-signal even when the executing engine is
183
187
  local.
@@ -201,7 +205,10 @@ max_iters: 25 # budget pressure may lower the effective cap (s
201
205
  | `reward_model` | `nil` | optional cheaper model id for `Reward.judge` / `.prm` (nil = active engine default) |
202
206
  | `reward_llm_timeout` | `12` | seconds for the cheap ORM chat (clamped 2..30) |
203
207
  | `verify_as_reward` | `nil` (auto) | browser-grounded claim sample policy |
204
- | `local_introspect` | `:failure_only` | ollama end-of-turn introspect policy |
208
+ | `local_introspect` | `:failure_only` | ollama / openwebui end-of-turn introspect policy |
209
+ | `tool_preference` | memory_recall-first list | Rank bonus + Policy suggested-action order |
210
+ | `defer_introspect` | `true` | Post-answer Learning on a background thread |
211
+ | `prompt_cache` | `true` | Engine-native prefix cache (not Ollama / Open WebUI) |
205
212
 
206
213
  Full detail: [Reinforcement Learning](Reinforcement-Learning.md).
207
214
 
@@ -572,16 +572,22 @@ module PWN
572
572
  end
573
573
 
574
574
  ev = evaluate(limit: opts[:limit] || 40)
575
+ fallback = %w[memory_recall sessions_view pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember]
576
+ pref = begin
577
+ PWN::AI::Agent::Registry.preference_order
578
+ rescue StandardError
579
+ []
580
+ end
581
+ actions = pref.empty? ? fallback : pref
575
582
  rec = begin
576
- recs = recommend(actions: %w[shell pwn_eval memory_recall sessions_view], epsilon: 0.0)
577
- recs[:action]
583
+ recommend(actions: actions, epsilon: 0.0)[:action]
578
584
  rescue StandardError
579
585
  nil
580
586
  end
581
587
  lines << 'POLICY (R5 tabular Q / REINFORCE — advisory only, does not replace planning)'
582
588
  lines << " episodes=#{s[:n_episodes]} states=#{s[:n_states]} pairs=#{s[:n_pairs]} updates=#{s[:n_updates]}"
583
589
  lines << " mean_return=#{s[:mean_return] || '-'} mean|TD|=#{s[:mean_abs_td] || '-'} greedy_match=#{ev[:greedy_match] || '-'}"
584
- lines << " current_state=#{current_state || '(none)'} suggest=#{rec || '-'}"
590
+ lines << " current_state=#{current_state || '(none)'} suggest=#{rec || '-'} tool_preference=#{actions.join(',')}"
585
591
  "#{lines.join("\n")}\n"
586
592
  rescue StandardError
587
593
  ''
@@ -43,6 +43,12 @@ module PWN
43
43
  CORE_TOOLS = %w[shell pwn_eval memory_remember memory_recall
44
44
  mistakes_record mistakes_resolve learning_note_outcome].freeze
45
45
 
46
+ # Operator-tunable tool order. Prefer these names when keyword-fit
47
+ # (or other rank features) tie. Overridable via opts[:order],
48
+ # opts[:preference], or PWN::Env[:ai][:agent][:tool_preference].
49
+ # Explicit nil/empty order disables preference (no Env/DEFAULT fallback).
50
+ DEFAULT_PREFERENCE = %w[memory_recall sessions_view pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember].freeze
51
+
46
52
  @entries = {}
47
53
  @discovered = false
48
54
 
@@ -100,7 +106,9 @@ module PWN
100
106
  # tools = PWN::AI::Agent::Registry.definitions(
101
107
  # enabled: 'optional - Array of toolset names to include; nil = all whose check passes',
102
108
  # relevance: 'optional - user request; when set AND :tool_router is enabled, slim to CORE + top-K keyword matches',
103
- # top_k: 'optional - keyword-ranked tools to keep beyond CORE (default 10)'
109
+ # top_k: 'optional - keyword-ranked tools to keep beyond CORE (default 10)',
110
+ # order: 'optional - preference list forwarded only when this key is present',
111
+ # preference: 'optional - alias of :order; same key-present rule'
104
112
  # )
105
113
 
106
114
  public_class_method def self.definitions(opts = {})
@@ -108,19 +116,84 @@ module PWN
108
116
  enabled = enabled.map(&:to_s) if enabled
109
117
  pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
110
118
 
119
+ pref_fwd = {}
120
+ pref_fwd[:order] = opts[:order] if opts.key?(:order)
121
+ pref_fwd[:preference] = opts[:preference] if opts.key?(:preference)
122
+
111
123
  if opts[:relevance] && router_enabled?
112
- keep = rank(query: opts[:relevance], entries: pool).first(opts[:top_k] || 10).map(&:name)
124
+ keep = rank({ query: opts[:relevance], entries: pool }.merge(pref_fwd)).first(opts[:top_k] || 10).map(&:name)
113
125
  names = (CORE_TOOLS + keep).uniq
114
126
  pool = pool.select { |e| names.include?(e.name) }
115
127
  end
116
128
 
129
+ pool = apply_preference({ items: pool }.merge(pref_fwd))
117
130
  pool.map { |e| { type: 'function', function: e.schema } }
118
131
  end
119
132
 
133
+ # Supported Method Parameters::
134
+ # order = PWN::AI::Agent::Registry.preference_order(
135
+ # order: 'optional - Array of tool names; explicit nil/[] disables',
136
+ # preference: 'optional - alias of :order. Explicit key disables Env/DEFAULT fallback'
137
+ # )
138
+
139
+ public_class_method def self.preference_order(opts = {})
140
+ explicit = opts.key?(:order) || opts.key?(:preference)
141
+ if explicit
142
+ raw = opts.key?(:preference) ? opts[:preference] : opts[:order]
143
+ return [] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
144
+
145
+ return Array(raw).map(&:to_s).reject(&:empty?)
146
+ end
147
+
148
+ raw = nil
149
+ raw = PWN::Env.dig(:ai, :agent, :tool_preference) if defined?(PWN::Env) && PWN::Env.is_a?(Hash)
150
+ raw = DEFAULT_PREFERENCE if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
151
+ Array(raw).map(&:to_s).reject(&:empty?)
152
+ rescue StandardError
153
+ DEFAULT_PREFERENCE.dup
154
+ end
155
+
156
+ # Supported Method Parameters::
157
+ # sorted = PWN::AI::Agent::Registry.apply_preference(
158
+ # items: 'required - Array of Entry objects or OpenAI tool hashes',
159
+ # order: 'optional - forwarded to preference_order only when the key is set',
160
+ # preference: 'optional - alias of :order; same key-present rule'
161
+ # )
162
+ #
163
+ # Stable sort: preferred names first, original index as the tie-break
164
+ # so equal-preference items keep their incoming order.
165
+ # Empty preference (explicit [] / nil) leaves items unsorted.
166
+
167
+ public_class_method def self.apply_preference(opts = {})
168
+ items = Array(opts[:items] || opts[:entries])
169
+ po = {}
170
+ po[:preference] = opts[:preference] if opts.key?(:preference)
171
+ po[:order] = opts[:order] if opts.key?(:order)
172
+ order = preference_order(po)
173
+ return items if order.empty?
174
+
175
+ index = {}
176
+ order.each_with_index { |name, i| index[name.to_s] = i }
177
+ fallback = order.length
178
+ items.sort_by.with_index do |item, orig|
179
+ name = if item.respond_to?(:name)
180
+ item.name.to_s
181
+ elsif item.is_a?(Hash)
182
+ (item.dig(:function, :name) ||
183
+ item.dig('function', 'name') ||
184
+ item[:name] || item['name']).to_s
185
+ else
186
+ item.to_s
187
+ end
188
+ [index.fetch(name, fallback), orig]
189
+ end
190
+ end
191
+
120
192
  # Supported Method Parameters::
121
193
  # ranked = PWN::AI::Agent::Registry.rank(
122
194
  # query: 'required - user request text',
123
- # entries: 'optional - Entry pool to rank (default .all)'
195
+ # entries: 'optional - Entry pool to rank (default .all)',
196
+ # order: 'optional - preference list forwarded to preference_order'
124
197
  # )
125
198
  #
126
199
  # C1 advantage-weighted router: score = α·keyword_sim + β·(tool
@@ -136,11 +209,23 @@ module PWN
136
209
 
137
210
  tokens = query.scan(/[a-z0-9_]{3,}/).uniq
138
211
  # C1 — advantage-weighted router:
139
- # score = α·keyword_sim + β·advantage + γ·UCB(tool) + δ·prm_advantage + ε·Q_adv
212
+ # score = alpha*keyword_sim + beta*advantage + gamma*UCB(tool)
213
+ # + delta*prm_advantage + eps_q*Q_adv + zeta*pref_score
140
214
  # UCB gives untried / low-N tools an exploration bonus so a single
141
215
  # early failure (before its dep was installed) does not blacklist
142
216
  # it forever; advantage prefers tools that outperform the fleet.
217
+ # zeta * pref_score (zeta=0.35) boosts Env/opts tool preference;
218
+ # alpha stays 1.0 so keyword fit remains the primary signal.
219
+ # zeta is 0 when preference is empty so ties are not pref-boosted.
143
220
  alpha = 1.0
221
+ po = {}
222
+ po[:preference] = opts[:preference] if opts.key?(:preference)
223
+ po[:order] = opts[:order] if opts.key?(:order)
224
+ pref_order = preference_order(po)
225
+ zeta = pref_order.empty? ? 0.0 : 0.35
226
+ pref_index = {}
227
+ pref_order.each_with_index { |n, i| pref_index[n.to_s] = i }
228
+ pref_n = [pref_order.length, 1].max
144
229
  # P4 — haircut advantage weight when reward proxy is hacked
145
230
  trust = defined?(Metrics) && Metrics.respond_to?(:proxy_trust) ? Metrics.proxy_trust : 1.0
146
231
  beta = 0.3 * trust
@@ -187,10 +272,14 @@ module PWN
187
272
  else
188
273
  0.0
189
274
  end
190
- [e, sim, (alpha * sim) + (beta * adv) + (gamma * ucb) + (delta * prm) + (eps_q * qadv)]
275
+ pidx = pref_index[e.name]
276
+ pref = pidx.nil? ? 0.0 : ((pref_n - pidx).to_f / pref_n)
277
+ total = (alpha * sim) + (beta * adv) + (gamma * ucb) + (delta * prm) + (eps_q * qadv) + (zeta * pref)
278
+ [e, sim, total]
191
279
  end
280
+ # Equal keyword-fit (and equal total): preferred name first.
192
281
  scored.reject { |_, sim, _| sim.zero? }
193
- .sort_by { |_, _, s| -s }
282
+ .sort_by { |e, _, s| [-s, pref_index.fetch(e.name, pref_n), e.name] }
194
283
  .map(&:first)
195
284
  end
196
285
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require 'rest-client'
5
- require 'tty-spinner'
6
5
  require 'securerandom'
7
6
  require 'base64'
8
7
  require 'digest'
@@ -418,10 +417,7 @@ module PWN
418
417
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
419
418
  rest_client = browser_obj[:browser]::Request
420
419
 
421
- if spinner
422
- spin = TTY::Spinner.new(format: :dots)
423
- spin.auto_spin
424
- end
420
+ spin = PWN::Plugins::TTYSpinner.start if spinner
425
421
 
426
422
  retry_count = 0
427
423
  begin
@@ -479,7 +475,7 @@ module PWN
479
475
  raise e
480
476
  end
481
477
  ensure
482
- spin.stop if spinner
478
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
483
479
  end
484
480
  end
485
481
 
data/lib/pwn/ai/gemini.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require 'rest-client'
5
- require 'tty-spinner'
6
5
  require 'securerandom'
7
6
 
8
7
  module PWN
@@ -55,10 +54,7 @@ module PWN
55
54
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
56
55
  rest_client = browser_obj[:browser]::Request
57
56
 
58
- if spinner
59
- spin = TTY::Spinner.new(format: :dots)
60
- spin.auto_spin
61
- end
57
+ spin = PWN::Plugins::TTYSpinner.start if spinner
62
58
 
63
59
  retry_count = 0
64
60
  begin
@@ -103,7 +99,7 @@ module PWN
103
99
  raise e
104
100
  end
105
101
  ensure
106
- spin.stop if spinner
102
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
107
103
  end
108
104
  end
109
105
 
data/lib/pwn/ai/grok.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require 'rest-client'
5
- require 'tty-spinner'
6
5
  require 'uri'
7
6
  require 'base64'
8
7
 
@@ -424,10 +423,7 @@ module PWN
424
423
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
425
424
  rest_client = browser_obj[:browser]::Request
426
425
 
427
- if spinner
428
- spin = TTY::Spinner.new(format: :dots)
429
- spin.auto_spin
430
- end
426
+ spin = PWN::Plugins::TTYSpinner.start if spinner
431
427
 
432
428
  retry_count = 0
433
429
  begin
@@ -495,7 +491,7 @@ module PWN
495
491
  end
496
492
  "#{e.message}: #{e.response}"
497
493
  ensure
498
- spin.stop if spinner
494
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
499
495
  end
500
496
 
501
497
  # Supported Method Parameters::
@@ -695,6 +691,9 @@ module PWN
695
691
  temp = opts[:temp].to_f
696
692
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
697
693
 
694
+ spinner = opts[:spinner]
695
+ timeout = opts[:timeout]
696
+
698
697
  conv_id = nil
699
698
  if defined?(PWN::AI::Agent::PromptCache) &&
700
699
  PWN::AI::Agent::PromptCache.enabled?(engine: :grok)
@@ -722,8 +721,8 @@ module PWN
722
721
  http_method: :post,
723
722
  rest_call: 'chat/completions',
724
723
  http_body: http_body,
725
- timeout: opts[:timeout],
726
- spinner: opts[:spinner],
724
+ timeout: timeout,
725
+ spinner: spinner,
727
726
  quiet: opts[:quiet],
728
727
  headers: (conv_id ? { 'x-grok-conv-id' => conv_id } : nil)
729
728
  )
data/lib/pwn/ai/ollama.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
- require 'tty-spinner'
7
6
 
8
7
  module PWN
9
8
  module AI
@@ -75,10 +74,7 @@ module PWN
75
74
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
76
75
  rest_client = browser_obj[:browser]::Request
77
76
 
78
- if spinner
79
- spin = TTY::Spinner.new(format: :dots)
80
- spin.auto_spin
81
- end
77
+ spin = PWN::Plugins::TTYSpinner.start if spinner
82
78
 
83
79
  retry_count = 0
84
80
  begin
@@ -198,7 +194,7 @@ module PWN
198
194
  raise e
199
195
  end
200
196
  ensure
201
- spin.stop if spinner
197
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
202
198
  end
203
199
 
204
200
  # Pull a user-visible answer out of a reasoning/"thinking" channel.
@@ -3,7 +3,6 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
- require 'tty-spinner'
7
6
  require 'digest'
8
7
  require 'uri'
9
8
 
@@ -460,10 +459,7 @@ module PWN
460
459
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
461
460
  rest_client = browser_obj[:browser]::Request
462
461
 
463
- if spinner
464
- spin = TTY::Spinner.new(format: :dots)
465
- spin.auto_spin
466
- end
462
+ spin = PWN::Plugins::TTYSpinner.start if spinner
467
463
 
468
464
  retry_count = 0
469
465
  begin
@@ -534,7 +530,7 @@ module PWN
534
530
  end
535
531
  "#{e.message}: #{e.response}"
536
532
  ensure
537
- spin.stop if spinner
533
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
538
534
  end
539
535
 
540
536
  # Supported Method Parameters::
@@ -3,7 +3,6 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
- require 'tty-spinner'
7
6
 
8
7
  module PWN
9
8
  module AI
@@ -87,10 +86,7 @@ module PWN
87
86
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
88
87
  rest_client = browser_obj[:browser]::Request
89
88
 
90
- if spinner
91
- spin = TTY::Spinner.new(format: :dots)
92
- spin.auto_spin
93
- end
89
+ spin = PWN::Plugins::TTYSpinner.start if spinner
94
90
 
95
91
  retry_count = 0
96
92
  begin
@@ -226,7 +222,7 @@ module PWN
226
222
  raise e
227
223
  end
228
224
  ensure
229
- spin.stop if spinner
225
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
230
226
  end
231
227
 
232
228
  # Pull a user-visible answer out of a reasoning/"thinking" channel.
@@ -3,7 +3,6 @@
3
3
  require 'base64'
4
4
  require 'json'
5
5
  require 'rest-client'
6
- require 'tty-spinner'
7
6
  require 'date'
8
7
 
9
8
  module PWN
@@ -54,10 +53,7 @@ module PWN
54
53
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
55
54
  rest_client = browser_obj[:browser]::Request
56
55
 
57
- if spinner
58
- spin = TTY::Spinner.new(format: :dots)
59
- spin.auto_spin
60
- end
56
+ spin = PWN::Plugins::TTYSpinner.start if spinner
61
57
 
62
58
  retries = 0
63
59
  case http_method.to_s.scrub.downcase.to_sym
@@ -109,7 +105,7 @@ module PWN
109
105
  rescue StandardError => e
110
106
  raise e
111
107
  ensure
112
- spin.stop if spinner
108
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
113
109
  end
114
110
 
115
111
  private_class_method def self.btc_rpc_call(opts = {})
@@ -2,7 +2,6 @@
2
2
 
3
3
  require 'json'
4
4
  require 'rest-client'
5
- require 'tty-spinner'
6
5
 
7
6
  module PWN
8
7
  module Blockchain
@@ -41,10 +40,7 @@ module PWN
41
40
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
42
41
  rest_client = browser_obj[:browser]::Request
43
42
 
44
- if spinner
45
- spin = TTY::Spinner.new(format: :dots)
46
- spin.auto_spin
47
- end
43
+ spin = PWN::Plugins::TTYSpinner.start if spinner
48
44
 
49
45
  retries = 0
50
46
  case http_method
@@ -96,7 +92,7 @@ module PWN
96
92
  rescue StandardError => e
97
93
  raise e
98
94
  ensure
99
- spin.stop if spinner
95
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
100
96
  end
101
97
 
102
98
  # Supported Method Parameters::
data/lib/pwn/config.rb CHANGED
@@ -160,6 +160,7 @@ module PWN
160
160
  # LLM tangible-task decomposition for autonomous goals (default on).
161
161
  task_summary_llm: nil,
162
162
  tool_router: nil, # nil = auto (true when :active is local :ollama/:openwebui) — cuts ~11k→~3k schema tokens
163
+ tool_preference: %w[memory_recall sessions_view pwn_eval shell mistakes_record mistakes_resolve learning_note_outcome memory_remember],
163
164
  escalation_persona: 'escalator', # Swarm persona for frontier corrective hints when a local model is stuck
164
165
  # sample E3 verify_as_reward: true|false|nil(auto: ~10% local / always frontier when CLAIM_RX hits)
165
166
  verify_as_reward: nil,
@@ -568,9 +569,9 @@ module PWN
568
569
  PWN::Cron.install_defaults if defined?(PWN::Cron) && PWN::Cron.respond_to?(:install_defaults)
569
570
 
570
571
  # Fill missing ai.agent / ai.ollama knobs from code defaults so older
571
- # vault files pick up Ollama/RL fixes (tool_router nil-auto, local
572
- # introspect policy, history compaction, result_max, escalation
573
- # default) without requiring a full pwn-vault rewrite. Explicit
572
+ # vault files pick up Ollama/RL fixes (tool_router nil-auto, tool_preference
573
+ # memory_recall-first, local introspect policy, history compaction, result_max,
574
+ # escalation default) without requiring a full pwn-vault rewrite. Explicit
574
575
  # vault values always win — deep_merge only supplies ABSENT keys.
575
576
  merge_ai_defaults!(env: env)
576
577
 
@@ -3,7 +3,6 @@
3
3
  require 'cgi'
4
4
  require 'json'
5
5
  require 'securerandom'
6
- require 'tty-spinner'
7
6
 
8
7
  module PWN
9
8
  module Plugins
@@ -45,8 +44,7 @@ module PWN
45
44
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
46
45
  rest_client = browser_obj[:browser]::Request
47
46
 
48
- spinner = TTY::Spinner.new
49
- spinner.auto_spin
47
+ spinner = PWN::Plugins::TTYSpinner.start
50
48
 
51
49
  max_request_attempts = 3
52
50
  tot_request_attempts ||= 1
@@ -111,7 +109,7 @@ module PWN
111
109
  rescue StandardError => e
112
110
  raise e
113
111
  ensure
114
- spinner.stop unless spinner.nil?
112
+ PWN::Plugins::TTYSpinner.stop(spin: spinner)
115
113
  end
116
114
 
117
115
  # Supported Method Parameters::
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'tty-spinner'
5
4
 
6
5
  module PWN
7
6
  module Plugins
@@ -47,8 +46,7 @@ module PWN
47
46
  browser_obj = PWN::Plugins::TransparentBrowser.open(browser_type: :rest)
48
47
  rest_client = browser_obj[:browser]::Request
49
48
 
50
- spinner = TTY::Spinner.new
51
- spinner.auto_spin
49
+ spinner = PWN::Plugins::TTYSpinner.start
52
50
 
53
51
  max_request_attempts = 3
54
52
  tot_request_attempts ||= 1
@@ -127,7 +125,7 @@ module PWN
127
125
  rescue StandardError => e
128
126
  raise e
129
127
  ensure
130
- spinner.stop unless spinner.nil?
128
+ PWN::Plugins::TTYSpinner.stop(spin: spinner)
131
129
  end
132
130
 
133
131
  # Supported Method Parameters::