Module: PWN::AI::Agent::Learning
- Defined in:
- lib/pwn/ai/agent/learning.rb
Overview
PWN::AI::Agent::Learning is the self-improvement engine that closes the pwn-ai feedback loop. It captures task outcomes, mines session transcripts for durable lessons, promotes successful workflows into reusable skills, and prunes / consolidates persistent memory so the agent gets sharper over time instead of accumulating noise.
Data flows:
Loop.run --(tool telemetry)--> Metrics.record
Loop.run --(final answer)----> Learning.auto_introspect (opt-in)
model --(tool calls)------> learning_note_outcome / _distill_skill
PromptBuilder <----------------- Learning.to_context + Metrics.to_context
Everything is file-backed under ~/.pwn so it survives across REPL restarts and is shared by every future session.
Constant Summary collapse
- LEARNING_FILE =
File.join(Dir.home, '.pwn', 'learning.jsonl')
- FINETUNE_DIR =
File.join(Dir.home, '.pwn', 'finetune')
- MAX_MEMORY_ENTRIES =
200- CLAIM_RX =
/CVE-\d{4}-\d{4,7}|\b[A-Za-z][\w.+-]{2,}\s+v?\d+\.\d+(?:\.\d+)?\b/- FAILURE_FINAL_RX =
/\[pwn-ai\] (iteration budget exhausted|engine returned no message)|\b(i (was )?unable to|i could not|i couldn'?t|cannot proceed|failed to)\b/i
Class Method Summary collapse
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.auto_introspect(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.auto_introspect( session_id: 'required - id of the just-completed session', request: 'optional - original user request (for outcome logging)', final: 'optional - final assistant answer (for outcome logging)' ).
-
.consolidate(opts = {}) ⇒ Object
- Supported Method Parameters
removed = PWN::AI::Agent::Learning.consolidate( max_entries: 'optional - hard cap on PWN::Memory size (default MAX_MEMORY_ENTRIES)' ).
-
.distill_skill(opts = {}) ⇒ Object
- Supported Method Parameters
skill = PWN::AI::Agent::Learning.distill_skill( name: 'required - snake_case name for the new skill', session_id: 'optional - PWN::Sessions id to mine (uses its transcript)', content: 'optional - explicit markdown body; overrides transcript mining', references: 'optional - Array of reference URLs / CWE / CVE / ATT&CK ids' ).
-
.exemplars_for(opts = {}) ⇒ Object
- Supported Method Parameters
msgs = PWN::AI::Agent::Learning.exemplars_for( request: 'required - current user request', limit: 'optional - max exemplar traces to return (default 1)', max_msgs: 'optional - cap on messages per exemplar (default 6)' ).
-
.export_finetune(opts = {}) ⇒ Object
- Supported Method Parameters
info = PWN::AI::Agent::Learning.export_finetune( format: 'optional - :sharegpt (default) | :openai_jsonl', out: 'optional - output path (default ~/.pwn/finetune/pwn-YYYYMMDD.jsonl)', min_tools: 'optional - only sessions with >= N tool messages (default 1)' ).
-
.flip_last_outcome(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.flip_last_outcome( session_id: 'optional - only flip if the newest outcome belongs to this session', reason: 'optional - why it is being flipped (usually the user correction text)' ).
-
.help ⇒ Object
Display Usage for this Module.
-
.note_outcome(opts = {}) ⇒ Object
- Supported Method Parameters
entry = PWN::AI::Agent::Learning.note_outcome( task: 'required - short description of what was attempted', success: 'required - Boolean, did the attempt achieve its goal', details: 'optional - free-form notes / error / evidence', session_id: 'optional - PWN::Sessions id this outcome belongs to', tags: 'optional - Array of String labels for later retrieval' ).
-
.outcomes(opts = {}) ⇒ Object
- Supported Method Parameters
rows = PWN::AI::Agent::Learning.outcomes( limit: 'optional - max entries returned newest-first (default 50)', success: 'optional - filter by Boolean outcome', tag: 'optional - filter by tag substring' ).
-
.reflect(opts = {}) ⇒ Object
- Supported Method Parameters
report = PWN::AI::Agent::Learning.reflect( session_id: 'required - PWN::Sessions id to analyse', dry_run: 'optional - when true, do not write to Memory/Skills (default false)' ).
-
.reset ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.reset.
-
.stats ⇒ Object
- Supported Method Parameters
stats = PWN::AI::Agent::Learning.stats.
-
.to_context(opts = {}) ⇒ Object
- Supported Method Parameters
ctx = PWN::AI::Agent::Learning.to_context( limit: 'optional - number of recent outcomes to surface (default 5)' ).
Class Method Details
.authors ⇒ Object
- Author(s)
0day Inc. support@0dayinc.com
532 533 534 |
# File 'lib/pwn/ai/agent/learning.rb', line 532 public_class_method def self. "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n" end |
.auto_introspect(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.auto_introspect( session_id: 'required - id of the just-completed session', request: 'optional - original user request (for outcome logging)', final: 'optional - final assistant answer (for outcome logging)' )
Called by Loop.run when PWN::Env[:agent][:auto_introspect] is truthy. Never raises — learning must not break the primary loop.
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/pwn/ai/agent/learning.rb', line 283 public_class_method def self.auto_introspect(opts = {}) session_id = opts[:session_id] return unless session_id return unless auto_introspect_enabled? ok = infer_success(session_id: session_id, final: opts[:final]) note_outcome( task: opts[:request].to_s[0, 120], success: ok, details: opts[:final].to_s[0, 300], session_id: session_id, tags: %w[auto loop] ) reflect(session_id: session_id) fact_check_local_final(final: opts[:final]) Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection) rescue StandardError => e warn "[pwn-ai/learning] auto_introspect swallowed: #{e.class}: #{e.}" nil end |
.consolidate(opts = {}) ⇒ Object
- Supported Method Parameters
removed = PWN::AI::Agent::Learning.consolidate( max_entries: 'optional - hard cap on PWN::Memory size (default MAX_MEMORY_ENTRIES)' )
Deduplicates near-identical lesson values and prunes the oldest entries once the cap is exceeded so the injected MEMORY block stays high-signal.
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/pwn/ai/agent/learning.rb', line 344 public_class_method def self.consolidate(opts = {}) cap = opts[:max_entries] || MAX_MEMORY_ENTRIES return { removed: 0 } unless defined?(PWN::Memory) mem = PWN::Memory.load seen = {} removed = [] mem.each do |k, v| sig = Digest::SHA256.hexdigest(v[:value].to_s.strip.downcase)[0, 16] if seen[sig] removed << k else seen[sig] = k end end removed.each { |k| mem.delete(k) } if mem.size > cap sorted = mem.sort_by { |_k, v| v[:timestamp].to_s } drop = sorted.first(mem.size - cap).map(&:first) drop.each { |k| mem.delete(k) } removed.concat(drop) end PWN::Memory.save(mem: mem) { removed: removed.length, remaining: mem.size } end |
.distill_skill(opts = {}) ⇒ Object
- Supported Method Parameters
skill = PWN::AI::Agent::Learning.distill_skill( name: 'required - snake_case name for the new skill', session_id: 'optional - PWN::Sessions id to mine (uses its transcript)', content: 'optional - explicit markdown body; overrides transcript mining', references: 'optional - Array of reference URLs / CWE / CVE / ATT&CK ids' )
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/pwn/ai/agent/learning.rb', line 216 public_class_method def self.distill_skill(opts = {}) name = opts[:name].to_s.gsub(/[^a-z0-9_-]/i, '_') raise 'ERROR: name is required' if name.empty? body = opts[:content].to_s body = build_skill_from_session(session_id: opts[:session_id], name: name) if body.strip.empty? && opts[:session_id] raise 'ERROR: content or session_id is required' if body.strip.empty? refs = Array(opts[:references]).map(&:to_s).map(&:strip).reject(&:empty?).uniq unless refs.empty? body = "---\nreferences:\n#{refs.map { |r| " - #{r}" }.join("\n")}\n---\n#{body}" unless body.start_with?("---\n") body = "#{body.rstrip}\n\n## References\n#{refs.map { |r| "- #{r}" }.join("\n")}\n" unless body =~ /^\#{1,3}\s*References\s*$/i end dir = skills_dir FileUtils.mkdir_p(dir) path = File.join(dir, "#{name}.md") File.write(path, body) PWN::Config.load_skills(pwn_skills_path: dir) if defined?(PWN::Config) && PWN::Config.respond_to?(:load_skills) note_outcome(task: "distill_skill:#{name}", success: true, details: "Saved #{path}", tags: %w[skill auto]) { saved: true, name: name, path: path, bytes: body.bytesize, references: refs } end |
.exemplars_for(opts = {}) ⇒ Object
- Supported Method Parameters
msgs = PWN::AI::Agent::Learning.exemplars_for( request: 'required - current user request', limit: 'optional - max exemplar traces to return (default 1)', max_msgs: 'optional - cap on messages per exemplar (default 6)' )
Retrieval-augmented BEHAVIOUR: keyword-matches request against prior successful outcomes in learning.jsonl, loads the matching session, and compresses its (user, tool, assistant) trace into a short few-shot exemplar Loop.run splices between system and user. Local models are dramatically better with 1 concrete example than with 25 abstract lessons.
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/pwn/ai/agent/learning.rb', line 146 public_class_method def self.exemplars_for(opts = {}) request = opts[:request].to_s.downcase limit = (opts[:limit] || 1).to_i max_msgs = (opts[:max_msgs] || 6).to_i return [] if request.strip.empty? tokens = request.scan(/[a-z0-9_]{3,}/).uniq return [] if tokens.empty? hits = outcomes(limit: 500, success: true) .reject { |r| r[:session_id].to_s.empty? } .map { |r| [r, tokens.count { |t| r[:task].to_s.downcase.include?(t) }] } .reject { |_, s| s.zero? } .sort_by { |_, s| -s } .first(limit) .map(&:first) hits.flat_map { |r| compress_exemplar(session_id: r[:session_id], max_msgs: max_msgs) } rescue StandardError [] end |
.export_finetune(opts = {}) ⇒ Object
- Supported Method Parameters
info = PWN::AI::Agent::Learning.export_finetune( format: 'optional - :sharegpt (default) | :openai_jsonl', out: 'optional - output path (default ~/.pwn/finetune/pwn-YYYYMMDD.jsonl)', min_tools: 'optional - only sessions with >= N tool messages (default 1)' )
Turns the learning corpus into a supervised dataset: every session
whose learning.jsonl outcome is success:true becomes one training
sample (system, user, assistant/tool_calls, tool, ..., final). Pair
with a weekly PWN::Cron job that runs ollama create qwen-pwn -f Modelfile over the export - the only path to ACTUAL parity with a
frontier model, because it changes the weights not just the scaffold.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/pwn/ai/agent/learning.rb', line 182 public_class_method def self.export_finetune(opts = {}) fmt = (opts[:format] || :sharegpt).to_sym min_tools = (opts[:min_tools] || 1).to_i FileUtils.mkdir_p(FINETUNE_DIR) out = opts[:out] || File.join(FINETUNE_DIR, "pwn-#{Time.now.utc.strftime('%Y%m%d')}.jsonl") sids = outcomes(limit: 10_000, success: true).map { |r| r[:session_id] }.compact.uniq rows = 0 File.open(out, 'w') do |f| sids.each do |sid| t = PWN::Sessions.load(session_id: sid) next if t.count { |e| e[:role].to_s == 'tool' } < min_tools conv = t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } } .reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') } line = case fmt when :openai_jsonl then { messages: conv } else { conversations: conv.map { |m| { from: sharegpt_role(role: m[:role]), value: m[:content] } } } end f.puts(JSON.generate(line)) rows += 1 end end { path: out, format: fmt, sessions: sids.length, samples: rows, bytes: File.size(out) } end |
.flip_last_outcome(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.flip_last_outcome( session_id: 'optional - only flip if the newest outcome belongs to this session', reason: 'optional - why it is being flipped (usually the user correction text)' )
Rewrites the most-recently-appended learning.jsonl entry from success:true to success:false. Called by Mistakes.check_user_correction when the user's next message rejects the previous answer, so the 100 %-success illusion is broken and the failure enters the corpus.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/pwn/ai/agent/learning.rb', line 315 public_class_method def self.flip_last_outcome(opts = {}) return { flipped: false } unless File.exist?(LEARNING_FILE) lines = File.readlines(LEARNING_FILE) return { flipped: false } if lines.empty? last = JSON.parse(lines.last, symbolize_names: true) return { flipped: false } if opts[:session_id] && last[:session_id] && last[:session_id] != opts[:session_id] return { flipped: false } unless last[:success] last[:success] = false last[:flipped_by] = 'user_correction' last[:details] = "#{last[:details]} | CORRECTED: #{opts[:reason].to_s[0, 200]}".strip lines[-1] = "#{JSON.generate(last)}\n" File.write(LEARNING_FILE, lines.join) { flipped: true, id: last[:id] } rescue StandardError { flipped: false } end |
.help ⇒ Object
Display Usage for this Module
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/pwn/ai/agent/learning.rb', line 538 public_class_method def self.help puts <<~USAGE USAGE: PWN::AI::Agent::Learning.note_outcome(task: 'nmap sweep 10.0.0.0/24', success: true, details: '12 hosts up') PWN::AI::Agent::Learning.outcomes(limit: 20, success: false) PWN::AI::Agent::Learning.reflect(session_id: sid) # LLM or heuristic → PWN::Memory PWN::AI::Agent::Learning.auto_introspect(session_id: sid, request: req, final: text) PWN::AI::Agent::Learning.distill_skill(name: 'quick_recon', session_id: sid) PWN::AI::Agent::Learning.exemplars_for(request: 'nmap sweep 10/8') # few-shot for Loop.run PWN::AI::Agent::Learning.export_finetune(format: :sharegpt) # -> ~/.pwn/finetune/*.jsonl PWN::AI::Agent::Learning.consolidate(max_entries: 200) # dedupe + prune Memory PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder PWN::AI::Agent::Learning.stats PWN::AI::Agent::Learning.reset Enable end-of-run auto-learning with: PWN::Env[:ai][:agent][:auto_introspect] = true #{self}.authors USAGE end |
.note_outcome(opts = {}) ⇒ Object
- Supported Method Parameters
entry = PWN::AI::Agent::Learning.note_outcome( task: 'required - short description of what was attempted', success: 'required - Boolean, did the attempt achieve its goal', details: 'optional - free-form notes / error / evidence', session_id: 'optional - PWN::Sessions id this outcome belongs to', tags: 'optional - Array of String labels for later retrieval' )
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/pwn/ai/agent/learning.rb', line 40 public_class_method def self.note_outcome(opts = {}) task = opts[:task].to_s success = opts[:success] ? true : false raise 'ERROR: task is required' if task.strip.empty? entry = { id: Digest::SHA256.hexdigest("#{task}-#{Time.now.to_f}")[0, 12], task: task, success: success, details: opts[:details].to_s[0, 2_000], session_id: opts[:session_id], tags: Array(opts[:tags]).map(&:to_s), timestamp: Time.now.utc.iso8601 } FileUtils.mkdir_p(File.dirname(LEARNING_FILE)) File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) } key = :"lesson_#{entry[:id]}" cat = :lesson val = "#{success ? 'SUCCESS' : 'FAILURE'}: #{task} — #{opts[:details].to_s.strip[0, 200]}" PWN::Memory.remember(key: key, value: val, category: cat) if defined?(PWN::Memory) entry end |
.outcomes(opts = {}) ⇒ Object
- Supported Method Parameters
rows = PWN::AI::Agent::Learning.outcomes( limit: 'optional - max entries returned newest-first (default 50)', success: 'optional - filter by Boolean outcome', tag: 'optional - filter by tag substring' )
72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/pwn/ai/agent/learning.rb', line 72 public_class_method def self.outcomes(opts = {}) limit = opts[:limit] || 50 want_ok = opts.key?(:success) ? !opts[:success].nil? && opts[:success] != false : nil tag = opts[:tag].to_s.downcase return [] unless File.exist?(LEARNING_FILE) rows = File.readlines(LEARNING_FILE).map do |l| JSON.parse(l, symbolize_names: true) rescue StandardError nil end rows.compact! rows.select! { |r| r[:success] == want_ok } unless want_ok.nil? rows.select! { |r| Array(r[:tags]).any? { |t| t.to_s.downcase.include?(tag) } } unless tag.empty? rows.reverse.first(limit) end |
.reflect(opts = {}) ⇒ Object
- Supported Method Parameters
report = PWN::AI::Agent::Learning.reflect( session_id: 'required - PWN::Sessions id to analyse', dry_run: 'optional - when true, do not write to Memory/Skills (default false)' )
Uses PWN::AI::Agent::Reflect (when available) to LLM-summarise the session into structured lessons. Falls back to a heuristic extractor when module_reflection is disabled so learning never stops.
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/pwn/ai/agent/learning.rb', line 249 public_class_method def self.reflect(opts = {}) session_id = opts[:session_id] dry_run = opts[:dry_run] ? true : false raise 'ERROR: session_id is required' if session_id.to_s.empty? transcript = PWN::Sessions.load(session_id: session_id) return { session_id: session_id, lessons: [], reason: 'empty transcript' } if transcript.empty? lessons = introspective_lessons(transcript: transcript) lessons = heuristic_lessons(transcript: transcript) if lessons.empty? saved = [] lessons.each do |l| next if l.to_s.strip.empty? key = :"reflect_#{session_id}_#{Digest::SHA256.hexdigest(l)[0, 8]}" PWN::Memory.remember(key: key, value: l, category: :lesson) unless dry_run saved << { key: key, lesson: l } end consolidate unless dry_run { session_id: session_id, lessons: saved, count: saved.length, dry_run: dry_run } end |
.reset ⇒ Object
- Supported Method Parameters
PWN::AI::Agent::Learning.reset
374 375 376 377 |
# File 'lib/pwn/ai/agent/learning.rb', line 374 public_class_method def self.reset FileUtils.rm_f(LEARNING_FILE) { cleared: true } end |
.stats ⇒ Object
- Supported Method Parameters
stats = PWN::AI::Agent::Learning.stats
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/pwn/ai/agent/learning.rb', line 92 public_class_method def self.stats rows = outcomes(limit: 10_000) total = rows.length ok = rows.count { |r| r[:success] } skills = defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) ? PWN::Skills.keys.length : 0 mem = defined?(PWN::Memory) ? PWN::Memory.load.keys.length : 0 { total_outcomes: total, successes: ok, failures: total - ok, success_rate: total.positive? ? (ok.to_f / total).round(3) : 0.0, skills_known: skills, memory_entries: mem, tool_metrics: (Metrics.summary(limit: 5) if defined?(Metrics)), extrospection: (Extrospection.stats if defined?(Extrospection)) } end |
.to_context(opts = {}) ⇒ Object
- Supported Method Parameters
ctx = PWN::AI::Agent::Learning.to_context( limit: 'optional - number of recent outcomes to surface (default 5)' )
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/pwn/ai/agent/learning.rb', line 115 public_class_method def self.to_context(opts = {}) limit = opts[:limit] || 5 rows = outcomes(limit: limit) fails = outcomes(limit: 200, success: false).first(limit) return '' if rows.empty? && fails.empty? fmt = lambda do |r| flag = r[:success] ? '✓' : '✗' " #{flag} #{r[:task].to_s[0, 100]} (#{r[:timestamp]})" end s = stats hdr = "RECENT OUTCOMES (success_rate=#{(s[:success_rate] * 100).round(1)}% over #{s[:total_outcomes]} attempts)" out = "#{hdr}\n#{rows.map(&fmt).join("\n")}\n" out += "RECENT FAILURES (learn from these — do not repeat)\n#{fails.map(&fmt).join("\n")}\n" unless fails.empty? "#{out}\n" end |