pwn 0.5.763 → 0.5.766
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/Gemfile +9 -9
- data/documentation/Installation.md +104 -0
- data/documentation/Reinforcement-Learning.md +80 -0
- data/documentation/pwn-REPL.md +74 -0
- data/etc/default_cron/session_cleanup.rb +255 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +9 -2
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +8 -0
- data/etc/default_skills/pwn/sessions/SKILL.md +10 -0
- data/lib/pwn/ai/agent/curriculum.rb +9 -2
- data/lib/pwn/ai/agent/learning.rb +365 -0
- data/lib/pwn/ai/agent/loop.rb +4 -0
- data/lib/pwn/ai/agent/reward.rb +12 -1
- data/lib/pwn/ai/agent/swarm.rb +10 -3
- data/lib/pwn/ai/agent/tools/learning.rb +20 -3
- data/lib/pwn/ai/agent/tools/sessions.rb +2 -1
- data/lib/pwn/ai/anthropic.rb +1 -1
- data/lib/pwn/ai/cli.rb +5 -2
- data/lib/pwn/ai/grok.rb +1 -1
- data/lib/pwn/ai/open_ai.rb +1 -1
- data/lib/pwn/ai/router.rb +56 -12
- data/lib/pwn/cron.rb +35 -1
- data/lib/pwn/migrate.rb +5 -1
- data/lib/pwn/plugins/repl/ai.rb +176 -31
- data/lib/pwn/plugins/repl/ai_console.rb +72 -15
- data/lib/pwn/plugins/repl/mesh.rb +73 -10
- data/lib/pwn/plugins/repl.rb +9 -1
- data/lib/pwn/sessions.rb +567 -22
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/learning_spec.rb +229 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +10 -1
- data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +14 -0
- data/spec/lib/pwn/ai/cli_spec.rb +15 -1
- data/spec/lib/pwn/ai/router_spec.rb +53 -0
- data/spec/lib/pwn/banner_spec.rb +1 -1
- data/spec/lib/pwn/cron_spec.rb +4 -3
- data/spec/lib/pwn/migrate_spec.rb +4 -4
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +9 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +251 -5
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +210 -0
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +209 -0
- data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +236 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +2 -0
- data/spec/lib/pwn/redaction_spec.rb +3 -2
- data/spec/lib/pwn/session_cleanup_default_spec.rb +72 -0
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +593 -0
- data/spec/lib/pwn/sessions_spec.rb +344 -0
- data/third_party/pwn_rdoc.jsonl +49 -1
- metadata +24 -19
|
@@ -35,7 +35,16 @@ PWN::Sessions.sessions_dir(opts)
|
|
|
35
35
|
|
|
36
36
|
- `sessions_dir`
|
|
37
37
|
- `list`
|
|
38
|
+
- `dependency_index`
|
|
39
|
+
- `activate`
|
|
40
|
+
- `deactivate`
|
|
41
|
+
- `active`
|
|
38
42
|
- `create`
|
|
43
|
+
- `metadata`
|
|
44
|
+
- `title_status`
|
|
45
|
+
- `resolve`
|
|
46
|
+
- `provisional_title`
|
|
47
|
+
- `refresh_title`
|
|
39
48
|
- `append`
|
|
40
49
|
- `load`
|
|
41
50
|
- `previous_id`
|
|
@@ -51,6 +60,7 @@ PWN::Sessions.sessions_dir(opts)
|
|
|
51
60
|
- `redact`
|
|
52
61
|
- `authors`
|
|
53
62
|
- `help`
|
|
63
|
+
- `active?`
|
|
54
64
|
- `lean!`
|
|
55
65
|
|
|
56
66
|
## Source
|
|
@@ -241,6 +241,10 @@ module PWN
|
|
|
241
241
|
Dir[File.join(dir, '*.jsonl')].sort_by { |f| -File.mtime(f).to_i }.first(limit * 2).map { |f| File.basename(f, '.jsonl') }
|
|
242
242
|
end
|
|
243
243
|
|
|
244
|
+
# Session cleanup removes directory discovery, not the learning corpus.
|
|
245
|
+
evidence_rows = defined?(Learning) ? Learning.outcomes(limit: 100_000).select { |row| row[:evidence_ref] } : []
|
|
246
|
+
evidence_times = evidence_rows.to_h { |row| [row[:session_id], row[:timestamp]] }
|
|
247
|
+
sids = (sids + evidence_times.keys).uniq
|
|
244
248
|
scored = []
|
|
245
249
|
sids.first(limit * 3).each do |sid|
|
|
246
250
|
break if scored.length >= limit
|
|
@@ -257,7 +261,7 @@ module PWN
|
|
|
257
261
|
(PWN::Sessions::SESSIONS_DIR if defined?(PWN::Sessions::SESSIONS_DIR)) || File.join(Dir.home, '.pwn', 'sessions'),
|
|
258
262
|
"#{sid}.jsonl"
|
|
259
263
|
)
|
|
260
|
-
File.exist?(path) ? File.mtime(path).utc :
|
|
264
|
+
File.exist?(path) ? File.mtime(path).utc : (Time.parse(evidence_times[sid]) if evidence_times[sid])
|
|
261
265
|
rescue StandardError
|
|
262
266
|
nil
|
|
263
267
|
end
|
|
@@ -1018,7 +1022,8 @@ module PWN
|
|
|
1018
1022
|
end
|
|
1019
1023
|
|
|
1020
1024
|
private_class_method def self.self_play(opts = {})
|
|
1021
|
-
|
|
1025
|
+
session = PWN::Sessions.create(title: "curriculum #{opts[:tag]}", active: true)
|
|
1026
|
+
sid = session[:id]
|
|
1022
1027
|
# P23 — short-horizon graded tasks: cap iters so practice actually
|
|
1023
1028
|
# teaches finish-under-N instead of letting DEFAULT_MAX_ITERS mask it.
|
|
1024
1029
|
prompt = opts[:prompt].to_s
|
|
@@ -1058,6 +1063,8 @@ module PWN
|
|
|
1058
1063
|
Reward.resolve_outcome(outcome: { source: :error, error: e.message }).merge(
|
|
1059
1064
|
session_id: sid, prompt: opts[:prompt], final: final, trace: trace
|
|
1060
1065
|
)
|
|
1066
|
+
ensure
|
|
1067
|
+
PWN::Sessions.deactivate(lease: session[:lease]) if session
|
|
1061
1068
|
end
|
|
1062
1069
|
|
|
1063
1070
|
private_class_method def self.score_branch(opts = {})
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'time'
|
|
6
6
|
require 'digest'
|
|
7
|
+
require 'securerandom'
|
|
7
8
|
|
|
8
9
|
module PWN
|
|
9
10
|
module AI
|
|
@@ -29,6 +30,330 @@ module PWN
|
|
|
29
30
|
DISPUTED_FILE = File.join(Dir.home, '.pwn', 'learning', 'disputed.jsonl')
|
|
30
31
|
LESSONS_FILE = File.join(Dir.home, '.pwn', 'lessons.json')
|
|
31
32
|
FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
|
|
33
|
+
# Evidence is immutable; provenance is not permission to remove a session.
|
|
34
|
+
# All readers validate the content hash and full-snapshot contract first.
|
|
35
|
+
public_class_method def self.evidence_transcript(opts = {})
|
|
36
|
+
row = opts[:row].transform_keys(&:to_sym)
|
|
37
|
+
ref = row[:evidence_ref]
|
|
38
|
+
raise IOError, 'Missing learning evidence reference' unless ref.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
ref = ref.transform_keys(&:to_sym)
|
|
41
|
+
hash = ref[:sha256]
|
|
42
|
+
sid = row[:session_id]
|
|
43
|
+
raise IOError, 'Invalid learning evidence reference' unless ref[:version] == 1 && hash.is_a?(String) && hash.match?(/\A[0-9a-f]{64}\z/) && ref[:id] == hash && sid.is_a?(String) && sid.match?(PWN::Sessions::SESSION_ID)
|
|
44
|
+
|
|
45
|
+
path = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{hash}.json")
|
|
46
|
+
body = evidence_read(path: path)
|
|
47
|
+
raise IOError, 'Learning evidence checksum mismatch' unless Digest::SHA256.hexdigest(body) == hash
|
|
48
|
+
|
|
49
|
+
data = JSON.parse(body, symbolize_names: true)
|
|
50
|
+
rows = data[:transcript]
|
|
51
|
+
valid = data[:version] == 1 && data[:kind] == 'full_session_snapshot' && data[:session_id] == sid &&
|
|
52
|
+
rows.is_a?(Array) && !rows.empty? && rows.all?(Hash) && data[:row_count] == rows.length &&
|
|
53
|
+
data[:source_sha256].to_s.match?(/\A[0-9a-f]{64}\z/) && data[:redaction] == 'PWN::Redaction.redact'
|
|
54
|
+
raise IOError, 'Incomplete learning evidence' unless valid
|
|
55
|
+
|
|
56
|
+
rows
|
|
57
|
+
rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
|
|
58
|
+
raise IOError, 'Unreadable learning evidence'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return a verified immutable snapshot, never arbitrary provenance paths.
|
|
62
|
+
public_class_method def self.session_evidence(opts = {})
|
|
63
|
+
sid = opts[:session_id].to_s
|
|
64
|
+
return nil unless sid.match?(PWN::Sessions::SESSION_ID) && File.file?(LEARNING_FILE)
|
|
65
|
+
|
|
66
|
+
rows = evidence_read(path: LEARNING_FILE).lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
67
|
+
refs = rows.select { |row| row.is_a?(Hash) && row[:session_id] == sid && row.key?(:evidence_ref) }
|
|
68
|
+
return nil if refs.empty?
|
|
69
|
+
|
|
70
|
+
# A single migration snapshots all outcomes for a session together.
|
|
71
|
+
# Divergent snapshots cannot be silently chosen for legacy ID-only callers.
|
|
72
|
+
transcripts = refs.map { |row| evidence_transcript(row: row) }.uniq
|
|
73
|
+
raise IOError, 'Ambiguous learning evidence snapshots' unless transcripts.length == 1
|
|
74
|
+
|
|
75
|
+
transcripts.first
|
|
76
|
+
rescue JSON::ParserError
|
|
77
|
+
raise IOError, 'Malformed learning evidence index'
|
|
78
|
+
rescue IOError
|
|
79
|
+
warn '[pwn-ai/learning] Retained evidence is unavailable or ambiguous; no transcript context was substituted. Check evidence integrity before training or evaluation.'
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A marker alone never releases retention; changed live sources stay pinned.
|
|
84
|
+
public_class_method def self.evidence_complete?(opts = {})
|
|
85
|
+
row = opts[:row].transform_keys(&:to_sym)
|
|
86
|
+
transcript = evidence_transcript(row: row)
|
|
87
|
+
sid = row[:session_id]
|
|
88
|
+
path = File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")
|
|
89
|
+
return true unless File.exist?(path) || File.symlink?(path)
|
|
90
|
+
|
|
91
|
+
ref = row[:evidence_ref].transform_keys(&:to_sym)
|
|
92
|
+
artifact = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{ref[:sha256]}.json")
|
|
93
|
+
data = JSON.parse(evidence_read(path: artifact))
|
|
94
|
+
source = evidence_read(path: path)
|
|
95
|
+
source_rows = source.lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
96
|
+
Digest::SHA256.hexdigest(source) == data['source_sha256'] && PWN::Redaction.redact(value: source_rows) == transcript
|
|
97
|
+
rescue IOError, SystemCallError, JSON::ParserError, TypeError, NoMethodError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Plan by default; no provider calls, vault reads, transcript logging or deletion.
|
|
102
|
+
public_class_method def self.migrate_evidence(opts = {})
|
|
103
|
+
dry = opts[:dry_run] != false
|
|
104
|
+
lock = learning_lock unless dry
|
|
105
|
+
original = File.exist?(LEARNING_FILE) ? evidence_read(path: LEARNING_FILE) : ''
|
|
106
|
+
malformed = 0
|
|
107
|
+
rows = original.lines.filter_map do |line|
|
|
108
|
+
row = JSON.parse(line, symbolize_names: true)
|
|
109
|
+
raise JSON::ParserError unless row.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
row
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
malformed += 1
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
report = { dry_run: dry, rows: rows.length, malformed_rows: malformed, session_refs: 0, migratable_sessions: 0,
|
|
118
|
+
missing_sessions: 0, malformed_sessions: 0, malformed_refs: 0, verified_rows: 0,
|
|
119
|
+
estimated_source_bytes: 0, migrated_rows: 0 }
|
|
120
|
+
snapshots = {}
|
|
121
|
+
rows.each do |row|
|
|
122
|
+
sid = row[:session_id]
|
|
123
|
+
next if sid.nil? || sid == ''
|
|
124
|
+
|
|
125
|
+
report[:session_refs] += 1
|
|
126
|
+
unless sid.is_a?(String) && sid.match?(PWN::Sessions::SESSION_ID)
|
|
127
|
+
report[:malformed_refs] += 1
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
if row.key?(:evidence_ref)
|
|
131
|
+
report[evidence_complete?(row: row) ? :verified_rows : :malformed_refs] += 1
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
next if snapshots.key?(sid)
|
|
135
|
+
|
|
136
|
+
path = File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")
|
|
137
|
+
snapshots[sid] = nil
|
|
138
|
+
unless File.exist?(path) || File.symlink?(path)
|
|
139
|
+
report[:missing_sessions] += 1
|
|
140
|
+
next
|
|
141
|
+
end
|
|
142
|
+
begin
|
|
143
|
+
body = evidence_read(path: path)
|
|
144
|
+
transcript = body.lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
145
|
+
raise IOError, 'Malformed source transcript' if transcript.empty? || !transcript.all?(Hash)
|
|
146
|
+
|
|
147
|
+
payload = JSON.generate(version: 1, kind: 'full_session_snapshot', session_id: sid,
|
|
148
|
+
source_sha256: Digest::SHA256.hexdigest(body), row_count: transcript.length,
|
|
149
|
+
redaction: 'PWN::Redaction.redact', transcript: PWN::Redaction.redact(value: transcript))
|
|
150
|
+
hash = Digest::SHA256.hexdigest(payload)
|
|
151
|
+
snapshots[sid] = { body: payload, source: body, path: path, ref: { version: 1, id: hash, sha256: hash } }
|
|
152
|
+
report[:migratable_sessions] += 1
|
|
153
|
+
report[:estimated_source_bytes] += body.bytesize
|
|
154
|
+
rescue IOError, SystemCallError, JSON::ParserError
|
|
155
|
+
report[:malformed_sessions] += 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
index = PWN::Sessions.dependency_index
|
|
159
|
+
eligible = snapshots.select { |_sid, snapshot| snapshot }.keys
|
|
160
|
+
releasable = eligible.select do |sid|
|
|
161
|
+
rows.select { |row| row[:session_id] == sid }.all? { |row| !row.key?(:evidence_ref) || evidence_complete?(row: row) }
|
|
162
|
+
end
|
|
163
|
+
report[:unique_session_refs] = rows.filter_map { |row| row[:session_id] if row[:session_id].is_a?(String) && row[:session_id].match?(PWN::Sessions::SESSION_ID) }.uniq.length
|
|
164
|
+
report[:estimated_artifact_bytes] = snapshots.values.compact.sum { |snapshot| snapshot[:body].bytesize }
|
|
165
|
+
report[:backup_bytes] = original.bytesize
|
|
166
|
+
report[:migratable_rows] = rows.count { |row| !row.key?(:evidence_ref) && snapshots[row[:session_id]] }
|
|
167
|
+
report[:remaining_dependency_sessions] = index[:dependencies].count do |sid, paths|
|
|
168
|
+
!releasable.include?(sid) || paths.any? { |path| path != LEARNING_FILE }
|
|
169
|
+
end
|
|
170
|
+
report[:remaining_dependency_edges] = index[:dependencies].sum { |sid, paths| paths.count { |path| path != LEARNING_FILE || !releasable.include?(sid) } }
|
|
171
|
+
report[:remaining_existing_dependency_sessions] = index[:dependencies].count do |sid, paths|
|
|
172
|
+
File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")) &&
|
|
173
|
+
(!releasable.include?(sid) || paths.any? { |path| path != LEARNING_FILE })
|
|
174
|
+
end
|
|
175
|
+
report[:already_missing_dependency_sessions] = index[:dependencies].count { |sid, _paths| !File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl")) }
|
|
176
|
+
report[:dependency_errors] = index[:errors].length
|
|
177
|
+
return report.merge(aborted: true) if malformed.positive?
|
|
178
|
+
return report if dry || eligible.empty?
|
|
179
|
+
|
|
180
|
+
evidence_commit(rows: rows, original: original, snapshots: snapshots, report: report)
|
|
181
|
+
ensure
|
|
182
|
+
learning_unlock(lock: lock) if lock
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private_class_method def self.evidence_read(opts = {})
|
|
186
|
+
path = opts[:path]
|
|
187
|
+
raise IOError, 'Unsafe evidence path' unless File.file?(path) && File.realpath(path) == File.expand_path(path)
|
|
188
|
+
|
|
189
|
+
File.binread(path)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Copy-on-write PRM metadata; never rewrite supporting actions/results.
|
|
193
|
+
public_class_method def self.annotate_evidence(opts = {})
|
|
194
|
+
sid = opts[:session_id].to_s
|
|
195
|
+
rewards = opts[:rewards]
|
|
196
|
+
raise ArgumentError, 'Invalid evidence annotation' unless sid.match?(PWN::Sessions::SESSION_ID) && rewards.is_a?(Array) && rewards.all? { |reward| reward.nil? || (reward.is_a?(Integer) && [-1, 0, 1].include?(reward)) }
|
|
197
|
+
|
|
198
|
+
lock = learning_lock
|
|
199
|
+
original = evidence_read(path: LEARNING_FILE)
|
|
200
|
+
rows = original.lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
201
|
+
row = rows.find { |entry| entry[:session_id] == sid && entry[:evidence_ref] }
|
|
202
|
+
return { annotated: false } unless row
|
|
203
|
+
|
|
204
|
+
transcript = session_evidence(session_id: sid)
|
|
205
|
+
prior = row[:evidence_ref][:sha256]
|
|
206
|
+
artifact = File.join(File.dirname(LEARNING_FILE), 'learning', 'evidence', "#{prior}.json")
|
|
207
|
+
data = JSON.parse(evidence_read(path: artifact), symbolize_names: true)
|
|
208
|
+
index = -1
|
|
209
|
+
transcript.each do |entry|
|
|
210
|
+
next unless entry[:role].to_s == 'tool'
|
|
211
|
+
|
|
212
|
+
index += 1
|
|
213
|
+
entry[:step_reward] = rewards[index] unless rewards[index].nil?
|
|
214
|
+
end
|
|
215
|
+
return { annotated: false, unchanged: true } if transcript == data[:transcript]
|
|
216
|
+
|
|
217
|
+
body = JSON.generate(data.merge(transcript: transcript, derived_from: prior, annotation: 'step_reward'))
|
|
218
|
+
hash = Digest::SHA256.hexdigest(body)
|
|
219
|
+
updated = rows.map { |entry| entry[:session_id] == sid ? entry.except(:evidence_ref) : entry }
|
|
220
|
+
snapshot = { body: body, source: nil, path: File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"),
|
|
221
|
+
ref: { version: 1, id: hash, sha256: hash } }
|
|
222
|
+
evidence_commit(rows: updated, original: original, snapshots: { sid => snapshot }, report: { migrated_rows: 0 }).merge(annotated: true)
|
|
223
|
+
ensure
|
|
224
|
+
learning_unlock(lock: lock) if lock
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Roll back only an unchanged migration while every original source exists.
|
|
228
|
+
public_class_method def self.rollback_evidence(opts = {})
|
|
229
|
+
id = opts[:migration].to_s
|
|
230
|
+
raise ArgumentError, 'Expected migration SHA-256 ID' unless id.match?(/\A[0-9a-f]{64}\z/)
|
|
231
|
+
|
|
232
|
+
lock = learning_lock
|
|
233
|
+
root = File.join(File.dirname(LEARNING_FILE), 'learning', 'migrations')
|
|
234
|
+
path = File.join(root, "#{id}.json")
|
|
235
|
+
record = JSON.parse(evidence_read(path: path), symbolize_names: true)
|
|
236
|
+
backup = evidence_read(path: File.join(root, "#{id}.backup.jsonl"))
|
|
237
|
+
raise IOError, 'Invalid migration backup' unless record[:before_sha256] == id && Digest::SHA256.hexdigest(backup) == id
|
|
238
|
+
|
|
239
|
+
current = evidence_read(path: LEARNING_FILE)
|
|
240
|
+
return { restored: true, unchanged: true } if current == backup
|
|
241
|
+
raise IOError, 'Learning changed since migration; refusing rollback' unless Digest::SHA256.hexdigest(current) == record[:after_sha256]
|
|
242
|
+
|
|
243
|
+
current_rows = current.lines.map { |line| JSON.parse(line, symbolize_names: true) }
|
|
244
|
+
backup.lines.each do |line|
|
|
245
|
+
previous = JSON.parse(line, symbolize_names: true)
|
|
246
|
+
if previous[:evidence_ref]
|
|
247
|
+
evidence_transcript(row: previous)
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
sid = previous[:session_id]
|
|
251
|
+
row = current_rows.find { |entry| entry[:session_id] == sid && entry[:evidence_ref] }
|
|
252
|
+
next unless row
|
|
253
|
+
|
|
254
|
+
raise IOError, 'Restore source sessions before rollback' unless evidence_complete?(row: row) && File.file?(File.join(PWN::Sessions::SESSIONS_DIR, "#{sid}.jsonl"))
|
|
255
|
+
end
|
|
256
|
+
evidence_write(path: LEARNING_FILE, body: backup)
|
|
257
|
+
evidence_write(path: path, body: JSON.generate(record.merge(state: 'rolled_back')))
|
|
258
|
+
{ restored: true }
|
|
259
|
+
ensure
|
|
260
|
+
learning_unlock(lock: lock) if lock
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private_class_method def self.evidence_write(opts = {})
|
|
264
|
+
path = opts[:path]
|
|
265
|
+
body = opts[:body]
|
|
266
|
+
dir = File.dirname(path)
|
|
267
|
+
ancestor = dir
|
|
268
|
+
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
|
|
269
|
+
raise IOError, 'Unsafe evidence ancestor' unless File.directory?(ancestor) && File.realpath(ancestor) == File.expand_path(ancestor)
|
|
270
|
+
|
|
271
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
272
|
+
raise IOError, 'Unsafe evidence directory' unless File.realpath(dir) == File.expand_path(dir)
|
|
273
|
+
raise IOError, 'Unsafe evidence target' if File.symlink?(path)
|
|
274
|
+
|
|
275
|
+
tmp = "#{path}.#{SecureRandom.hex(12)}.tmp"
|
|
276
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
277
|
+
file.write(body)
|
|
278
|
+
file.flush
|
|
279
|
+
file.fsync
|
|
280
|
+
end
|
|
281
|
+
File.rename(tmp, path)
|
|
282
|
+
File.open(dir, &:fsync)
|
|
283
|
+
raise IOError, 'Evidence write verification failed' unless evidence_read(path: path) == body
|
|
284
|
+
|
|
285
|
+
path
|
|
286
|
+
ensure
|
|
287
|
+
FileUtils.rm_f(tmp) if tmp
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private_class_method def self.evidence_commit(opts = {})
|
|
291
|
+
rows, original, snapshots, report = opts.values_at(:rows, :original, :snapshots, :report)
|
|
292
|
+
root = File.join(File.dirname(LEARNING_FILE), 'learning')
|
|
293
|
+
before_hash = Digest::SHA256.hexdigest(original)
|
|
294
|
+
backup = File.join(root, 'migrations', "#{before_hash}.backup.jsonl")
|
|
295
|
+
evidence_write(path: backup, body: original) unless File.exist?(backup)
|
|
296
|
+
raise IOError, 'Invalid migration backup' unless evidence_read(path: backup) == original
|
|
297
|
+
|
|
298
|
+
rows.each do |row|
|
|
299
|
+
snapshot = snapshots[row[:session_id]]
|
|
300
|
+
next if row.key?(:evidence_ref) || snapshot.nil?
|
|
301
|
+
|
|
302
|
+
ref = snapshot[:ref]
|
|
303
|
+
path = File.join(root, 'evidence', "#{ref[:sha256]}.json")
|
|
304
|
+
evidence_write(path: path, body: snapshot[:body]) unless File.exist?(path)
|
|
305
|
+
row[:evidence_ref] = ref
|
|
306
|
+
evidence_transcript(row: row)
|
|
307
|
+
report[:migrated_rows] += 1
|
|
308
|
+
end
|
|
309
|
+
updated = rows.map { |row| "#{JSON.generate(row)}\n" }.join
|
|
310
|
+
ledger = File.join(root, 'migrations', "#{before_hash}.json")
|
|
311
|
+
record = { version: 1, state: 'prepared', before_sha256: before_hash,
|
|
312
|
+
after_sha256: Digest::SHA256.hexdigest(updated), migrated_rows: report[:migrated_rows] }
|
|
313
|
+
evidence_write(path: ledger, body: JSON.generate(record))
|
|
314
|
+
raise IOError, 'Learning changed during migration' unless evidence_read(path: LEARNING_FILE) == original
|
|
315
|
+
|
|
316
|
+
snapshots.each_value do |snapshot|
|
|
317
|
+
next unless snapshot
|
|
318
|
+
|
|
319
|
+
if snapshot[:source].nil?
|
|
320
|
+
raise IOError, 'Session appeared during evidence annotation' if File.exist?(snapshot[:path]) || File.symlink?(snapshot[:path])
|
|
321
|
+
|
|
322
|
+
next
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
raise IOError, 'Session changed during migration' unless evidence_read(path: snapshot[:path]) == snapshot[:source]
|
|
326
|
+
end
|
|
327
|
+
evidence_write(path: LEARNING_FILE, body: updated)
|
|
328
|
+
evidence_write(path: ledger, body: JSON.generate(record.merge(state: 'committed')))
|
|
329
|
+
report.merge(ledger: ledger)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
|
|
333
|
+
private_class_method def self.learning_lock
|
|
334
|
+
key = :pwn_learning_write_lock
|
|
335
|
+
return nil if Thread.current[key]
|
|
336
|
+
|
|
337
|
+
root = File.dirname(LEARNING_FILE)
|
|
338
|
+
FileUtils.mkdir_p(root, mode: 0o700)
|
|
339
|
+
path = File.join(root, '.learning-write.lock')
|
|
340
|
+
raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
|
|
341
|
+
|
|
342
|
+
# Ownership transfers to the caller's ensure/learning_unlock.
|
|
343
|
+
file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
|
|
344
|
+
file.flock(File::LOCK_EX)
|
|
345
|
+
Thread.current[key] = file
|
|
346
|
+
file
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
private_class_method def self.learning_unlock(opts = {})
|
|
350
|
+
file = opts[:lock]
|
|
351
|
+
return unless file
|
|
352
|
+
|
|
353
|
+
Thread.current[:pwn_learning_write_lock] = nil
|
|
354
|
+
file.close
|
|
355
|
+
end
|
|
356
|
+
|
|
32
357
|
# P0 — post-answer introspect must not train "stop early" while
|
|
33
358
|
# spending the iteration budget after the final. Soft cap skips
|
|
34
359
|
# expensive stages (tool critic, PRM-LLM, reflect, extrospect);
|
|
@@ -91,6 +416,7 @@ module PWN
|
|
|
91
416
|
# )
|
|
92
417
|
|
|
93
418
|
public_class_method def self.note_outcome(opts = {})
|
|
419
|
+
lock = learning_lock
|
|
94
420
|
task = opts[:task].to_s
|
|
95
421
|
# 4.1 — allow success: 'soft' (HER) distinct from true/false
|
|
96
422
|
raw_ok = opts[:success]
|
|
@@ -169,6 +495,8 @@ module PWN
|
|
|
169
495
|
Curriculum.calibrate(predicted: pred, actual: decision ? decision[:training_score] : opts[:score], engine: eng)
|
|
170
496
|
end
|
|
171
497
|
entry
|
|
498
|
+
ensure
|
|
499
|
+
learning_unlock(lock: lock) if lock
|
|
172
500
|
end
|
|
173
501
|
|
|
174
502
|
public_class_method def self.consistency_check(opts = {})
|
|
@@ -843,6 +1171,7 @@ module PWN
|
|
|
843
1171
|
end
|
|
844
1172
|
|
|
845
1173
|
public_class_method def self.flip_last_outcome(opts = {})
|
|
1174
|
+
lock = learning_lock
|
|
846
1175
|
return { flipped: false } unless File.exist?(LEARNING_FILE)
|
|
847
1176
|
|
|
848
1177
|
lines = File.readlines(LEARNING_FILE)
|
|
@@ -869,6 +1198,8 @@ module PWN
|
|
|
869
1198
|
{ flipped: true, id: last[:id], rejected: last[:details].to_s[0, 2_000] }
|
|
870
1199
|
rescue StandardError
|
|
871
1200
|
{ flipped: false }
|
|
1201
|
+
ensure
|
|
1202
|
+
learning_unlock(lock: lock) if lock
|
|
872
1203
|
end
|
|
873
1204
|
|
|
874
1205
|
# Supported Method Parameters::
|
|
@@ -959,8 +1290,11 @@ module PWN
|
|
|
959
1290
|
# PWN::AI::Agent::Learning.reset
|
|
960
1291
|
|
|
961
1292
|
public_class_method def self.reset
|
|
1293
|
+
lock = learning_lock
|
|
962
1294
|
FileUtils.rm_f(LEARNING_FILE)
|
|
963
1295
|
{ cleared: true }
|
|
1296
|
+
ensure
|
|
1297
|
+
learning_unlock(lock: lock) if lock
|
|
964
1298
|
end
|
|
965
1299
|
|
|
966
1300
|
# -------------------------------------------------------------
|
|
@@ -1168,6 +1502,7 @@ module PWN
|
|
|
1168
1502
|
# One-shot / on-load repair: rewrite tags+details where verdict label
|
|
1169
1503
|
# disagrees with score (solved @ 0.3 etc.). Safe to call repeatedly.
|
|
1170
1504
|
public_class_method def self.reconcile_verdict_tags!(opts = {})
|
|
1505
|
+
lock = learning_lock unless opts[:dry_run]
|
|
1171
1506
|
return { repaired: 0 } unless File.exist?(LEARNING_FILE)
|
|
1172
1507
|
|
|
1173
1508
|
dry = opts[:dry_run] ? true : false
|
|
@@ -1206,6 +1541,8 @@ module PWN
|
|
|
1206
1541
|
{ repaired: repaired, dry_run: dry }
|
|
1207
1542
|
rescue StandardError => e
|
|
1208
1543
|
{ repaired: 0, error: "#{e.class}: #{e.message}" }
|
|
1544
|
+
ensure
|
|
1545
|
+
learning_unlock(lock: lock) if lock
|
|
1209
1546
|
end
|
|
1210
1547
|
|
|
1211
1548
|
private_class_method def self.auto_introspect_enabled?
|
|
@@ -1599,6 +1936,7 @@ module PWN
|
|
|
1599
1936
|
# keeping best score. Truncate details. Never sacrifices exemplar pool.
|
|
1600
1937
|
|
|
1601
1938
|
public_class_method def self.prune_outcomes!(opts = {})
|
|
1939
|
+
lock = learning_lock unless opts[:dry_run]
|
|
1602
1940
|
dry = opts[:dry_run] ? true : false
|
|
1603
1941
|
max_rows = (opts[:max_rows] || MAX_OUTCOME_ROWS).to_i
|
|
1604
1942
|
retain_days = (opts[:retain_days] || OUTCOME_RETAIN_DAYS).to_f
|
|
@@ -1722,6 +2060,8 @@ module PWN
|
|
|
1722
2060
|
end,
|
|
1723
2061
|
dry_run: dry
|
|
1724
2062
|
}
|
|
2063
|
+
ensure
|
|
2064
|
+
learning_unlock(lock: lock) if lock
|
|
1725
2065
|
end
|
|
1726
2066
|
|
|
1727
2067
|
# Memory lean + outcome prune.
|
|
@@ -1940,6 +2280,7 @@ module PWN
|
|
|
1940
2280
|
end
|
|
1941
2281
|
|
|
1942
2282
|
public_class_method def self.requeue_conflicted(opts = {})
|
|
2283
|
+
lock = learning_lock unless opts[:dry_run]
|
|
1943
2284
|
dry = opts[:dry_run] ? true : false
|
|
1944
2285
|
rows = list_conflicted(limit: 10_000)
|
|
1945
2286
|
return { rescored: 0, dry_run: dry } if rows.empty? || dry
|
|
@@ -1974,6 +2315,8 @@ module PWN
|
|
|
1974
2315
|
n += 1
|
|
1975
2316
|
end
|
|
1976
2317
|
{ rescored: n, dry_run: false }
|
|
2318
|
+
ensure
|
|
2319
|
+
learning_unlock(lock: lock) if lock
|
|
1977
2320
|
end
|
|
1978
2321
|
|
|
1979
2322
|
public_class_method def self.compact!(opts = {})
|
|
@@ -1998,6 +2341,28 @@ module PWN
|
|
|
1998
2341
|
# Display Usage for this Module
|
|
1999
2342
|
|
|
2000
2343
|
public_class_method def self.help
|
|
2344
|
+
puts "
|
|
2345
|
+
# Plan a full-snapshot evidence migration, or explicitly apply it.
|
|
2346
|
+
#{self}.migrate_evidence(dry_run: 'optional - Boolean; defaults true, false writes evidence and references')
|
|
2347
|
+
|
|
2348
|
+
# Restore an unchanged learning ledger before any source-session cleanup.
|
|
2349
|
+
#{self}.rollback_evidence(migration: 'required - SHA-256 migration ID from the ledger filename')
|
|
2350
|
+
|
|
2351
|
+
# Persist derived PRM rewards without rewriting immutable supporting content.
|
|
2352
|
+
#{self}.annotate_evidence(
|
|
2353
|
+
session_id: 'required - canonical source session ID with retained evidence and no live transcript',
|
|
2354
|
+
rewards: 'required - Array of integer -1, 0, 1 or nil per tool row'
|
|
2355
|
+
)
|
|
2356
|
+
|
|
2357
|
+
# Read and verify the immutable evidence supporting one outcome.
|
|
2358
|
+
#{self}.evidence_transcript(row: 'required - outcome Hash including session_id and evidence_ref')
|
|
2359
|
+
|
|
2360
|
+
# Resolve a legacy session ID to an unambiguous verified snapshot.
|
|
2361
|
+
#{self}.session_evidence(session_id: 'required - canonical source session ID')
|
|
2362
|
+
|
|
2363
|
+
# Check complete evidence and unchanged source before releasing a pin.
|
|
2364
|
+
#{self}.evidence_complete?(row: 'required - outcome Hash including session_id and evidence_ref')
|
|
2365
|
+
"
|
|
2001
2366
|
puts "USAGE:
|
|
2002
2367
|
# Run note outcome and return its result
|
|
2003
2368
|
#{self}.note_outcome(
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -3105,6 +3105,8 @@ module PWN
|
|
|
3105
3105
|
|
|
3106
3106
|
public_class_method def self.run(opts = {})
|
|
3107
3107
|
options = opts.merge(request: opts[:request])
|
|
3108
|
+
sid = opts[:session_id].to_s
|
|
3109
|
+
lease = PWN::Sessions.activate(session_id: sid) if !sid.empty? && File.file?(File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl"))
|
|
3108
3110
|
original_system = opts[:system_role_content]
|
|
3109
3111
|
begin
|
|
3110
3112
|
run_turn(options)
|
|
@@ -3121,6 +3123,8 @@ module PWN
|
|
|
3121
3123
|
options.delete(:verification_contract)
|
|
3122
3124
|
retry
|
|
3123
3125
|
end
|
|
3126
|
+
ensure
|
|
3127
|
+
PWN::Sessions.deactivate(lease: lease)
|
|
3124
3128
|
end
|
|
3125
3129
|
|
|
3126
3130
|
private_class_method def self.update_steered_open_goal!(opts = {})
|
data/lib/pwn/ai/agent/reward.rb
CHANGED
|
@@ -1647,8 +1647,17 @@ module PWN
|
|
|
1647
1647
|
return unless sid && defined?(PWN::Sessions)
|
|
1648
1648
|
|
|
1649
1649
|
path = File.join(PWN::Sessions.sessions_dir, "#{sid}.jsonl")
|
|
1650
|
-
|
|
1650
|
+
unless File.exist?(path)
|
|
1651
|
+
result = Learning.annotate_evidence(session_id: sid, rewards: rewards)
|
|
1652
|
+
if result[:annotated]
|
|
1653
|
+
PWN::Sessions.load(session_id: sid).select { |row| row[:role].to_s == 'tool' }.each_with_index do |row, index|
|
|
1654
|
+
fold_step_reward_to_metrics(content: row[:content], reward: rewards[index]) unless rewards[index].nil?
|
|
1655
|
+
end
|
|
1656
|
+
end
|
|
1657
|
+
return result
|
|
1658
|
+
end
|
|
1651
1659
|
|
|
1660
|
+
lease = PWN::Sessions.activate(session_id: sid)
|
|
1652
1661
|
lines = File.readlines(path)
|
|
1653
1662
|
ti = -1
|
|
1654
1663
|
lines.map! do |l|
|
|
@@ -1668,6 +1677,8 @@ module PWN
|
|
|
1668
1677
|
File.write(path, lines.join)
|
|
1669
1678
|
rescue StandardError
|
|
1670
1679
|
nil
|
|
1680
|
+
ensure
|
|
1681
|
+
PWN::Sessions.deactivate(lease: lease)
|
|
1671
1682
|
end
|
|
1672
1683
|
|
|
1673
1684
|
# Extract tool name from "name → …" session content and record PRM.
|
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -253,7 +253,8 @@ module PWN
|
|
|
253
253
|
bus_append(swarm_id: sid, from: opts[:from] || caller_label,
|
|
254
254
|
to: name, content: opts[:request].to_s)
|
|
255
255
|
|
|
256
|
-
|
|
256
|
+
session_leases = []
|
|
257
|
+
session_id = persona_session(swarm_id: sid, name: name, leases: session_leases)
|
|
257
258
|
sys = build_persona_prompt(name: name, persona: persona,
|
|
258
259
|
swarm_id: sid, session_id: session_id)
|
|
259
260
|
sys = "#{sys}\n#{opts[:handoff]}" if opts[:handoff]
|
|
@@ -285,6 +286,7 @@ module PWN
|
|
|
285
286
|
(Thread.current[:pwn_swarm_honesty] ||= []) << honesty.merge(name: name) unless empty_tools
|
|
286
287
|
{ ok: true, swarm_id: sid, name: name, session_id: session_id, reply: reply, inbox: inbox, honesty: honesty }
|
|
287
288
|
ensure
|
|
289
|
+
Array(session_leases).each { |lease| PWN::Sessions.deactivate(lease: lease) }
|
|
288
290
|
Thread.current[:pwn_swarm_depth] = depth
|
|
289
291
|
if empty_tools
|
|
290
292
|
Thread.current[:pwn_swarm_id] = prior_id
|
|
@@ -410,12 +412,17 @@ module PWN
|
|
|
410
412
|
name = opts[:name].to_s
|
|
411
413
|
map_path = File.join(SWARM_ROOT, sid, 'personas.json')
|
|
412
414
|
map = File.exist?(map_path) ? JSON.parse(File.read(map_path)) : {}
|
|
413
|
-
|
|
415
|
+
if map[name]
|
|
416
|
+
opts[:leases] << PWN::Sessions.activate(session_id: map[name]) if opts[:leases]
|
|
417
|
+
return map[name]
|
|
418
|
+
end
|
|
414
419
|
|
|
415
420
|
sess = PWN::Sessions.create(
|
|
416
421
|
title: "swarm:#{sid} persona:#{name}",
|
|
417
|
-
source: 'pwn-ai-swarm'
|
|
422
|
+
source: 'pwn-ai-swarm',
|
|
423
|
+
active: !opts[:leases].nil?
|
|
418
424
|
)
|
|
425
|
+
opts[:leases] << sess[:lease] if opts[:leases]
|
|
419
426
|
map[name] = sess[:id]
|
|
420
427
|
File.write(map_path, JSON.pretty_generate(map))
|
|
421
428
|
sess[:id]
|
|
@@ -226,17 +226,34 @@ PWN::AI::Agent::Registry.register(
|
|
|
226
226
|
name: 'learning_gc_stores',
|
|
227
227
|
description: 'Lean-keep ~/.pwn RL stores (memory, learning.jsonl, mistakes.json, ' \
|
|
228
228
|
'sessions/) without dropping gold outcomes, open mistakes, prefs/SOPs, ' \
|
|
229
|
-
'or reference-pinned session transcripts. dry_run:true plans only.'
|
|
229
|
+
'or reference-pinned session transcripts. dry_run:true plans only. ' \
|
|
230
|
+
'operation:evidence_plan audits immutable evidence migration; evidence_apply writes it, ' \
|
|
231
|
+
'evidence_rollback restores an unchanged ledger before session cleanup. None deletes sessions.',
|
|
230
232
|
parameters: {
|
|
231
233
|
type: 'object',
|
|
232
234
|
properties: {
|
|
233
|
-
dry_run: { type: 'boolean', default: false, description: 'Report only; do not rewrite.' }
|
|
235
|
+
dry_run: { type: 'boolean', default: false, description: 'Report only; do not rewrite.' },
|
|
236
|
+
operation: { type: 'string', enum: %w[gc evidence_plan evidence_apply evidence_rollback], default: 'gc' },
|
|
237
|
+
migration: { type: 'string', description: 'SHA-256 migration ID for evidence_rollback.' }
|
|
234
238
|
},
|
|
235
239
|
required: []
|
|
236
240
|
}
|
|
237
241
|
},
|
|
238
242
|
check: -> { defined?(PWN::AI::Agent::Learning) && PWN::AI::Agent::Learning.respond_to?(:gc_stores!) },
|
|
239
243
|
handler: lambda { |args|
|
|
240
|
-
|
|
244
|
+
case args[:operation].to_s
|
|
245
|
+
when 'evidence_plan'
|
|
246
|
+
next PWN::AI::Agent::Learning.migrate_evidence(dry_run: true)
|
|
247
|
+
when 'evidence_apply'
|
|
248
|
+
next PWN::AI::Agent::Learning.migrate_evidence(dry_run: args[:dry_run] == true)
|
|
249
|
+
when 'evidence_rollback'
|
|
250
|
+
raise ArgumentError, 'Use evidence_plan for read-only inspection' if args[:dry_run]
|
|
251
|
+
|
|
252
|
+
next PWN::AI::Agent::Learning.rollback_evidence(migration: args[:migration])
|
|
253
|
+
when '', 'gc'
|
|
254
|
+
next PWN::AI::Agent::Learning.gc_stores!(dry_run: args[:dry_run] ? true : false)
|
|
255
|
+
else
|
|
256
|
+
raise ArgumentError, 'Unknown learning maintenance operation'
|
|
257
|
+
end
|
|
241
258
|
}
|
|
242
259
|
)
|
|
@@ -53,7 +53,8 @@ PWN::AI::Agent::Registry.register(
|
|
|
53
53
|
schema: {
|
|
54
54
|
name: 'sessions_list',
|
|
55
55
|
description: 'List every pwn-ai session transcript in ~/.pwn/sessions ' \
|
|
56
|
-
'(id, path,
|
|
56
|
+
'(id, title, path, size, mtime, lines, dependencies, dependency_errors) newest-first. ' \
|
|
57
|
+
'dependencies lists files referencing this session through structured links, not prose mentions. ' \
|
|
57
58
|
'Use to discover a session_id for learning_reflect / ' \
|
|
58
59
|
'learning_distill_skill / sessions_view.',
|
|
59
60
|
parameters: {
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -325,7 +325,7 @@ module PWN
|
|
|
325
325
|
# )
|
|
326
326
|
|
|
327
327
|
private_class_method def self.anthropic_rest_call(opts = {})
|
|
328
|
-
opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
|
|
328
|
+
opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools] || Thread.current[:pwn_session_title]
|
|
329
329
|
engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
|
|
330
330
|
raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
331
331
|
|