pwn 0.5.664 → 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: 7e88bcfaae1b1859df50e16528f8460a31cab0d7a8168206b9f48c3541c00b88
4
- data.tar.gz: c143b22d04b09c1c30a7f6b2f7bfe17cf3dfb8812484db4e42daf9565ba12668
3
+ metadata.gz: d289c114e06e9e253df8f9071e23c0ed34786b946c04bf570521f808bca54bf2
4
+ data.tar.gz: c045f0f8e5df6b33a0c9927a72aca147c903010e6ff0a888fee7e2ef04b96ace
5
5
  SHA512:
6
- metadata.gz: 06d533eb382b1b6509684e15db4dcb8e9bdd24d9ac628e1f07c090bfd7f099e7a608ef61e2c87969e54f245736fb6a10f170042cd650f1dbd023816c788e662c
7
- data.tar.gz: c3243b7fd533a14ad0825546b60b88b465ae8bdc5b476f001dd00cad98788d73173b578c981467a6ec664f1e60815d4bd2f3efa6a6b659e17b85e6ab6d4ad09d
6
+ metadata.gz: 62f913dfe7d20f3154a55a5f2db7f91589141f87b57f0736d0da7bc1f5ae2a843f3f75cb2e274539ce68b9d1ea82cfed1584f0fcf5bf054fbbb2e28764a29028
7
+ data.tar.gz: 67f7cbcb82a68a099fe659caa47477877ce3e5164f3267903ef6b4191b1103d10f558574e5378967b698b1b07f41184ac4f8291598262db31439332b70389f73
@@ -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.)
@@ -617,6 +636,20 @@ module PWN
617
636
  stages_skipped << :extrospect
618
637
  end
619
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
+
620
653
  {
621
654
  ok: ok,
622
655
  score: v[:score],
@@ -696,21 +729,44 @@ module PWN
696
729
  # confidence :heuristic auto-gen self-evicts first.
697
730
  if mem.size > cap
698
731
  now = Time.now.utc
699
- 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
+
700
738
  age_d = (now - Time.parse(v[:timestamp].to_s)) / 86_400.0
701
739
  ttl_d = (v[:ttl].to_f / 86_400.0)
702
740
  imp = (v[:importance] || 0.5).to_f.clamp(0.05, 1.0)
703
741
  conf = (v[:confidence] || (v[:source].to_s == 'human' ? 0.95 : 0.5)).to_f.clamp(0.05, 1.0)
704
742
  staleness = ttl_d.positive? ? age_d / ttl_d : age_d / 90.0
705
- -(staleness / (imp * conf))
743
+ # lower score = drop first; Infinity protected sorts last
744
+ [k, -(staleness / (imp * conf))]
706
745
  rescue StandardError
707
- 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
708
758
  end
709
- drop = sorted.first(mem.size - cap).map(&:first)
710
759
  drop.each { |k| mem.delete(k) }
711
760
  removed.concat(drop)
712
761
  end
713
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
714
770
  { removed: removed.uniq.length, remaining: mem.size }
715
771
  end
716
772
 
@@ -1211,6 +1267,249 @@ module PWN
1211
1267
  values.first[0, 200]
1212
1268
  end
1213
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
+
1214
1513
  # Supported Method Parameters::
1215
1514
  # PWN::AI::Agent::Learning.purge_noise
1216
1515
  #
@@ -1264,6 +1563,9 @@ module PWN
1264
1563
  PWN::AI::Agent::Learning.exemplars_for(request: 'nmap sweep 10/8') # few-shot for Loop.run
1265
1564
  PWN::AI::Agent::Learning.export_finetune(format: :sharegpt) # -> ~/.pwn/finetune/*.jsonl
1266
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
1267
1569
  PWN::AI::Agent::Learning.purge_noise # one-shot GC of pre-R1 garbage lessons
1268
1570
  PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
1269
1571
  PWN::AI::Agent::Learning.stats
@@ -1271,6 +1573,8 @@ module PWN
1271
1573
 
1272
1574
  Enable end-of-run auto-learning with:
1273
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.
1274
1578
 
1275
1579
  #{self}.authors
1276
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 << {
@@ -35,6 +35,14 @@ module PWN
35
35
  module Mistakes
36
36
  MISTAKES_FILE = File.join(Dir.home, '.pwn', 'mistakes.json')
37
37
  REPEAT_THRESHOLD = 3
38
+ # Lean retention for mistakes.json
39
+ SAMPLE_ARGS_MAX = 160
40
+ SNIPPET_MAX = 160
41
+ ERROR_MAX = 300
42
+ SESSIONS_KEEP = 3
43
+ MAX_RESOLVED_KEPT = 80
44
+ RESOLVED_MIN_AGE_DAYS = 21
45
+ FIX_MAX = 500
38
46
 
39
47
  CORRECTION_RX = /
40
48
  \b(
@@ -169,8 +177,8 @@ module PWN
169
177
  norm = normalize_error(error: error)
170
178
 
171
179
  m = store[key] ||= {
172
- signature: sig, tool: tool, error: norm,
173
- snippet: error.to_s.strip[0, 300],
180
+ signature: sig, tool: tool, error: norm.to_s[0, ERROR_MAX],
181
+ snippet: error.to_s.strip[0, SNIPPET_MAX],
174
182
  count: 0, drift_count: 0, first_seen: now, sessions: [],
175
183
  resolved: false, fix: nil, source: (opts[:source] || :tool).to_s
176
184
  }
@@ -186,9 +194,10 @@ module PWN
186
194
  m[:count] += 1
187
195
  end
188
196
  m[:last_seen] = now
189
- m[:snippet] = error.to_s.strip[0, 300]
190
- m[:sample_args] = opts[:args].to_s[0, 200] if opts[:args]
191
- m[:sessions] = (Array(m[:sessions]) + [opts[:session_id]]).compact.uniq.last(10)
197
+ m[:error] = norm.to_s[0, ERROR_MAX]
198
+ m[:snippet] = error.to_s.strip[0, SNIPPET_MAX]
199
+ m[:sample_args] = opts[:args].to_s[0, SAMPLE_ARGS_MAX] if opts[:args]
200
+ m[:sessions] = (Array(m[:sessions]) + [opts[:session_id]]).compact.uniq.last(SESSIONS_KEEP)
192
201
  # 2.2 — recoverable shape for repair routing
193
202
  if opts[:shape]
194
203
  m[:shape] = opts[:shape].to_s
@@ -229,7 +238,7 @@ module PWN
229
238
 
230
239
  store[key][:resolved] = true
231
240
  store[key][:regressed] = false
232
- store[key][:fix] = fix.strip[0, 500]
241
+ store[key][:fix] = fix.strip[0, FIX_MAX]
233
242
  store[key][:resolved_at] = Time.now.utc.iso8601
234
243
  # 2.3 — structured fix payload (strategy/tool/args_template/holdouts).
235
244
  # Prose-only resolve is why shell sigs regressed after auto-curriculum.
@@ -473,6 +482,115 @@ module PWN
473
482
  nil
474
483
  end
475
484
 
485
+ # Supported Method Parameters::
486
+ # result = PWN::AI::Agent::Mistakes.lean!(
487
+ # dry_run: 'optional - Boolean (default false)',
488
+ # max_resolved_kept: 'optional - cap on resolved-with-fix records',
489
+ # resolved_min_age_days: 'optional - age before resolved count=1 may drop'
490
+ # )
491
+ #
492
+ # Compact text fields on every record. Never drops unresolved,
493
+ # regressed, or high-count repeaters. Aged resolved-once fixes may
494
+ # drop after Memory already holds mistake_fix_<sig>.
495
+
496
+ public_class_method def self.lean!(opts = {})
497
+ dry = opts[:dry_run] ? true : false
498
+ max_resolved = (opts[:max_resolved_kept] || MAX_RESOLVED_KEPT).to_i
499
+ min_age = (opts[:resolved_min_age_days] || RESOLVED_MIN_AGE_DAYS).to_f
500
+ store = load
501
+ before_bytes = File.exist?(MISTAKES_FILE) ? File.size(MISTAKES_FILE) : 0
502
+ now = Time.now.utc
503
+ compacted = 0
504
+ dropped = []
505
+
506
+ store.each_value do |m|
507
+ before = begin
508
+ m.to_json.bytesize
509
+ rescue StandardError
510
+ 0
511
+ end
512
+ m[:sample_args] = m[:sample_args].to_s[0, SAMPLE_ARGS_MAX] if m[:sample_args].to_s.bytesize > SAMPLE_ARGS_MAX
513
+ m[:snippet] = m[:snippet].to_s[0, SNIPPET_MAX] if m[:snippet].to_s.bytesize > SNIPPET_MAX
514
+ m[:error] = m[:error].to_s[0, ERROR_MAX] if m[:error].to_s.bytesize > ERROR_MAX
515
+ m[:sessions] = Array(m[:sessions]).compact.uniq.last(SESSIONS_KEEP)
516
+ after = begin
517
+ m.to_json.bytesize
518
+ rescue StandardError
519
+ before
520
+ end
521
+ compacted += 1 if after < before
522
+ end
523
+
524
+ age_days = lambda do |m|
525
+ t = m[:resolved_at] || m[:last_seen]
526
+ (now - Time.parse(t.to_s)) / 86_400.0
527
+ rescue StandardError
528
+ 0.0
529
+ end
530
+
531
+ protected_m = lambda do |m|
532
+ return true unless m[:resolved]
533
+ return true if m[:regressed]
534
+ return true if effective_count(mistake: m) >= REPEAT_THRESHOLD
535
+ return true if m[:count].to_i >= 2 && m[:fix].to_s.strip != ''
536
+ return true if m[:fix].to_s.strip != '' && age_days.call(m) < min_age
537
+
538
+ false
539
+ end
540
+
541
+ # Drop aged resolved-once when fix lives in Memory or past age
542
+ store.each do |sig, m|
543
+ next if protected_m.call(m)
544
+ next unless m[:resolved] && m[:fix].to_s.strip != '' && m[:count].to_i <= 1
545
+ next unless age_days.call(m) >= min_age
546
+
547
+ mem_has = false
548
+ if defined?(PWN::Memory)
549
+ begin
550
+ mem_has = PWN::Memory.load.key?(:"mistake_fix_#{sig}")
551
+ rescue StandardError
552
+ mem_has = false
553
+ end
554
+ end
555
+ # Drop when Memory has the fix OR age is well past (2× min) even without mem key
556
+ dropped << sig.to_s if mem_has || age_days.call(m) >= (min_age * 2)
557
+ end
558
+
559
+ dropped.each { |s| store.delete(s.to_sym) } unless dry
560
+
561
+ # Cap resolved-with-fix by oldest resolved_at
562
+ resolved = store.select { |_s, m| m[:resolved] && m[:fix].to_s.strip != '' }
563
+ if resolved.size > max_resolved
564
+ excess = resolved.sort_by { |_s, m| m[:resolved_at].to_s }
565
+ .first(resolved.size - max_resolved)
566
+ excess.each do |sig, m|
567
+ next if m[:regressed] || m[:count].to_i >= 2
568
+ next if effective_count(mistake: m) >= REPEAT_THRESHOLD
569
+
570
+ dropped << sig.to_s
571
+ store.delete(sig) unless dry
572
+ end
573
+ end
574
+
575
+ save(store: store) unless dry
576
+
577
+ open_n = store.values.count { |m| !m[:resolved] }
578
+ {
579
+ compacted_fields: compacted,
580
+ dropped: dropped.uniq.length,
581
+ dropped_sigs: dropped.uniq.first(20),
582
+ remaining: store.size,
583
+ unresolved: open_n,
584
+ bytes_before: before_bytes,
585
+ bytes_after: if dry
586
+ before_bytes
587
+ else
588
+ (File.exist?(MISTAKES_FILE) ? File.size(MISTAKES_FILE) : 0)
589
+ end,
590
+ dry_run: dry
591
+ }
592
+ end
593
+
476
594
  # Supported Method Parameters::
477
595
  # PWN::AI::Agent::Mistakes.reset
478
596
 
@@ -219,3 +219,25 @@ PWN::AI::Agent::Registry.register(
219
219
  { previous: prev, current: ai[:agent][:auto_introspect] ? true : false }
220
220
  }
221
221
  )
222
+
223
+ PWN::AI::Agent::Registry.register(
224
+ name: 'learning_gc_stores',
225
+ toolset: 'learning',
226
+ schema: {
227
+ name: 'learning_gc_stores',
228
+ description: 'Lean-keep ~/.pwn RL stores (memory, learning.jsonl, mistakes.json, ' \
229
+ 'sessions/) without dropping gold outcomes, open mistakes, prefs/SOPs, ' \
230
+ 'or reference-pinned session transcripts. dry_run:true plans only.',
231
+ parameters: {
232
+ type: 'object',
233
+ properties: {
234
+ dry_run: { type: 'boolean', default: false, description: 'Report only; do not rewrite.' }
235
+ },
236
+ required: []
237
+ }
238
+ },
239
+ check: -> { defined?(PWN::AI::Agent::Learning) && PWN::AI::Agent::Learning.respond_to?(:gc_stores!) },
240
+ handler: lambda { |args|
241
+ PWN::AI::Agent::Learning.gc_stores!(dry_run: args[:dry_run] ? true : false)
242
+ }
243
+ )