pwn 0.5.663 → 0.5.665

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d14d73b9f076c18a1248f868c7febb2666758cfcf522493b850d3bc3e0a09ed2
4
- data.tar.gz: ae83a33e35025aa612b82d48d0ccab99bea2e5a4b20d1d2ffff81189233739b1
3
+ metadata.gz: d289c114e06e9e253df8f9071e23c0ed34786b946c04bf570521f808bca54bf2
4
+ data.tar.gz: c045f0f8e5df6b33a0c9927a72aca147c903010e6ff0a888fee7e2ef04b96ace
5
5
  SHA512:
6
- metadata.gz: 9d39cbfff72ee966e15990f6d2329edc0fc3671bdc7b5c7f11ebb29587d4e93f64a447f9e5bb6fde2dac84160e1f4764a9143001866c85b730681fd903541340
7
- data.tar.gz: d8f6e3f3e50605efde9a8db3fa96daf4a385d599b98a5fe787af62a1d7ed9c0232da987f7320cb9a07001208d0a21f96767530a943d819b14c43841fe0d867d8
6
+ metadata.gz: 62f913dfe7d20f3154a55a5f2db7f91589141f87b57f0736d0da7bc1f5ae2a843f3f75cb2e274539ce68b9d1ea82cfed1584f0fcf5bf054fbbb2e28764a29028
7
+ data.tar.gz: 67f7cbcb82a68a099fe659caa47477877ce3e5164f3267903ef6b4191b1103d10f558574e5378967b698b1b07f41184ac4f8291598262db31439332b70389f73
@@ -292,13 +292,21 @@ module PWN
292
292
  end
293
293
  end
294
294
  if commit && defined?(Learning)
295
+ # P29 — keep verdict tag score-aligned (same as auto_introspect).
296
+ sc = v[:score].to_f
297
+ verd = if defined?(Learning) && Learning.respond_to?(:verdict_for_score, true)
298
+ Learning.send(:verdict_for_score, score: sc).to_s
299
+ elsif sc >= 0.6 then 'solved'
300
+ elsif sc >= 0.3 then 'partial'
301
+ else 'wrong'
302
+ end
295
303
  Learning.note_outcome(
296
304
  task: req[0, 120],
297
- success: v[:score].to_f >= 0.6,
298
- score: v[:score],
299
- details: "offline_judge #{v[:verdict]}(#{v[:score]}) #{v[:rationale]}",
305
+ success: sc >= 0.6,
306
+ score: sc,
307
+ details: "offline_judge #{verd}(#{sc.round(2)}) #{v[:rationale]}",
300
308
  session_id: sid,
301
- tags: %w[offline_judge auto]
309
+ tags: ['offline_judge', 'auto', verd]
302
310
  )
303
311
  end
304
312
  scored << { session_id: sid, score: v[:score], verdict: v[:verdict] }
@@ -11,12 +11,14 @@ module PWN
11
11
  # PWN::AI::Agent::Learning is the self-improvement engine that closes
12
12
  # the pwn-ai feedback loop. It captures task outcomes, mines session
13
13
  # transcripts for durable lessons, promotes successful workflows into
14
- # reusable skills, and prunes / consolidates persistent memory so the
15
- # agent gets sharper over time instead of accumulating noise.
14
+ # reusable skills, and keeps ~/.pwn lean (memory + learning.jsonl +
15
+ # mistakes + sessions) so the agent gets sharper over time instead of
16
+ # accumulating noise.
16
17
  #
17
18
  # Data flows:
18
19
  # Loop.run --(tool telemetry)--> Metrics.record
19
20
  # Loop.run --(final answer)----> Learning.auto_introspect (opt-in)
21
+ # auto_introspect --(throttled)--> Learning.gc_stores! # ~/.pwn lean
20
22
  # model --(tool calls)------> learning_note_outcome / _distill_skill
21
23
  # PromptBuilder <----------------- Learning.to_context + Metrics.to_context
22
24
  #
@@ -34,6 +36,22 @@ module PWN
34
36
  INTROSPECT_MIN_STAGES = %i[judge note_outcome fold_judge sentinel].freeze
35
37
 
36
38
  MAX_MEMORY_ENTRIES = 200
39
+ # Lean outcome retention — keep gold RL signal, drop bulk auto noise.
40
+ MAX_OUTCOME_ROWS = 800
41
+ OUTCOME_RETAIN_DAYS = 45
42
+ OUTCOME_RECENT_DAYS = 14
43
+ OUTCOME_DETAILS_MAX = 800
44
+ GOLD_MIN_SCORE = 0.6
45
+ EXEMPLARS_POOL_MIN = 200
46
+ FAILURE_WINDOW_MIN = 200
47
+ HIGH_VALUE_TAGS = %w[
48
+ needs_human extro_verify sdr gqrx rl pwn-ai curriculum hindsight her
49
+ ].freeze
50
+ LOW_VALUE_ONLY_TAGS = %w[
51
+ auto loop partial wrong solved offline_judge plan_cover_high
52
+ ].freeze
53
+ PRUNE_EVERY_N_APPENDS = 25
54
+
37
55
  # E3/P26 — only CVE-ids or software-name + full semver (x.y.z).
38
56
  # Two-part floats ("cap 0.2", "proxy 1.0", "judge 37.0") are RL
39
57
  # metric crumbs that were scraped by verify_as_reward and flooded
@@ -85,7 +103,7 @@ module PWN
85
103
  id: Digest::SHA256.hexdigest("#{task}-#{Time.now.to_f}")[0, 12],
86
104
  task: task,
87
105
  success: success,
88
- details: opts[:details].to_s[0, 2_000],
106
+ details: opts[:details].to_s[0, OUTCOME_DETAILS_MAX],
89
107
  session_id: opts[:session_id],
90
108
  tags: Array(opts[:tags]).map(&:to_s),
91
109
  timestamp: Time.now.utc.iso8601
@@ -93,6 +111,7 @@ module PWN
93
111
  entry[:score] = opts[:score].to_f if opts.key?(:score)
94
112
  FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
95
113
  File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
114
+ maybe_prune_outcomes!
96
115
 
97
116
  # M4 — default: outcomes live in learning.jsonl ONLY.
98
117
  # M4.1 — PROCESS SOPs (rubocop/rake/spec after code changes, etc.)
@@ -160,13 +179,33 @@ module PWN
160
179
 
161
180
  public_class_method def self.to_context(opts = {})
162
181
  limit = opts[:limit] || 5
163
- rows = outcomes(limit: limit)
164
- fails = outcomes(limit: 200, success: false).first(limit)
182
+ # Fetch a wider window so prefer_primary_tasks can drop critic/red_team
183
+ # envelope rows (REQUEST:/GOAL: prefixes) without starving the block.
184
+ rows = prefer_primary_tasks(rows: outcomes(limit: limit * 4)).first(limit)
185
+ fails = prefer_primary_tasks(rows: outcomes(limit: 200, success: false))
186
+ # Do not mirror the same ids under both headings — that doubled the
187
+ # failure signal and made RECENT OUTCOMES == RECENT FAILURES when the
188
+ # last N attempts all failed (the injected block looked "stuck").
189
+ row_ids = rows.map { |r| r[:id] }.compact
190
+ fails = fails.reject { |r| row_ids.include?(r[:id]) }.first(limit)
165
191
  return '' if rows.empty? && fails.empty?
166
192
 
167
193
  fmt = lambda do |r|
168
- flag = r[:success] ? '✓' : '✗'
169
- " #{flag} #{r[:task].to_s[0, 100]} (#{r[:timestamp]})"
194
+ flag = case r[:success]
195
+ when true then '✓'
196
+ when 'soft', :soft then '∼'
197
+ else '✗'
198
+ end
199
+ score = r.key?(:score) ? format('%.2f', r[:score].to_f) : '-'
200
+ task = display_task(task: r[:task])
201
+ line = " #{flag} [#{score}] #{task} (#{r[:timestamp]})"
202
+ # Surface a one-line cause crumb so the agent can actually learn
203
+ # from failures instead of only seeing that they failed.
204
+ if r[:success] != true
205
+ crumb = cause_crumb(details: r[:details])
206
+ line += "\n cause: #{crumb}" unless crumb.empty?
207
+ end
208
+ line
170
209
  end
171
210
  s = stats
172
211
  jm = s[:judge_mean]
@@ -468,7 +507,12 @@ module PWN
468
507
  v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok) if defined?(Reward)
469
508
  v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
470
509
  v[:score] = [v[:score], 0.3].min if crit[:verdict] == :flaw
471
- ok = v[:score] >= 0.6
510
+ # P29 critic floor used to leave stale verdict=:solved at score=0.3,
511
+ # producing learning.jsonl rows tagged "solved" with success=false
512
+ # (116+ rows). Always resync verdict/success from the final score.
513
+ v[:verdict] = verdict_for_score(score: v[:score])
514
+ v[:success] = v[:score].to_f >= 0.6
515
+ ok = v[:success]
472
516
 
473
517
  # W1 pending user_correction pair
474
518
  pend = Thread.current[:pwn_pending_pref]
@@ -515,11 +559,14 @@ module PWN
515
559
  outcome_tags = ['auto', 'loop', v[:verdict].to_s]
516
560
  outcome_tags << plan_cov[:tag] if plan_cov && plan_cov[:tag]
517
561
  outcome_tags << "plan_cover=#{plan_cov[:score]}" if plan_cov && plan_cov[:total].to_i.positive?
562
+ # P29 — persist the bare user ask (strip REQUEST:/GOAL: envelopes at write time)
563
+ task_txt = display_task(task: opts[:request].to_s)
564
+ task_txt = opts[:request].to_s[0, 100] if task_txt.empty?
518
565
  note_outcome(
519
- task: opts[:request].to_s[0, 120],
566
+ task: task_txt,
520
567
  success: ok,
521
568
  score: v[:score],
522
- details: "#{v[:verdict]}(#{v[:score].round(2)}) #{v[:rationale]} | #{opts[:final].to_s[0, 200]}",
569
+ details: "#{v[:verdict]}(#{v[:score].to_f.round(2)}) #{v[:rationale]} | #{opts[:final].to_s[0, 200]}",
523
570
  session_id: session_id,
524
571
  tags: outcome_tags
525
572
  )
@@ -589,6 +636,20 @@ module PWN
589
636
  stages_skipped << :extrospect
590
637
  end
591
638
 
639
+ # Keep ~/.pwn RL stores lean on the feedback path (memory +
640
+ # learning.jsonl + mistakes + sessions). Throttled; never raises.
641
+ # Disk-only work: skip only hard budget / budget_hot.
642
+ begin
643
+ if !over_hard.call && !budget_hot && should_gc_stores?
644
+ stages_run << :lean_gc
645
+ gc_stores!(current_session_id: session_id)
646
+ elsif should_gc_stores?
647
+ stages_skipped << :lean_gc
648
+ end
649
+ rescue StandardError => e
650
+ warn "[pwn-ai/learning] post-introspect lean swallowed: #{e.class}: #{e.message}"
651
+ end
652
+
592
653
  {
593
654
  ok: ok,
594
655
  score: v[:score],
@@ -668,21 +729,44 @@ module PWN
668
729
  # confidence :heuristic auto-gen self-evicts first.
669
730
  if mem.size > cap
670
731
  now = Time.now.utc
671
- sorted = mem.sort_by do |_k, v|
732
+ scored = mem.map do |k, v|
733
+ if defined?(PWN::Memory) && PWN::Memory.respond_to?(:protected_entry?) &&
734
+ PWN::Memory.protected_entry?(key: k, entry: v)
735
+ next [k, Float::INFINITY]
736
+ end
737
+
672
738
  age_d = (now - Time.parse(v[:timestamp].to_s)) / 86_400.0
673
739
  ttl_d = (v[:ttl].to_f / 86_400.0)
674
740
  imp = (v[:importance] || 0.5).to_f.clamp(0.05, 1.0)
675
741
  conf = (v[:confidence] || (v[:source].to_s == 'human' ? 0.95 : 0.5)).to_f.clamp(0.05, 1.0)
676
742
  staleness = ttl_d.positive? ? age_d / ttl_d : age_d / 90.0
677
- -(staleness / (imp * conf))
743
+ # lower score = drop first; Infinity protected sorts last
744
+ [k, -(staleness / (imp * conf))]
678
745
  rescue StandardError
679
- 0.0
746
+ [k, 0.0]
747
+ end
748
+ # sort ascending by score so lowest (most stale/low-imp) first
749
+ ordered = scored.sort_by { |_k, s| s }
750
+ drop = []
751
+ ordered.each do |pair|
752
+ k = pair[0]
753
+ break if mem.size - drop.size <= cap
754
+ next if defined?(PWN::Memory) && PWN::Memory.respond_to?(:protected_entry?) &&
755
+ PWN::Memory.protected_entry?(key: k, entry: mem[k])
756
+
757
+ drop << k
680
758
  end
681
- drop = sorted.first(mem.size - cap).map(&:first)
682
759
  drop.each { |k| mem.delete(k) }
683
760
  removed.concat(drop)
684
761
  end
685
762
  PWN::Memory.save(mem: mem, force: mem.empty?)
763
+ if PWN::Memory.respond_to?(:lean!)
764
+ begin
765
+ PWN::Memory.lean!
766
+ rescue StandardError => e
767
+ warn "[pwn-ai/learning] post-consolidate memory.lean! swallowed: #{e.class}: #{e.message}"
768
+ end
769
+ end
686
770
  { removed: removed.uniq.length, remaining: mem.size }
687
771
  end
688
772
 
@@ -794,6 +878,103 @@ module PWN
794
878
  nil
795
879
  end
796
880
 
881
+ # P29 — map score → verdict with the same thresholds as Reward.judge.
882
+ private_class_method def self.verdict_for_score(opts = {})
883
+ s = opts[:score].to_f
884
+ return :solved if s >= 0.6
885
+ return :partial if s >= 0.3
886
+
887
+ :wrong
888
+ end
889
+
890
+ # Strip critic/red_team envelope prefixes so the injected block shows
891
+ # the human ask, not "REQUEST:\n…\nANSWER:" / "GOAL:\n…\nPLAN:".
892
+ private_class_method def self.display_task(opts = {})
893
+ t = opts[:task].to_s.gsub(/\s+/, ' ').strip
894
+ if t.match?(/\AREQUEST:\s*/i)
895
+ body = t.sub(/\AREQUEST:\s*/i, '')
896
+ body = body.split(/\bANSWER:\s*/i, 2).first.to_s
897
+ t = body.strip
898
+ elsif t.match?(/\AGOAL:\s*/i)
899
+ body = t.sub(/\AGOAL:\s*/i, '')
900
+ body = body.split(/\bPLAN:\s*/i, 2).first.to_s
901
+ t = body.strip
902
+ end
903
+ t[0, 100]
904
+ end
905
+
906
+ # Prefer bare user goals over REQUEST:/GOAL: swarm envelopes when both
907
+ # describe the same underlying attempt (offline_judge + critic sessions).
908
+ private_class_method def self.prefer_primary_tasks(opts = {})
909
+ rows = Array(opts[:rows])
910
+ return rows if rows.empty?
911
+
912
+ scored = rows.map do |r|
913
+ t = r[:task].to_s
914
+ envelope = t.match?(/\A\s*(REQUEST:|GOAL:)/i) ? 1 : 0
915
+ # Higher is better: bare task first, then newer (rows already newest-first)
916
+ [r, -envelope]
917
+ end
918
+ # stable: keep relative order within same envelope rank
919
+ scored.sort_by.with_index { |(_, rank), i| [rank, i] }.map(&:first)
920
+ end
921
+
922
+ private_class_method def self.cause_crumb(opts = {})
923
+ d = opts[:details].to_s.gsub(/\s+/, ' ').strip
924
+ return '' if d.empty?
925
+
926
+ # Prefer explicit FLAW / CORRECTED crumbs; else verdict(score) head.
927
+ if (m = d.match(/\bFLAW:\s*(.+)\z/i)) || (m = d.match(/\bFLAW:\s*([^|]+)/i))
928
+ return m[1].to_s.strip[0, 120]
929
+ end
930
+ if (m = d.match(/\bCORRECTED:\s*(.+)\z/i))
931
+ return "corrected: #{m[1].to_s.strip[0, 100]}"
932
+ end
933
+
934
+ d[0, 120]
935
+ end
936
+
937
+ # One-shot / on-load repair: rewrite tags+details where verdict label
938
+ # disagrees with score (solved @ 0.3 etc.). Safe to call repeatedly.
939
+ public_class_method def self.reconcile_verdict_tags!(opts = {})
940
+ return { repaired: 0 } unless File.exist?(LEARNING_FILE)
941
+
942
+ dry = opts[:dry_run] ? true : false
943
+ repaired = 0
944
+ lines = File.readlines(LEARNING_FILE)
945
+ out = lines.map do |l|
946
+ r = JSON.parse(l, symbolize_names: true)
947
+ score = r.key?(:score) ? r[:score].to_f : nil
948
+ next l if score.nil?
949
+
950
+ want = verdict_for_score(score: score).to_s
951
+ tags = Array(r[:tags]).map(&:to_s)
952
+ stale = tags & %w[solved partial wrong unknown]
953
+ next l if stale.empty? || stale.include?(want)
954
+
955
+ repaired += 1
956
+ next l if dry
957
+
958
+ cleaned = tags - %w[solved partial wrong unknown]
959
+ cleaned << want
960
+ r[:tags] = cleaned
961
+ # Fix leading "solved(0.3)" style details head when present
962
+ det = r[:details].to_s
963
+ r[:details] = det.sub(
964
+ /\A(solved|partial|wrong|unknown)\(\d+(?:\.\d+)?\)/i,
965
+ "#{want}(#{format('%.2f', score)})"
966
+ )
967
+ r[:success] = (score >= 0.6) if [true, false].include?(r[:success])
968
+ "#{JSON.generate(r)}\n"
969
+ rescue StandardError
970
+ l
971
+ end
972
+ File.write(LEARNING_FILE, out.join) if !dry && repaired.positive?
973
+ { repaired: repaired, dry_run: dry }
974
+ rescue StandardError => e
975
+ { repaired: 0, error: "#{e.class}: #{e.message}" }
976
+ end
977
+
797
978
  private_class_method def self.auto_introspect_enabled?
798
979
  return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
799
980
 
@@ -1086,6 +1267,249 @@ module PWN
1086
1267
  values.first[0, 200]
1087
1268
  end
1088
1269
 
1270
+ # Supported Method Parameters::
1271
+ # result = PWN::AI::Agent::Learning.prune_outcomes!(
1272
+ # dry_run: 'optional - Boolean (default false)',
1273
+ # max_rows: 'optional - hard cap (default MAX_OUTCOME_ROWS)',
1274
+ # retain_days: 'optional - age floor for low-value drop',
1275
+ # recent_days: 'optional - always keep newer than this'
1276
+ # )
1277
+ #
1278
+ # Keep gold RL rows (success+score>=0.6+session_id), recent window,
1279
+ # high-value tags, and near-miss failures. Dedupe by task+success
1280
+ # keeping best score. Truncate details. Never sacrifices exemplar pool.
1281
+
1282
+ public_class_method def self.prune_outcomes!(opts = {})
1283
+ dry = opts[:dry_run] ? true : false
1284
+ max_rows = (opts[:max_rows] || MAX_OUTCOME_ROWS).to_i
1285
+ retain_days = (opts[:retain_days] || OUTCOME_RETAIN_DAYS).to_f
1286
+ recent_days = (opts[:recent_days] || OUTCOME_RECENT_DAYS).to_f
1287
+ details_max = (opts[:details_max] || OUTCOME_DETAILS_MAX).to_i
1288
+ gold_min = (opts[:gold_min_score] || GOLD_MIN_SCORE).to_f
1289
+
1290
+ return { kept: 0, removed: 0, skipped: true } unless File.exist?(LEARNING_FILE)
1291
+
1292
+ before_bytes = File.size(LEARNING_FILE)
1293
+ rows = File.readlines(LEARNING_FILE).map do |l|
1294
+ JSON.parse(l, symbolize_names: true)
1295
+ rescue StandardError
1296
+ nil
1297
+ end.compact
1298
+
1299
+ now = Time.now.utc
1300
+ age_days = lambda do |r|
1301
+ (now - Time.parse(r[:timestamp].to_s)) / 86_400.0
1302
+ rescue StandardError
1303
+ 999.0
1304
+ end
1305
+
1306
+ protected_row = lambda do |r|
1307
+ tags = Array(r[:tags]).map(&:to_s)
1308
+ a = age_days.call(r)
1309
+ return true if a <= recent_days
1310
+ return true if r[:success] == true && r.key?(:score) && r[:score].to_f >= gold_min && r[:session_id].to_s != ''
1311
+ return true if r[:success] == true && !r.key?(:score) && r[:session_id].to_s != '' && a <= retain_days
1312
+ return true if tags.intersect?(HIGH_VALUE_TAGS)
1313
+ return true if r[:success] == false && r.key?(:score) && r[:score].to_f >= 0.5
1314
+
1315
+ false
1316
+ end
1317
+
1318
+ rows.reject! { |r| r[:task].to_s.strip.empty? }
1319
+
1320
+ best = {}
1321
+ rows.each do |r|
1322
+ key = [r[:task].to_s.strip.downcase.gsub(/\s+/, ' ')[0, 160], r[:success].to_s]
1323
+ prev = best[key]
1324
+ if prev.nil?
1325
+ best[key] = r
1326
+ else
1327
+ ps = prev.key?(:score) ? prev[:score].to_f : -1.0
1328
+ rs = r.key?(:score) ? r[:score].to_f : -1.0
1329
+ better = rs > ps || (rs == ps && r[:timestamp].to_s > prev[:timestamp].to_s)
1330
+ better ||= protected_row.call(r) && !protected_row.call(prev)
1331
+ best[key] = r if better
1332
+ end
1333
+ end
1334
+ deduped = best.values
1335
+ removed_dupes = rows.size - deduped.size
1336
+
1337
+ truncated = 0
1338
+ deduped.each do |r|
1339
+ d = r[:details].to_s
1340
+ next if d.bytesize <= details_max
1341
+
1342
+ r[:details] = "#{d[0, details_max]}…[compacted]"
1343
+ truncated += 1
1344
+ end
1345
+
1346
+ protected, unprotected = deduped.partition { |r| protected_row.call(r) }
1347
+
1348
+ kept_unprot = unprotected.reject do |r|
1349
+ a = age_days.call(r)
1350
+ tags = Array(r[:tags]).map(&:to_s)
1351
+ score = r.key?(:score) ? r[:score].to_f : 1.0
1352
+ noise_tags = (tags - LOW_VALUE_ONLY_TAGS).empty? && tags.any?
1353
+ a > retain_days && noise_tags && score < 0.4
1354
+ end
1355
+
1356
+ gold = (protected + kept_unprot).select do |r|
1357
+ r[:success] == true && r[:session_id].to_s != '' &&
1358
+ (!r.key?(:score) || r[:score].to_f >= gold_min)
1359
+ end
1360
+ if gold.size < EXEMPLARS_POOL_MIN
1361
+ need = EXEMPLARS_POOL_MIN - gold.size
1362
+ extra = unprotected.select { |r| r[:success] == true && r[:session_id].to_s != '' }
1363
+ .sort_by { |r| r[:timestamp].to_s }
1364
+ .last(need)
1365
+ kept_unprot = (kept_unprot + extra).uniq
1366
+ end
1367
+
1368
+ fails = (protected + kept_unprot).reject { |r| r[:success] == true }
1369
+ if fails.size < FAILURE_WINDOW_MIN
1370
+ need = FAILURE_WINDOW_MIN - fails.size
1371
+ extra = unprotected.reject { |r| r[:success] == true }
1372
+ .sort_by { |r| r[:timestamp].to_s }
1373
+ .last(need)
1374
+ kept_unprot = (kept_unprot + extra).uniq
1375
+ end
1376
+
1377
+ kept = (protected + kept_unprot).uniq
1378
+ if kept.size > max_rows
1379
+ prot_ids = protected.map { |r| r[:id] }.compact
1380
+ over = kept.size - max_rows
1381
+ victims = kept.reject { |r| prot_ids.include?(r[:id]) }
1382
+ .sort_by { |r| r[:timestamp].to_s }
1383
+ .first(over)
1384
+ v_ids = victims.map { |r| r[:id] }
1385
+ kept = kept.reject { |r| v_ids.include?(r[:id]) }
1386
+ end
1387
+
1388
+ kept = kept.sort_by { |r| r[:timestamp].to_s }
1389
+
1390
+ atomic_jsonl_write(path: LEARNING_FILE, rows: kept) unless dry
1391
+
1392
+ {
1393
+ kept: kept.size,
1394
+ removed: (rows.size - kept.size) + removed_dupes,
1395
+ deduped: removed_dupes,
1396
+ truncated_details: truncated,
1397
+ protected: protected.size,
1398
+ bytes_before: before_bytes,
1399
+ bytes_after: if dry
1400
+ before_bytes
1401
+ else
1402
+ (File.exist?(LEARNING_FILE) ? File.size(LEARNING_FILE) : 0)
1403
+ end,
1404
+ dry_run: dry
1405
+ }
1406
+ end
1407
+
1408
+ # Memory lean + outcome prune.
1409
+ public_class_method def self.lean!(opts = {})
1410
+ dry = opts[:dry_run] ? true : false
1411
+ out = { dry_run: dry }
1412
+ out[:memory] = if defined?(PWN::Memory) && PWN::Memory.respond_to?(:lean!)
1413
+ PWN::Memory.lean!(dry_run: dry)
1414
+ else
1415
+ { skipped: true }
1416
+ end
1417
+ out[:memory_consolidate] = consolidate(max_entries: opts[:max_entries] || MAX_MEMORY_ENTRIES) unless dry
1418
+ out[:learning] = prune_outcomes!(
1419
+ dry_run: dry,
1420
+ max_rows: opts[:max_rows],
1421
+ retain_days: opts[:retain_days],
1422
+ recent_days: opts[:recent_days],
1423
+ details_max: opts[:details_max],
1424
+ gold_min_score: opts[:gold_min_score]
1425
+ )
1426
+ out
1427
+ end
1428
+
1429
+ # One-shot lean across memory + learning + mistakes + sessions.
1430
+ # Called from auto_introspect (throttled) so the RL feedback loop
1431
+ # keeps ~/.pwn high-signal without a manual learning_gc_stores turn.
1432
+ # Supported Method Parameters::
1433
+ # result = PWN::AI::Agent::Learning.gc_stores!(
1434
+ # dry_run: 'optional - Boolean (default false)',
1435
+ # current_session_id: 'optional - never delete this sessions id',
1436
+ # max_entries: 'optional - Memory consolidate cap',
1437
+ # max_rows: 'optional - learning.jsonl cap',
1438
+ # retain_days: 'optional - outcome / session age floor'
1439
+ # )
1440
+ public_class_method def self.gc_stores!(opts = {})
1441
+ dry = opts[:dry_run] ? true : false
1442
+ res = lean!(
1443
+ dry_run: dry,
1444
+ max_entries: opts[:max_entries],
1445
+ max_rows: opts[:max_rows],
1446
+ retain_days: opts[:retain_days],
1447
+ recent_days: opts[:recent_days],
1448
+ details_max: opts[:details_max],
1449
+ gold_min_score: opts[:gold_min_score]
1450
+ )
1451
+ res[:mistakes] = if defined?(Mistakes) && Mistakes.respond_to?(:lean!)
1452
+ Mistakes.lean!(dry_run: dry)
1453
+ else
1454
+ { skipped: true }
1455
+ end
1456
+ res[:sessions] = if defined?(PWN::Sessions) && PWN::Sessions.respond_to?(:lean!)
1457
+ sess_opts = { dry_run: dry }
1458
+ sid = opts[:current_session_id].to_s
1459
+ sess_opts[:current_session_id] = sid unless sid.empty?
1460
+ sess_opts[:retain_days] = opts[:retain_days] if opts.key?(:retain_days)
1461
+ sess_opts[:max_files] = opts[:max_files] if opts.key?(:max_files)
1462
+ PWN::Sessions.lean!(**sess_opts)
1463
+ else
1464
+ { skipped: true }
1465
+ end
1466
+ res
1467
+ end
1468
+
1469
+ # True every PRUNE_EVERY_N_APPENDS rows, or once learning.jsonl
1470
+ # exceeds MAX_OUTCOME_ROWS (forces lean even if modulo miss).
1471
+ private_class_method def self.should_gc_stores?
1472
+ return true unless File.exist?(LEARNING_FILE)
1473
+
1474
+ lines = File.foreach(LEARNING_FILE).count
1475
+ return true if lines >= MAX_OUTCOME_ROWS
1476
+ return true if lines.positive? && (lines % PRUNE_EVERY_N_APPENDS).zero?
1477
+
1478
+ false
1479
+ rescue StandardError
1480
+ false
1481
+ end
1482
+
1483
+ private_class_method def self.maybe_prune_outcomes!
1484
+ return unless File.exist?(LEARNING_FILE)
1485
+
1486
+ lines = File.foreach(LEARNING_FILE).count
1487
+ return if lines < MAX_OUTCOME_ROWS && (lines % PRUNE_EVERY_N_APPENDS != 0)
1488
+
1489
+ prune_outcomes!
1490
+ rescue StandardError => e
1491
+ warn "[pwn-ai/learning] maybe_prune_outcomes! swallowed: #{e.class}: #{e.message}"
1492
+ end
1493
+
1494
+ private_class_method def self.atomic_jsonl_write(opts = {})
1495
+ path = opts[:path]
1496
+ rows = Array(opts[:rows])
1497
+ dir = File.dirname(path)
1498
+ FileUtils.mkdir_p(dir)
1499
+ bak = "#{path}.bak-lean-#{Time.now.utc.strftime('%Y%m%d')}"
1500
+ FileUtils.cp(path, bak) if File.exist?(path) && !File.exist?(bak)
1501
+ tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
1502
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
1503
+ f.flock(File::LOCK_EX)
1504
+ rows.each { |r| f.puts(JSON.generate(r)) }
1505
+ f.flush
1506
+ f.fsync
1507
+ end
1508
+ File.rename(tmp, path)
1509
+ ensure
1510
+ FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
1511
+ end
1512
+
1089
1513
  # Supported Method Parameters::
1090
1514
  # PWN::AI::Agent::Learning.purge_noise
1091
1515
  #
@@ -1139,6 +1563,9 @@ module PWN
1139
1563
  PWN::AI::Agent::Learning.exemplars_for(request: 'nmap sweep 10/8') # few-shot for Loop.run
1140
1564
  PWN::AI::Agent::Learning.export_finetune(format: :sharegpt) # -> ~/.pwn/finetune/*.jsonl
1141
1565
  PWN::AI::Agent::Learning.consolidate(max_entries: 200) # M1 semantic-merge + M3 importance-evict
1566
+ PWN::AI::Agent::Learning.lean! # memory + learning.jsonl prune
1567
+ PWN::AI::Agent::Learning.gc_stores! # full ~/.pwn RL lean (mem/learn/mistakes/sessions)
1568
+ PWN::AI::Agent::Learning.prune_outcomes! # learning.jsonl gold-keep cap
1142
1569
  PWN::AI::Agent::Learning.purge_noise # one-shot GC of pre-R1 garbage lessons
1143
1570
  PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
1144
1571
  PWN::AI::Agent::Learning.stats
@@ -1146,6 +1573,8 @@ module PWN
1146
1573
 
1147
1574
  Enable end-of-run auto-learning with:
1148
1575
  PWN::Env[:ai][:agent][:auto_introspect] = true
1576
+ # auto_introspect throttles gc_stores! every PRUNE_EVERY_N_APPENDS
1577
+ # outcomes so ~/.pwn stays lean without a manual GC turn.
1149
1578
 
1150
1579
  #{self}.authors
1151
1580
  USAGE
@@ -172,6 +172,9 @@ module PWN
172
172
 
173
173
  # P17 — evidence-enough early final: latest tool rounds already answer
174
174
  # the ask → force synthesis instead of burning iters into text-only tail.
175
+ # Must NOT fire on routine tool JSON {"success":true} while a multi-step
176
+ # English plan still has open tasks — that blocks legitimate completion
177
+ # (mid-fix "write the complete final answer now" thrash).
175
178
  private_class_method def self.evidence_enough_to_finalize?(opts = {})
176
179
  messages = Array(opts[:messages])
177
180
  turn_fails = opts[:turn_fails] || {}
@@ -186,10 +189,23 @@ module PWN
186
189
  fail_n = turn_fails.values.sum
187
190
  return false if fail_n >= 3
188
191
 
192
+ # English-task gate: multi-step plans only early-final on/after the
193
+ # last tangible task. plan_idx is 0-based; open work => not enough.
194
+ ts_state = opts[:ts_state]
195
+ if ts_state.is_a?(Hash)
196
+ plan = Array(ts_state[:plan])
197
+ if plan.length >= 2
198
+ idx = ts_state[:plan_idx].to_i
199
+ return false if idx < (plan.length - 1)
200
+ end
201
+ end
202
+
189
203
  tools_ok = messages.select { |msg| msg[:role].to_s == 'tool' }
190
204
  return false if tools_ok.size < 2
191
205
 
192
206
  # Last two tool payloads should look like successful evidence, not errors.
207
+ # Agent tool wrappers always emit "success":true on ok — that alone is
208
+ # NOT proof the user goal is done (do not match bare success JSON).
193
209
  last2 = tools_ok.last(2)
194
210
  return false if last2.any? do |msg|
195
211
  content = msg[:content].to_s
@@ -203,11 +219,14 @@ module PWN
203
219
  deep_enough = tools_ok.size >= 3 || (short_plan && tools_ok.size >= plan_steps)
204
220
  return false unless deep_enough
205
221
 
206
- # Request looks satisfied if tool names/content echo key nouns from request
207
- # OR we clearly completed a mutation (write/patch/resolve) successfully.
208
222
  recent_txt = last2.map { |msg| msg[:content].to_s[0, 500] }.join(' ')
209
- return true if recent_txt.match?(/"success"\s*:\s*true|syntax ok|wrote |patched|resolved|File\.write|ruby -c/i)
210
- return true if short_plan && tools_ok.size >= plan_steps && fail_n.zero?
223
+ # Goal-shaped completion only write/patch/verify, not shell success wrappers.
224
+ mutation_done = recent_txt.match?(
225
+ /syntax ok|wrote |patched|resolved|File\.write|ruby -c|0 offenses|examples?,\s*0 failures/i
226
+ )
227
+ return true if mutation_done
228
+ return true if short_plan && tools_ok.size >= plan_steps && fail_n.zero? &&
229
+ request.match?(/\b(what|who|when|where|which|how many|status|list|show|print|uname|cwd|version)\b/i)
211
230
 
212
231
  false
213
232
  rescue StandardError
@@ -1072,7 +1091,8 @@ module PWN
1072
1091
  i: i,
1073
1092
  max_iters: max_iters,
1074
1093
  request: request,
1075
- plan_steps: plan_steps
1094
+ plan_steps: plan_steps,
1095
+ ts_state: ts_state
1076
1096
  ) && turn_fails['evidence_final'].to_i < 1
1077
1097
  turn_fails['evidence_final'] += 1
1078
1098
  messages << {