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 +4 -4
- data/lib/pwn/ai/agent/learning.rb +311 -7
- data/lib/pwn/ai/agent/loop.rb +25 -5
- data/lib/pwn/ai/agent/mistakes.rb +124 -6
- data/lib/pwn/ai/agent/tools/learning.rb +22 -0
- data/lib/pwn/ai/agent/tools/memory.rb +29 -4
- data/lib/pwn/ai/agent/tools/mistakes.rb +22 -0
- data/lib/pwn/ai/agent/tools/sessions.rb +37 -2
- data/lib/pwn/memory.rb +105 -6
- data/lib/pwn/plugins/repl.rb +2 -2
- data/lib/pwn/sessions.rb +268 -4
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/persistence_roundtrip_spec.rb +2 -2
- data/spec/lib/pwn/ai/agent/lean_stores_spec.rb +269 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +76 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +75 -0
- data/spec/lib/pwn/memory_spec.rb +46 -1
- data/spec/lib/pwn/sessions_spec.rb +40 -1
- data/third_party/pwn_rdoc.jsonl +16 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d289c114e06e9e253df8f9071e23c0ed34786b946c04bf570521f808bca54bf2
|
|
4
|
+
data.tar.gz: c045f0f8e5df6b33a0c9927a72aca147c903010e6ff0a888fee7e2ef04b96ace
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
15
|
-
# agent gets sharper over time instead of
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
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,
|
|
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[:
|
|
190
|
-
m[:
|
|
191
|
-
m[:
|
|
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,
|
|
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
|
+
)
|