pwn 0.5.677 → 0.5.680

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 (45) 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/loop.rb +42 -5
  23. data/lib/pwn/ai/agent/policy.rb +9 -3
  24. data/lib/pwn/ai/agent/registry.rb +95 -6
  25. data/lib/pwn/ai/anthropic.rb +3 -6
  26. data/lib/pwn/ai/gemini.rb +3 -6
  27. data/lib/pwn/ai/grok.rb +8 -8
  28. data/lib/pwn/ai/ollama.rb +3 -6
  29. data/lib/pwn/ai/open_ai.rb +3 -6
  30. data/lib/pwn/ai/open_web_ui.rb +3 -6
  31. data/lib/pwn/blockchain/btc.rb +2 -6
  32. data/lib/pwn/blockchain/eth.rb +2 -6
  33. data/lib/pwn/config.rb +4 -3
  34. data/lib/pwn/cron.rb +1 -1
  35. data/lib/pwn/plugins/black_duck_binary_analysis.rb +2 -4
  36. data/lib/pwn/plugins/jira_data_center.rb +2 -4
  37. data/lib/pwn/plugins/repl.rb +6 -6
  38. data/lib/pwn/plugins/tty_spinner.rb +71 -0
  39. data/lib/pwn/plugins.rb +1 -0
  40. data/lib/pwn/version.rb +1 -1
  41. data/spec/lib/pwn/ai/agent/registry_spec.rb +6 -0
  42. data/spec/lib/pwn/ai/grok_spec.rb +7 -0
  43. data/spec/lib/pwn/plugins/tty_spinner_spec.rb +13 -0
  44. data/third_party/pwn_rdoc.jsonl +8 -2
  45. 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
 
@@ -1013,7 +1013,7 @@ module PWN
1013
1013
  cwt_opts[:tool_choice] = has_tool_result && !still_acting ? 'auto' : 'required'
1014
1014
  end
1015
1015
  end
1016
- response = mod.chat_with_tools(**cwt_opts)
1016
+ response = mod.chat_with_tools(cwt_opts)
1017
1017
  publish_usage(response: response, engine: engine)
1018
1018
  normalize_llm(response: response)
1019
1019
  else
@@ -1404,9 +1404,46 @@ module PWN
1404
1404
  private_class_method def self.answer_statement(opts = {})
1405
1405
  request = opts[:request].to_s
1406
1406
  session_id = opts[:session_id]
1407
- txt = <<~ACK.strip
1408
- Noted. No multi-step task breakdown for a general statement — ready when you have a question or a goal to accomplish.
1409
- ACK
1407
+ system_role_content = opts[:system_role_content].to_s
1408
+ engine = active_engine
1409
+ mod_name = ENGINE_MODS[engine]
1410
+ raise "ERROR: Unsupported AI engine for agent loop: #{engine}" unless mod_name
1411
+
1412
+ mod = Object.const_get(mod_name)
1413
+ q_sys = <<~SYS
1414
+ #{system_role_content}
1415
+
1416
+ INTENT: STATEMENT (this turn only)
1417
+ The user is making a statement — not an autonomous multi-step goal.
1418
+ Respond concisely in plain US English. Do NOT call tools unless a
1419
+ single factual lookup is strictly required and already present in
1420
+ context. Do NOT plan multi-step work. Do NOT invent task traces,
1421
+ planner monologue, rubocop, rake, or live recon.
1422
+ SYS
1423
+
1424
+ txt =
1425
+ if mod.respond_to?(:chat)
1426
+ r = mod.chat(
1427
+ request: request,
1428
+ system_role_content: q_sys,
1429
+ spinner: true
1430
+ )
1431
+ if r.is_a?(Hash)
1432
+ (r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text) || r[:content]).to_s
1433
+ else
1434
+ r.to_s
1435
+ end
1436
+ else
1437
+ messages = [
1438
+ { role: 'system', content: q_sys },
1439
+ { role: 'user', content: request }
1440
+ ]
1441
+ msg = call_engine(messages: messages, tools: nil)
1442
+ msg.is_a?(Hash) ? msg[:content].to_s : msg.to_s
1443
+ end
1444
+
1445
+ txt = txt.to_s.strip
1446
+ txt = 'I do not have enough context to respond to your statement.' if txt.empty?
1410
1447
 
1411
1448
  append_session(session_id: session_id, role: 'user', content: request)
1412
1449
  append_session(session_id: session_id, role: 'assistant', content: txt)
@@ -1415,7 +1452,7 @@ module PWN
1415
1452
  session_id: session_id,
1416
1453
  request: request,
1417
1454
  final: txt,
1418
- predicted: 0.9,
1455
+ predicted: 0.85,
1419
1456
  plan: [],
1420
1457
  ts_state: nil
1421
1458
  )
@@ -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,8 @@ 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
421
+ spin.auto_spin if spinner
425
422
 
426
423
  retry_count = 0
427
424
  begin
@@ -479,7 +476,7 @@ module PWN
479
476
  raise e
480
477
  end
481
478
  ensure
482
- spin.stop if spinner
479
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
483
480
  end
484
481
  end
485
482
 
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,8 @@ 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
58
+ spin.auto_spin if spinner
62
59
 
63
60
  retry_count = 0
64
61
  begin
@@ -103,7 +100,7 @@ module PWN
103
100
  raise e
104
101
  end
105
102
  ensure
106
- spin.stop if spinner
103
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
107
104
  end
108
105
  end
109
106
 
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,8 @@ 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
427
+ spin.auto_spin if spinner
431
428
 
432
429
  retry_count = 0
433
430
  begin
@@ -495,7 +492,7 @@ module PWN
495
492
  end
496
493
  "#{e.message}: #{e.response}"
497
494
  ensure
498
- spin.stop if spinner
495
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
499
496
  end
500
497
 
501
498
  # Supported Method Parameters::
@@ -695,6 +692,9 @@ module PWN
695
692
  temp = opts[:temp].to_f
696
693
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
697
694
 
695
+ spinner = opts[:spinner]
696
+ timeout = opts[:timeout]
697
+
698
698
  conv_id = nil
699
699
  if defined?(PWN::AI::Agent::PromptCache) &&
700
700
  PWN::AI::Agent::PromptCache.enabled?(engine: :grok)
@@ -722,8 +722,8 @@ module PWN
722
722
  http_method: :post,
723
723
  rest_call: 'chat/completions',
724
724
  http_body: http_body,
725
- timeout: opts[:timeout],
726
- spinner: opts[:spinner],
725
+ timeout: timeout,
726
+ spinner: spinner,
727
727
  quiet: opts[:quiet],
728
728
  headers: (conv_id ? { 'x-grok-conv-id' => conv_id } : nil)
729
729
  )
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,8 @@ 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
78
+ spin.auto_spin if spinner
82
79
 
83
80
  retry_count = 0
84
81
  begin
@@ -198,7 +195,7 @@ module PWN
198
195
  raise e
199
196
  end
200
197
  ensure
201
- spin.stop if spinner
198
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
202
199
  end
203
200
 
204
201
  # 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,8 @@ 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
463
+ spin.auto_spin if spinner
467
464
 
468
465
  retry_count = 0
469
466
  begin
@@ -534,7 +531,7 @@ module PWN
534
531
  end
535
532
  "#{e.message}: #{e.response}"
536
533
  ensure
537
- spin.stop if spinner
534
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
538
535
  end
539
536
 
540
537
  # 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,8 @@ 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
90
+ spin.auto_spin if spinner
94
91
 
95
92
  retry_count = 0
96
93
  begin
@@ -226,7 +223,7 @@ module PWN
226
223
  raise e
227
224
  end
228
225
  ensure
229
- spin.stop if spinner
226
+ PWN::Plugins::TTYSpinner.stop(spin: spin)
230
227
  end
231
228
 
232
229
  # 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::