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_reflect (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')
MAX_MEMORY_ENTRIES =
200

Class Method Summary collapse

Class Method Details

.authorsObject

Author(s)

0day Inc. support@0dayinc.com



349
350
351
# File 'lib/pwn/ai/agent/learning.rb', line 349

public_class_method def self.authors
  "AUTHOR(S):\n  0day Inc. <support@0dayinc.com>\n"
end

.auto_reflect(opts = {}) ⇒ Object

Supported Method Parameters

PWN::AI::Agent::Learning.auto_reflect( 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_reflect] is truthy. Never raises — learning must not break the primary loop.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/pwn/ai/agent/learning.rb', line 201

public_class_method def self.auto_reflect(opts = {})
  session_id = opts[:session_id]
  return unless session_id
  return unless auto_reflect_enabled?

  note_outcome(
    task: opts[:request].to_s[0, 120],
    success: !opts[:final].to_s.strip.empty?,
    details: opts[:final].to_s[0, 300],
    session_id: session_id,
    tags: %w[auto loop]
  )
  reflect(session_id: session_id)
rescue StandardError => e
  warn "[pwn-ai/learning] auto_reflect swallowed: #{e.class}: #{e.message}"
  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.



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/pwn/ai/agent/learning.rb', line 228

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' )



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/pwn/ai/agent/learning.rb', line 134

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

.helpObject

Display Usage for this Module



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/pwn/ai/agent/learning.rb', line 355

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_reflect(session_id: sid, request: req, final: text)
      PWN::AI::Agent::Learning.distill_skill(name: 'quick_recon', session_id: sid)
      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_reflect] = 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' )



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/pwn/ai/agent/learning.rb', line 38

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' )



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/pwn/ai/agent/learning.rb', line 70

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::Introspection (when available) to LLM-summarise the session into structured lessons. Falls back to a heuristic extractor when introspection is disabled so learning never stops.



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/pwn/ai/agent/learning.rb', line 167

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

.resetObject

Supported Method Parameters

PWN::AI::Agent::Learning.reset



258
259
260
261
# File 'lib/pwn/ai/agent/learning.rb', line 258

public_class_method def self.reset
  FileUtils.rm_f(LEARNING_FILE)
  { cleared: true }
end

.statsObject

Supported Method Parameters

stats = PWN::AI::Agent::Learning.stats



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/pwn/ai/agent/learning.rb', line 90

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))
  }
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)' )



112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/pwn/ai/agent/learning.rb', line 112

public_class_method def self.to_context(opts = {})
  limit = opts[:limit] || 5
  rows = outcomes(limit: limit)
  return '' if rows.empty?

  lines = rows.map do |r|
    flag = r[:success] ? '' : ''
    "  #{flag} #{r[:task][0, 100]} (#{r[:timestamp]})"
  end
  s = stats
  hdr = "RECENT OUTCOMES (success_rate=#{(s[:success_rate] * 100).round(1)}% over #{s[:total_outcomes]} attempts)"
  "#{hdr}\n#{lines.join("\n")}\n\n"
end