pwn 0.5.782 → 0.5.783
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/documentation/Installation.md +33 -9
- data/documentation/pwn-REPL.md +31 -3
- data/documentation/pwn-ai-Agent.md +62 -2
- data/etc/default_cron/session_cleanup.rb +335 -42
- data/lib/pwn/ai/agent/curriculum.rb +2 -2
- data/lib/pwn/ai/agent/learning.rb +11 -4
- data/lib/pwn/ai/agent/mistakes.rb +22 -3
- data/lib/pwn/ai/agent/tools/sessions.rb +6 -6
- data/lib/pwn/ai/http_retry.rb +3 -0
- data/lib/pwn/ai/router.rb +3 -1
- data/lib/pwn/cron.rb +1 -1
- data/lib/pwn/memory.rb +62 -6
- data/lib/pwn/plugins/repl/ai.rb +13 -3
- data/lib/pwn/plugins/repl/ai_console.rb +37 -21
- data/lib/pwn/session_trace.rb +1 -1
- data/lib/pwn/sessions.rb +305 -80
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/learning_spec.rb +65 -2
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +3 -3
- data/spec/lib/pwn/memory_spec.rb +58 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +191 -6
- data/spec/lib/pwn/plugins/repl/ai_result_chronology_spec.rb +2 -2
- data/spec/lib/pwn/plugins/repl/ai_spec.rb +28 -11
- data/spec/lib/pwn/session_cleanup_default_spec.rb +2 -2
- data/spec/lib/pwn/session_cleanup_payload_spec.rb +467 -36
- data/spec/lib/pwn/sessions_spec.rb +116 -16
- data/third_party/pwn_rdoc.jsonl +12 -0
- metadata +1 -1
data/lib/pwn/sessions.rb
CHANGED
|
@@ -73,18 +73,46 @@ module PWN
|
|
|
73
73
|
SESSIONS_DIR
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Resolve housekeeping without ever replacing an existing lease inode.
|
|
77
|
+
# Compact layout is enabled only by the offline, backed-up migration.
|
|
78
|
+
# Supported Method Parameters::
|
|
79
|
+
# path = PWN::Sessions.support_path(kind: :metadata, session_id: id)
|
|
80
|
+
public_class_method def self.support_path(opts = {})
|
|
81
|
+
id = opts[:session_id].to_s
|
|
82
|
+
raise ArgumentError, 'Invalid session support identifier' if id.empty? || File.basename(id) != id || %w[. ..].include?(id) || id.include?("\0")
|
|
83
|
+
|
|
84
|
+
folder, name = case opts[:kind]
|
|
85
|
+
when :metadata then ['metadata', "#{id}.meta.json"]
|
|
86
|
+
when :lease then ['leases', ".#{id}.cleanup.lock"]
|
|
87
|
+
when :owner then ['owners', ".activity-owner-#{id}.lock"]
|
|
88
|
+
when :trace then ['traces', File.join(id, 'trace.jsonl')]
|
|
89
|
+
else raise ArgumentError, 'Invalid session support kind'
|
|
90
|
+
end
|
|
91
|
+
legacy = File.join(sessions_dir, name)
|
|
92
|
+
return legacy if File.exist?(legacy) || File.symlink?(legacy)
|
|
93
|
+
|
|
94
|
+
path = File.join(sessions_dir, 'support', folder, name)
|
|
95
|
+
return legacy unless File.exist?(path) || File.symlink?(path) || File.file?(File.join(sessions_dir, '.layout-v2'))
|
|
96
|
+
|
|
97
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
98
|
+
raise IOError, 'Unsafe session support directory' unless File.realpath(File.dirname(path)) == File.expand_path(File.dirname(path))
|
|
99
|
+
|
|
100
|
+
path
|
|
101
|
+
end
|
|
102
|
+
|
|
76
103
|
# Supported Method Parameters::
|
|
77
104
|
# sessions = PWN::Sessions.list
|
|
78
|
-
public_class_method def self.list
|
|
105
|
+
public_class_method def self.list(opts = {})
|
|
79
106
|
dir = sessions_dir
|
|
80
|
-
references = dependency_index
|
|
81
|
-
Dir.glob(File.join(dir, '*.jsonl')).reverse.
|
|
82
|
-
naming =
|
|
107
|
+
references = opts[:references] || dependency_index
|
|
108
|
+
Dir.glob(File.join(dir, '*.jsonl')).reverse.filter_map do |f|
|
|
109
|
+
naming = retention_status(session_id: File.basename(f, '.jsonl'), references: references)
|
|
110
|
+
next unless opts[:include_hidden] || naming[:visible] || File.basename(f, '.jsonl') == opts[:current_session_id].to_s
|
|
111
|
+
|
|
83
112
|
{
|
|
84
113
|
id: File.basename(f, '.jsonl'),
|
|
85
114
|
title: metadata(session_id: File.basename(f, '.jsonl'))[:title],
|
|
86
115
|
dependencies: references[:dependencies].fetch(File.basename(f, '.jsonl'), []),
|
|
87
|
-
dependency_errors: references[:errors],
|
|
88
116
|
path: f,
|
|
89
117
|
size: File.size(f),
|
|
90
118
|
mtime: File.mtime(f).utc.iso8601,
|
|
@@ -93,6 +121,53 @@ module PWN
|
|
|
93
121
|
end
|
|
94
122
|
end
|
|
95
123
|
|
|
124
|
+
# Naming validity, dependency validity and physical safety are independent.
|
|
125
|
+
# Supported Method Parameters::
|
|
126
|
+
# status = PWN::Sessions.retention_status(session_id: id, references: index)
|
|
127
|
+
public_class_method def self.retention_status(opts = {})
|
|
128
|
+
id = opts[:session_id].to_s
|
|
129
|
+
references = opts[:references] || dependency_index
|
|
130
|
+
naming = title_status(session_id: id)
|
|
131
|
+
errors = references.fetch(:session_errors, {}).fetch(id, [])
|
|
132
|
+
dependencies = references[:dependencies].fetch(id, [])
|
|
133
|
+
visible = errors.empty? && naming[:classification] != :unknown && (naming[:is_summarized] || !dependencies.empty?)
|
|
134
|
+
naming.merge(visible: visible, dependencies: dependencies, dependency_errors: errors)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Operator inventory separates global diagnostics from concise resumable rows.
|
|
138
|
+
public_class_method def self.catalog(opts = {})
|
|
139
|
+
references = dependency_index
|
|
140
|
+
current_id = opts[:current_session_id].to_s
|
|
141
|
+
inventory = list(references: references, include_hidden: true)
|
|
142
|
+
rows = inventory.select { |row| row[:visible] || row[:id] == current_id }.sort_by { |row| [File.mtime(row[:path]).to_r, row[:id]] }
|
|
143
|
+
limit = (opts[:limit] || 25).to_i.clamp(1, 200)
|
|
144
|
+
offset = opts[:offset].nil? ? [rows.length - limit, 0].max : [opts[:offset].to_i, 0].max
|
|
145
|
+
selected = rows.slice(offset, limit) || []
|
|
146
|
+
has_more = offset + selected.length < rows.length
|
|
147
|
+
active = rows.find { |row| row[:id] == current_id }
|
|
148
|
+
selected = (selected + [active]).compact.uniq.sort_by { |row| [File.mtime(row[:path]).to_r, row[:id]] }
|
|
149
|
+
storage = Dir.children(sessions_dir).group_by do |name|
|
|
150
|
+
if File.directory?(File.join(sessions_dir, name))
|
|
151
|
+
:support_directories
|
|
152
|
+
elsif name.end_with?('.jsonl')
|
|
153
|
+
:transcripts
|
|
154
|
+
elsif name.end_with?('.meta.json')
|
|
155
|
+
:metadata
|
|
156
|
+
elsif name.end_with?('.lock')
|
|
157
|
+
:locks
|
|
158
|
+
elsif name.end_with?('.tmp')
|
|
159
|
+
:temporary_files
|
|
160
|
+
else
|
|
161
|
+
:other
|
|
162
|
+
end
|
|
163
|
+
end.transform_values(&:length)
|
|
164
|
+
{ total: rows.length, physical_sessions: inventory.length, filtered_sessions: inventory.length - rows.length,
|
|
165
|
+
returned: selected.length, storage: storage, has_previous: offset.positive?,
|
|
166
|
+
offset: offset, limit: limit, has_more: has_more,
|
|
167
|
+
sessions: selected.map { |row| row.slice(:id, :title, :mtime, :dependencies).merge(active: row[:id] == current_id) },
|
|
168
|
+
unresolved_dependency_stores: references[:errors], diagnostics: references.fetch(:diagnostics, []) }
|
|
169
|
+
end
|
|
170
|
+
|
|
96
171
|
# Index retained loop evidence without reading vault/config/credential stores.
|
|
97
172
|
# Supported Method Parameters::
|
|
98
173
|
# references = PWN::Sessions.dependency_index
|
|
@@ -116,6 +191,10 @@ module PWN
|
|
|
116
191
|
end
|
|
117
192
|
dependencies = {}
|
|
118
193
|
evidence_dependencies = {}
|
|
194
|
+
provenance = {}
|
|
195
|
+
session_errors = {}
|
|
196
|
+
invalid_records = []
|
|
197
|
+
diagnostics = []
|
|
119
198
|
databases.each do |path|
|
|
120
199
|
id = File.basename(path, '.db')
|
|
121
200
|
next unless id.match?(SESSION_ID)
|
|
@@ -133,49 +212,95 @@ module PWN
|
|
|
133
212
|
raise IOError, 'Unsafe dependency parent' if errors.any? { |parent| path.start_with?("#{parent}/") }
|
|
134
213
|
raise IOError, 'Unsafe dependency store' unless File.file?(path) && File.realpath(path) == File.expand_path(path)
|
|
135
214
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
records.each do |record|
|
|
142
|
-
next unless record.key?('evidence_ref')
|
|
215
|
+
bytes = File.binread(path)
|
|
216
|
+
units = dependency_units(path: path, bytes: bytes)
|
|
217
|
+
units.each do |locator, record|
|
|
218
|
+
raise IOError, 'Invalid dependency record' unless record.is_a?(Hash)
|
|
219
|
+
raise IOError, 'Invalid mistake entry' if path == File.join(root, 'mistakes.json') && !record.values.all?(Hash)
|
|
143
220
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
221
|
+
if path == File.join(root, 'learning.jsonl') && record.key?('evidence_ref')
|
|
222
|
+
PWN::AI::Agent::Learning.evidence_transcript(row: record)
|
|
223
|
+
hash = record['evidence_ref']['sha256']
|
|
224
|
+
evidence_dependencies[File.join(root, 'learning', 'evidence', "#{hash}.json")] = [path]
|
|
225
|
+
end
|
|
226
|
+
dependency_ids(path: path, root: root, records: [record]).uniq.each do |id|
|
|
227
|
+
(dependencies[id] ||= []) << path
|
|
228
|
+
end
|
|
229
|
+
(provenance[record['session_id']] ||= []) << path if path.start_with?("#{SESSIONS_DIR}/requests/")
|
|
230
|
+
rescue JSON::ParserError, IOError => e
|
|
231
|
+
diagnostics << { path: path, locator: locator, code: e.message }
|
|
232
|
+
owners = dependency_error_owners(path: path, root: root, record: record)
|
|
233
|
+
if owners.empty?
|
|
234
|
+
errors << path
|
|
235
|
+
else
|
|
236
|
+
owners.each { |id| (session_errors[id] ||= []) << path }
|
|
237
|
+
invalid_records << { path: path, locator: locator, owners: owners, sha256: Digest::SHA256.hexdigest(bytes) }
|
|
151
238
|
end
|
|
152
239
|
end
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
(dependencies[id] ||= []) << path
|
|
156
|
-
end
|
|
157
|
-
rescue JSON::ParserError, IOError, SystemCallError
|
|
240
|
+
rescue JSON::ParserError, IOError, SystemCallError => e
|
|
241
|
+
diagnostics << { path: path, code: e.is_a?(JSON::ParserError) ? 'Malformed JSON store' : e.message }
|
|
158
242
|
errors << path
|
|
159
243
|
end
|
|
160
244
|
end
|
|
161
|
-
result = { dependencies: dependencies.transform_values { |paths| paths.uniq.sort }, errors: errors.uniq.sort
|
|
245
|
+
result = { dependencies: dependencies.transform_values { |paths| paths.uniq.sort }, errors: errors.uniq.sort,
|
|
246
|
+
session_errors: session_errors.transform_values { |paths| paths.uniq.sort }, invalid_records: invalid_records }
|
|
247
|
+
result[:diagnostics] = diagnostics unless diagnostics.empty?
|
|
162
248
|
result[:evidence_dependencies] = evidence_dependencies unless evidence_dependencies.empty?
|
|
249
|
+
result[:provenance] = provenance unless provenance.empty?
|
|
163
250
|
result
|
|
164
251
|
end
|
|
165
252
|
|
|
253
|
+
# Keep row boundaries: an invalid shared row must not poison its neighbours.
|
|
254
|
+
private_class_method def self.dependency_units(opts = {})
|
|
255
|
+
path = opts[:path]
|
|
256
|
+
bytes = opts[:bytes]
|
|
257
|
+
if path.end_with?('.jsonl')
|
|
258
|
+
return bytes.lines.each_with_index.map do |line, index|
|
|
259
|
+
[index, JSON.parse(line)]
|
|
260
|
+
rescue JSON::ParserError
|
|
261
|
+
[index, nil]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
record = JSON.parse(bytes)
|
|
265
|
+
return record.map { |key, value| [key, { key => value }] } if %w[mistakes.json memory.json memory.archive.json].include?(File.basename(path)) && record.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
[[nil, record]]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Extract only intact structural selectors, never prose or guessed IDs.
|
|
271
|
+
private_class_method def self.dependency_error_owners(opts = {})
|
|
272
|
+
record = opts[:record]
|
|
273
|
+
return [] unless record.is_a?(Hash)
|
|
274
|
+
|
|
275
|
+
relative = opts[:path].delete_prefix("#{opts[:root]}/")
|
|
276
|
+
return record.keys.filter_map { |key| key[/\Areflect_(\d{8}_\d{6}_[0-9a-f]{8})_[0-9a-f]{8}\z/, 1] } if %w[memory.json memory.archive.json].include?(relative)
|
|
277
|
+
|
|
278
|
+
schema = DEPENDENCY_SCHEMAS.find { |pattern, _| relative.match?(pattern) }
|
|
279
|
+
return [] unless schema
|
|
280
|
+
|
|
281
|
+
schema.last.flat_map do |selector|
|
|
282
|
+
dependency_select(value: record, selector: selector, intact_only: true)
|
|
283
|
+
rescue IOError
|
|
284
|
+
[]
|
|
285
|
+
end.uniq.sort
|
|
286
|
+
end
|
|
287
|
+
|
|
166
288
|
private_class_method def self.dependency_ids(opts = {})
|
|
167
289
|
path = opts[:path]
|
|
168
290
|
records = opts[:records]
|
|
169
291
|
if path.start_with?("#{SESSIONS_DIR}/")
|
|
170
292
|
relative = path.delete_prefix("#{SESSIONS_DIR}/")
|
|
171
293
|
# There is no cross-session link in the transcript/sidecar schema.
|
|
172
|
-
return [] if relative.match?(%r{\A[^/]+\.(?:jsonl|meta\.json)\z})
|
|
294
|
+
return [] if relative.match?(%r{\A(?:support/metadata/)?[^/]+\.(?:jsonl|meta\.json)\z})
|
|
295
|
+
return dependency_value(value: relative.split('/')[2]) if relative.match?(%r{\Asupport/traces/[^/]+/trace\.jsonl\z})
|
|
173
296
|
return dependency_value(value: relative.split('/').first) if relative.match?(%r{\A[^/]+/trace\.jsonl\z})
|
|
297
|
+
return request_checkpoint_dependencies(path: path, records: records) if relative.start_with?('requests/')
|
|
174
298
|
|
|
175
299
|
raise IOError, 'Unknown session dependency store'
|
|
176
300
|
end
|
|
177
301
|
|
|
178
302
|
relative = path.delete_prefix("#{opts[:root]}/")
|
|
303
|
+
return mistake_dependency_ids(records: records) if relative == 'mistakes.json'
|
|
179
304
|
return memory_dependency_ids(records: records) if %w[memory.json memory.archive.json].include?(relative)
|
|
180
305
|
return event_dependency_ids(name: relative, records: records) if %w[curriculum/log.jsonl extrospection.json].include?(relative)
|
|
181
306
|
|
|
@@ -183,6 +308,7 @@ module PWN
|
|
|
183
308
|
raise IOError, 'Unknown dependency store' unless schema
|
|
184
309
|
|
|
185
310
|
ids = records.flat_map do |record|
|
|
311
|
+
next [] if relative == 'policy_traj.jsonl' && complete_policy_trajectory?(record: record)
|
|
186
312
|
next [] if relative == 'learning.jsonl' && record.key?('evidence_ref') && PWN::AI::Agent::Learning.evidence_complete?(row: record)
|
|
187
313
|
|
|
188
314
|
# Swarm.bus_append passes a swarm ID through ArtifactRegistry's session
|
|
@@ -196,6 +322,52 @@ module PWN
|
|
|
196
322
|
ids
|
|
197
323
|
end
|
|
198
324
|
|
|
325
|
+
# Policy replay, training and metrics consume persisted transitions, never
|
|
326
|
+
# Sessions.load. Keep IDs as provenance; incomplete legacy records still pin.
|
|
327
|
+
private_class_method def self.complete_policy_trajectory?(opts = {})
|
|
328
|
+
row = opts[:record]
|
|
329
|
+
keys = %w[session_id request request_family kind engine started_at ended_at score verdict return attribution steps]
|
|
330
|
+
return false unless (row.keys - keys).empty? && %w[session_id kind engine started_at ended_at].all? { |key| row[key].is_a?(String) }
|
|
331
|
+
return false unless row['request_family'].is_a?(String) || row['request'].is_a?(String)
|
|
332
|
+
return false unless row['session_id'].match?(SESSION_ID) && row['steps'].is_a?(Array)
|
|
333
|
+
return false unless row['score'].nil? ? row['return'].nil? || row['return'].is_a?(Numeric) : row['score'].is_a?(Numeric) && row['return'].is_a?(Numeric)
|
|
334
|
+
|
|
335
|
+
row['steps'].all? do |step|
|
|
336
|
+
next false unless step.is_a?(Hash) && %w[state action next_state].all? { |key| step[key].is_a?(String) } && step['reward'].is_a?(Numeric)
|
|
337
|
+
next true if step['action'] == 'final' && step['terminal'] == true && step['credit_mode'] == 'terminal_attribution' && step['credit_eligible'] == false
|
|
338
|
+
|
|
339
|
+
step['duration'].is_a?(Numeric) && %w[ok terminal].all? { |key| [true, false].include?(step[key]) }
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# RequestRuntime.restore! consumes its own checkpoint, not Sessions.load.
|
|
344
|
+
# Its identity is provenance, not a claim that the transcript is evidence.
|
|
345
|
+
private_class_method def self.request_checkpoint_dependencies(opts = {})
|
|
346
|
+
opts[:records].each do |row|
|
|
347
|
+
raise IOError, 'Unsupported request checkpoint version' unless row['version'] == 1
|
|
348
|
+
|
|
349
|
+
keys = %w[version generation session_id request_id original_request request amendments cwd started_at observations outcome events effects evidence enabled_toolsets verification_contract]
|
|
350
|
+
raise IOError, 'Unknown request checkpoint fields' unless (row.keys - keys).empty?
|
|
351
|
+
|
|
352
|
+
sid = row['session_id']
|
|
353
|
+
request = row['request_id']
|
|
354
|
+
valid = sid.is_a?(String) && sid.match?(SESSION_ID) && request.is_a?(String) && !request.empty?
|
|
355
|
+
raise IOError, 'Invalid request checkpoint identity' unless valid
|
|
356
|
+
|
|
357
|
+
expected = File.join(SESSIONS_DIR, 'requests', Digest::SHA256.hexdigest(sid), "#{Digest::SHA256.hexdigest(request)}.json")
|
|
358
|
+
raise IOError, 'Request checkpoint path identity mismatch' unless opts[:path] == expected
|
|
359
|
+
|
|
360
|
+
valid = row['generation'].is_a?(Integer) && row['generation'].positive? &&
|
|
361
|
+
%w[original_request request cwd started_at].all? { |key| row[key].is_a?(String) } &&
|
|
362
|
+
%w[amendments events evidence].all? { |key| row[key].is_a?(Array) } &&
|
|
363
|
+
%w[observations effects].all? { |key| row[key].is_a?(Hash) } &&
|
|
364
|
+
%w[outcome verification_contract].all? { |key| row[key].nil? || row[key].is_a?(Hash) } &&
|
|
365
|
+
(row['enabled_toolsets'].nil? || row['enabled_toolsets'].is_a?(Array))
|
|
366
|
+
raise IOError, 'Invalid request checkpoint schema' unless valid
|
|
367
|
+
end
|
|
368
|
+
[]
|
|
369
|
+
end
|
|
370
|
+
|
|
199
371
|
private_class_method def self.dependency_select(opts = {})
|
|
200
372
|
value = opts[:value]
|
|
201
373
|
selector = opts[:selector]
|
|
@@ -209,7 +381,13 @@ module PWN
|
|
|
209
381
|
raise IOError, 'Invalid dependency container' unless value.is_a?(type)
|
|
210
382
|
|
|
211
383
|
values = key == :values ? value.values : value
|
|
212
|
-
values.flat_map
|
|
384
|
+
values.flat_map do |item|
|
|
385
|
+
dependency_select(value: item, selector: rest, intact_only: opts[:intact_only])
|
|
386
|
+
rescue IOError
|
|
387
|
+
raise unless opts[:intact_only]
|
|
388
|
+
|
|
389
|
+
[]
|
|
390
|
+
end
|
|
213
391
|
when :path
|
|
214
392
|
raise IOError, 'Invalid dependency path' unless value.is_a?(String)
|
|
215
393
|
return [] unless File.dirname(value) == SESSIONS_DIR && value.match?(/\.(?:meta\.json|jsonl)\z/)
|
|
@@ -218,7 +396,7 @@ module PWN
|
|
|
218
396
|
else
|
|
219
397
|
raise IOError, 'Invalid dependency object' unless value.is_a?(Hash)
|
|
220
398
|
|
|
221
|
-
dependency_select(value: value[key], selector: rest)
|
|
399
|
+
dependency_select(value: value[key], selector: rest, intact_only: opts[:intact_only])
|
|
222
400
|
end
|
|
223
401
|
end
|
|
224
402
|
|
|
@@ -230,13 +408,40 @@ module PWN
|
|
|
230
408
|
value.match?(SESSION_ID) ? [value] : []
|
|
231
409
|
end
|
|
232
410
|
|
|
411
|
+
# Mistakes.for_tool/top/prompt_hint/resolve consume the stored error, fix and
|
|
412
|
+
# counters. sessions/verified_sessions are occurrence/verification provenance;
|
|
413
|
+
# previous_user/previous_assistant read only the caller's CURRENT correction.
|
|
414
|
+
# Incomplete or unfamiliar historical rows remain protected, never repaired.
|
|
415
|
+
private_class_method def self.mistake_dependency_ids(opts = {})
|
|
416
|
+
keys = %w[signature tool error snippet count drift_count first_seen last_seen sessions resolved fix source
|
|
417
|
+
cause sample_args shape needs_code_change meta regressed structured_fix resolved_at parked
|
|
418
|
+
needs_human park_reason parked_at hint_outcomes verified_sessions last_verified hint_misses hint_confidence]
|
|
419
|
+
opts[:records].flat_map do |record|
|
|
420
|
+
record.flat_map do |signature, row|
|
|
421
|
+
raise IOError, 'Invalid mistake entry' unless row.is_a?(Hash)
|
|
422
|
+
|
|
423
|
+
ids = %w[sessions verified_sessions].flat_map { |field| dependency_select(value: row, selector: [field, :items]) }
|
|
424
|
+
complete = (row.keys - keys).empty? && row['signature'] == signature &&
|
|
425
|
+
%w[tool error snippet first_seen last_seen source].all? { |key| row[key].is_a?(String) } &&
|
|
426
|
+
row['count'].is_a?(Integer) && row['count'] >= 0 && [true, false].include?(row['resolved']) &&
|
|
427
|
+
row['sessions'].is_a?(Array) && (row['fix'].nil? || row['fix'].is_a?(String))
|
|
428
|
+
complete ? [] : ids
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
233
433
|
private_class_method def self.memory_dependency_ids(opts = {})
|
|
234
434
|
opts[:records].flat_map do |record|
|
|
235
435
|
raise IOError, 'Invalid memory entries' unless record.values.all?(Hash)
|
|
236
436
|
|
|
237
|
-
# Learning.reflect
|
|
238
|
-
#
|
|
239
|
-
|
|
437
|
+
# Learning.reflect persists the lesson itself. Memory.recall/search read
|
|
438
|
+
# value, never resolve this provenance key back to a transcript.
|
|
439
|
+
keys = %w[value category timestamp source confidence importance ttl hits last_hit namespace]
|
|
440
|
+
record.flat_map do |key, row|
|
|
441
|
+
id = key[/\Areflect_(\d{8}_\d{6}_[0-9a-f]{8})_[0-9a-f]{8}\z/, 1]
|
|
442
|
+
complete = (row.keys - keys).empty? && %w[value category timestamp source].all? { |field| row[field].is_a?(String) }
|
|
443
|
+
id && !complete ? [id] : []
|
|
444
|
+
end
|
|
240
445
|
end
|
|
241
446
|
end
|
|
242
447
|
|
|
@@ -246,7 +451,7 @@ module PWN
|
|
|
246
451
|
# Curriculum.practice persists run results only for this event.
|
|
247
452
|
next [] unless record['event'] == 'practice'
|
|
248
453
|
|
|
249
|
-
|
|
454
|
+
practice_dependency_ids(record: record)
|
|
250
455
|
else
|
|
251
456
|
observations = record.fetch('observations', [])
|
|
252
457
|
raise IOError, 'Invalid extrospection observations' unless observations.is_a?(Array) && observations.all?(Hash)
|
|
@@ -259,12 +464,39 @@ module PWN
|
|
|
259
464
|
raise IOError, 'Invalid extrospection tags' unless tags.is_a?(Array)
|
|
260
465
|
next [] unless tags.first == 'drift'
|
|
261
466
|
|
|
262
|
-
dependency_value(value: tags[1])
|
|
467
|
+
ids = dependency_value(value: tags[1])
|
|
468
|
+
# Extrospection.observations/drift consume saved data/snapshots;
|
|
469
|
+
# the tag is attribution only. Keep incomplete observations pinned.
|
|
470
|
+
keys = %w[id source category target data tags ttl timestamp]
|
|
471
|
+
complete = (observation.keys - keys).empty? && %w[id source category timestamp].all? { |key| observation[key].is_a?(String) } &&
|
|
472
|
+
observation['data'].is_a?(String) && tags.all?(String)
|
|
473
|
+
complete ? [] : ids
|
|
263
474
|
end
|
|
264
475
|
end
|
|
265
476
|
end
|
|
266
477
|
end
|
|
267
478
|
|
|
479
|
+
# Curriculum.log has no replay reader: practice consumes self_play's result
|
|
480
|
+
# immediately, including unknown/failed verdicts. offline_judge discovers
|
|
481
|
+
# actual transcripts plus verified Learning evidence, never log run IDs.
|
|
482
|
+
private_class_method def self.practice_dependency_ids(opts = {})
|
|
483
|
+
record = opts[:record]
|
|
484
|
+
ids = dependency_select(value: record, selector: ['data', :items, 'runs', :items, 'session_id'])
|
|
485
|
+
return ids unless record['ts'].is_a?(String) && (record.keys - %w[ts event data]).empty? && record['data'].is_a?(Array)
|
|
486
|
+
|
|
487
|
+
record['data'].flat_map do |result|
|
|
488
|
+
next dependency_select(value: result, selector: ['runs', :items, 'session_id']) unless result['runs'].is_a?(Array)
|
|
489
|
+
|
|
490
|
+
result['runs'].flat_map do |run|
|
|
491
|
+
refs = dependency_select(value: run, selector: ['session_id'])
|
|
492
|
+
complete = run['prompt'].is_a?(String) && run.key?('final') && run.key?('trace') &&
|
|
493
|
+
(run['final'].nil? || run['final'].is_a?(String)) && (run['trace'].nil? || run['trace'].is_a?(String)) &&
|
|
494
|
+
run.key?('score') && (run['score'].nil? || run['score'].is_a?(Numeric)) && run.key?('verdict')
|
|
495
|
+
complete ? [] : refs
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
268
500
|
# Hold a kernel-owned shared lease until the caller closes this handle.
|
|
269
501
|
# Cleanup takes an exclusive lock on this same permanent inode. No PID or
|
|
270
502
|
# timestamp can grant ownership; exit/crash closes the actual descriptor.
|
|
@@ -275,7 +507,7 @@ module PWN
|
|
|
275
507
|
raise ArgumentError, 'Invalid session ID' if sid.empty? || File.basename(sid) != sid || sid.include?("\0") || sid == '.' || sid == '..'
|
|
276
508
|
|
|
277
509
|
register_activity_owner
|
|
278
|
-
lock = File.open(
|
|
510
|
+
lock = File.open(support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- caller owns the lease lifetime
|
|
279
511
|
lock.close_on_exec = true
|
|
280
512
|
raise IOError, "Session #{sid} is being removed" unless lock.flock(File::LOCK_SH | File::LOCK_NB)
|
|
281
513
|
raise IOError, "Session #{sid} not found" unless opts[:creating] || File.file?(File.join(sessions_dir, "#{sid}.jsonl"))
|
|
@@ -301,7 +533,7 @@ module PWN
|
|
|
301
533
|
return unless File.file?('/proc/self/stat')
|
|
302
534
|
|
|
303
535
|
start = File.read('/proc/self/stat').split(') ').last.split[19]
|
|
304
|
-
path =
|
|
536
|
+
path = support_path(kind: :owner, session_id: "#{Process.pid}-#{start}")
|
|
305
537
|
@activity_owner_mutex ||= Mutex.new
|
|
306
538
|
@activity_owner_mutex.synchronize do
|
|
307
539
|
@activity_owners ||= {}
|
|
@@ -328,7 +560,7 @@ module PWN
|
|
|
328
560
|
sid = opts[:session_id].to_s
|
|
329
561
|
raise ArgumentError, 'Invalid session ID' if sid.empty? || File.basename(sid) != sid || sid == '.' || sid == '..'
|
|
330
562
|
|
|
331
|
-
File.open(
|
|
563
|
+
File.open(support_path(kind: :lease, session_id: sid), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
332
564
|
!lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
333
565
|
end
|
|
334
566
|
end
|
|
@@ -376,7 +608,7 @@ module PWN
|
|
|
376
608
|
# this effective title. Explicit titles follow the same seven-word limit.
|
|
377
609
|
public_class_method def self.metadata(opts = {})
|
|
378
610
|
sid = opts[:session_id].to_s
|
|
379
|
-
path =
|
|
611
|
+
path = support_path(kind: :metadata, session_id: sid)
|
|
380
612
|
if File.file?(path)
|
|
381
613
|
stored = JSON.parse(File.read(path), symbolize_names: true)
|
|
382
614
|
return stored.merge(id: sid, title: bounded_title(title: stored[:title])) if stored.is_a?(Hash) && !bounded_title(title: stored[:title]).empty?
|
|
@@ -386,7 +618,7 @@ module PWN
|
|
|
386
618
|
row = JSON.parse(first.to_s, symbolize_names: true)
|
|
387
619
|
title = row[:content].to_s.delete_prefix('Session started: ') if row[:role] == 'system' && row[:content].to_s.start_with?('Session started: ')
|
|
388
620
|
{ id: sid, title: bounded_title(title: title.to_s.empty? ? "pwn-ai session #{sid}" : title) }
|
|
389
|
-
rescue Errno::ENOENT, JSON::ParserError
|
|
621
|
+
rescue Errno::ENOENT, JSON::ParserError, ArgumentError
|
|
390
622
|
{ id: sid, title: bounded_title(title: "pwn-ai session #{sid}") }
|
|
391
623
|
end
|
|
392
624
|
|
|
@@ -401,7 +633,7 @@ module PWN
|
|
|
401
633
|
path = File.join(sessions_dir, "#{sid}.jsonl")
|
|
402
634
|
return status.merge(title_reason: 'invalid_transcript') unless cleanup_transcript_valid?(path: path)
|
|
403
635
|
|
|
404
|
-
meta_path =
|
|
636
|
+
meta_path = support_path(kind: :metadata, session_id: sid)
|
|
405
637
|
return status.merge(classification: :unnamed, title_reason: 'missing_summary_provenance') unless File.exist?(meta_path) || File.symlink?(meta_path)
|
|
406
638
|
return status.merge(title_reason: 'unsafe_metadata') unless File.file?(meta_path) && !File.symlink?(meta_path)
|
|
407
639
|
|
|
@@ -464,7 +696,7 @@ module PWN
|
|
|
464
696
|
# A background summary must not invalidate a name just copied from the menu.
|
|
465
697
|
public_class_method def self.resolve(opts = {})
|
|
466
698
|
identifier = opts[:identifier].to_s
|
|
467
|
-
rows = list
|
|
699
|
+
rows = list(include_hidden: true)
|
|
468
700
|
return identifier if rows.any? { |row| row[:id] == identifier }
|
|
469
701
|
|
|
470
702
|
titles = rows.to_h do |row|
|
|
@@ -504,6 +736,10 @@ module PWN
|
|
|
504
736
|
# Call from a worker, never the terminal/input thread. Failed summaries retain
|
|
505
737
|
# the provisional/previous title and leave the cursor available for retry.
|
|
506
738
|
public_class_method def self.refresh_title(opts = {})
|
|
739
|
+
previous_config = Thread.current[:pwn_session_title_config]
|
|
740
|
+
previous_runtime = Thread.current[:pwn_request_runtime]
|
|
741
|
+
Thread.current[:pwn_session_title_config] = Marshal.load(Marshal.dump(opts[:ai_config] || PWN::Env[:ai]))
|
|
742
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
507
743
|
sid = opts[:session_id].to_s
|
|
508
744
|
lease = activate(session_id: sid)
|
|
509
745
|
original = metadata(session_id: sid)
|
|
@@ -517,8 +753,8 @@ module PWN
|
|
|
517
753
|
text.chars.each_slice(1500) do |chunk|
|
|
518
754
|
output = PWN::AI::Router.summarize(text: "Previous overall summary: #{result[:summary]}\nNew conversation turns:\n#{chunk.join}", llm: true, session: true)
|
|
519
755
|
parsed = JSON.parse(output.to_s.sub(/\A```(?:json)?\s*/, '').sub(/\s*```\z/, ''), symbolize_names: true)
|
|
520
|
-
|
|
521
|
-
|
|
756
|
+
raise ArgumentError, 'Invalid summary response' unless parsed.is_a?(Hash) && parsed[:title].is_a?(String) && parsed[:summary].is_a?(String)
|
|
757
|
+
raise ArgumentError, 'Invalid summary title' if !summary_title_valid?(title: parsed[:title]) || parsed[:summary].strip.empty?
|
|
522
758
|
|
|
523
759
|
result = result.merge(title: bounded_title(title: parsed[:title].gsub(/[[:space:][:cntrl:]]+/, ' ')), summary: parsed[:summary])
|
|
524
760
|
end
|
|
@@ -531,9 +767,12 @@ module PWN
|
|
|
531
767
|
write_metadata(session_id: sid, metadata: result)
|
|
532
768
|
result
|
|
533
769
|
end
|
|
534
|
-
rescue StandardError
|
|
770
|
+
rescue StandardError => e
|
|
771
|
+
opts[:on_error]&.call(e.class.name)
|
|
535
772
|
original
|
|
536
773
|
ensure
|
|
774
|
+
Thread.current[:pwn_session_title_config] = previous_config
|
|
775
|
+
Thread.current[:pwn_request_runtime] = previous_runtime
|
|
537
776
|
deactivate(lease: lease)
|
|
538
777
|
end
|
|
539
778
|
|
|
@@ -546,7 +785,7 @@ module PWN
|
|
|
546
785
|
private_class_method def self.write_metadata(opts = {})
|
|
547
786
|
sid = opts[:session_id].to_s
|
|
548
787
|
lease = activate(session_id: sid)
|
|
549
|
-
path =
|
|
788
|
+
path = support_path(kind: :metadata, session_id: sid)
|
|
550
789
|
return unless File.file?(File.join(sessions_dir, "#{sid}.jsonl"))
|
|
551
790
|
|
|
552
791
|
tmp = "#{path}.#{SecureRandom.hex(8)}.tmp"
|
|
@@ -621,7 +860,7 @@ module PWN
|
|
|
621
860
|
# )
|
|
622
861
|
public_class_method def self.previous_id(opts = {})
|
|
623
862
|
exclude = opts[:exclude_session_id].to_s
|
|
624
|
-
rows = list.sort_by { |s| [s[:mtime].to_s, s[:id].to_s] }.reverse
|
|
863
|
+
rows = list(include_hidden: true).sort_by { |s| [s[:mtime].to_s, s[:id].to_s] }.reverse
|
|
625
864
|
hit = rows.find { |s| s[:id].to_s != exclude }
|
|
626
865
|
hit && hit[:id].to_s
|
|
627
866
|
rescue StandardError
|
|
@@ -654,7 +893,7 @@ module PWN
|
|
|
654
893
|
|
|
655
894
|
hits = []
|
|
656
895
|
target = opts[:target].to_s.downcase
|
|
657
|
-
list.sort_by { |s| s[:mtime].to_s }.reverse.first(max_files).each do |sess|
|
|
896
|
+
list(include_hidden: true).sort_by { |s| s[:mtime].to_s }.reverse.first(max_files).each do |sess|
|
|
658
897
|
sid = sess[:id].to_s
|
|
659
898
|
next if !include_current && !exclude.empty? && sid == exclude
|
|
660
899
|
|
|
@@ -796,7 +1035,7 @@ module PWN
|
|
|
796
1035
|
|
|
797
1036
|
force = opts[:force] ? true : false
|
|
798
1037
|
path = File.join(sessions_dir, "#{sid}.jsonl")
|
|
799
|
-
lock_path =
|
|
1038
|
+
lock_path = support_path(kind: :lease, session_id: sid)
|
|
800
1039
|
lock = opts[:cleanup_lock] || File.open(lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
|
|
801
1040
|
raise IOError, "Session #{sid} is active" unless File.identical?(lock, lock_path) && lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
802
1041
|
|
|
@@ -806,7 +1045,7 @@ module PWN
|
|
|
806
1045
|
end
|
|
807
1046
|
|
|
808
1047
|
FileUtils.rm_f(path)
|
|
809
|
-
FileUtils.rm_f(
|
|
1048
|
+
FileUtils.rm_f(support_path(kind: :metadata, session_id: sid))
|
|
810
1049
|
true
|
|
811
1050
|
ensure
|
|
812
1051
|
lock&.close unless opts[:cleanup_lock]
|
|
@@ -815,7 +1054,7 @@ module PWN
|
|
|
815
1054
|
# Supported Method Parameters::
|
|
816
1055
|
# stats = PWN::Sessions.stats
|
|
817
1056
|
public_class_method def self.stats
|
|
818
|
-
sessions = list
|
|
1057
|
+
sessions = list(include_hidden: true)
|
|
819
1058
|
{
|
|
820
1059
|
total_sessions: sessions.size,
|
|
821
1060
|
total_lines: sessions.sum { |s| s[:lines] },
|
|
@@ -882,7 +1121,7 @@ module PWN
|
|
|
882
1121
|
next unless stub || aged
|
|
883
1122
|
|
|
884
1123
|
deleted << e[:id]
|
|
885
|
-
FileUtils.rm_f([e[:path],
|
|
1124
|
+
FileUtils.rm_f([e[:path], support_path(kind: :metadata, session_id: e[:id])]) unless dry
|
|
886
1125
|
end
|
|
887
1126
|
|
|
888
1127
|
# Refresh remaining after deletes
|
|
@@ -894,7 +1133,7 @@ module PWN
|
|
|
894
1133
|
over = remaining.size - max_files
|
|
895
1134
|
unpinned.first(over).each do |e|
|
|
896
1135
|
deleted << e[:id]
|
|
897
|
-
FileUtils.rm_f([e[:path],
|
|
1136
|
+
FileUtils.rm_f([e[:path], support_path(kind: :metadata, session_id: e[:id])]) unless dry
|
|
898
1137
|
remaining.delete(e)
|
|
899
1138
|
end
|
|
900
1139
|
end
|
|
@@ -934,10 +1173,10 @@ module PWN
|
|
|
934
1173
|
public_class_method def self.protected_session_ids(opts = {})
|
|
935
1174
|
now = opts[:now] || Time.now.utc
|
|
936
1175
|
hot_days = (opts[:hot_days] || HOT_DAYS).to_f
|
|
937
|
-
retain_days = (opts[:retain_days] || RETAIN_DAYS).to_f
|
|
938
1176
|
pins = {}
|
|
939
1177
|
references = dependency_index
|
|
940
1178
|
references[:dependencies].each_key { |id| pins[id] = true }
|
|
1179
|
+
references[:session_errors].each_key { |id| pins[id] = true }
|
|
941
1180
|
Dir.glob(File.join(SESSIONS_DIR, '*.jsonl')).each { |path| pins[File.basename(path, '.jsonl')] = true } if references[:errors].any?
|
|
942
1181
|
pins[opts[:current_session_id].to_s] = true unless opts[:current_session_id].to_s.empty?
|
|
943
1182
|
|
|
@@ -965,31 +1204,8 @@ module PWN
|
|
|
965
1204
|
end
|
|
966
1205
|
end
|
|
967
1206
|
|
|
968
|
-
#
|
|
969
|
-
|
|
970
|
-
if File.exist?(mist_file)
|
|
971
|
-
begin
|
|
972
|
-
store = JSON.parse(File.read(mist_file), symbolize_names: true)
|
|
973
|
-
store.each_value do |m|
|
|
974
|
-
next unless m.is_a?(Hash)
|
|
975
|
-
|
|
976
|
-
openish = !m[:resolved] || m[:regressed]
|
|
977
|
-
recent_closed = false
|
|
978
|
-
if m[:resolved] && m[:last_seen]
|
|
979
|
-
begin
|
|
980
|
-
recent_closed = ((now - Time.parse(m[:last_seen].to_s)) / 86_400.0) <= retain_days
|
|
981
|
-
rescue StandardError
|
|
982
|
-
recent_closed = false
|
|
983
|
-
end
|
|
984
|
-
end
|
|
985
|
-
next unless openish || recent_closed
|
|
986
|
-
|
|
987
|
-
Array(m[:sessions]).each { |s| pins[s.to_s] = true if s.to_s != '' }
|
|
988
|
-
end
|
|
989
|
-
rescue StandardError
|
|
990
|
-
# ignore
|
|
991
|
-
end
|
|
992
|
-
end
|
|
1207
|
+
# Mistake retention is already classified by the shared reader-based
|
|
1208
|
+
# dependency index above; do not re-pin occurrence provenance here.
|
|
993
1209
|
|
|
994
1210
|
pins.keys.reject(&:empty?)
|
|
995
1211
|
end
|
|
@@ -1108,7 +1324,7 @@ module PWN
|
|
|
1108
1324
|
next if File.mtime(path) > cutoff
|
|
1109
1325
|
|
|
1110
1326
|
id = File.basename(path, '.jsonl')
|
|
1111
|
-
File.open(
|
|
1327
|
+
File.open(support_path(kind: :lease, session_id: id), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
1112
1328
|
next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
1113
1329
|
next unless File.file?(path) && File.mtime(path) <= cutoff
|
|
1114
1330
|
|
|
@@ -1139,7 +1355,16 @@ module PWN
|
|
|
1139
1355
|
#{self}.sessions_dir
|
|
1140
1356
|
|
|
1141
1357
|
# Run list and return its result
|
|
1142
|
-
#{self}.list
|
|
1358
|
+
#{self}.list(include_hidden: 'optional - include unretained sessions for diagnostics and explicit resume', current_session_id: 'optional - include this active session regardless of naming classification', references: 'optional - precomputed structural dependency index')
|
|
1359
|
+
|
|
1360
|
+
# Resolve housekeeping paths with legacy compatibility.
|
|
1361
|
+
#{self}.support_path(kind: 'required - metadata, lease, owner or trace', session_id: 'required - support record identifier')
|
|
1362
|
+
|
|
1363
|
+
# Page retained sessions separately from global store diagnostics.
|
|
1364
|
+
#{self}.catalog(offset: 'optional - nonnegative retained session offset, omitted selects newest page', limit: 'optional - page size from one to 200, defaults 25 plus active row if outside page', current_session_id: 'optional - active session id always included without granting retention')
|
|
1365
|
+
|
|
1366
|
+
# Classify retention without confusing global store uncertainty with session errors.
|
|
1367
|
+
#{self}.retention_status(session_id: 'required - canonical session identifier', references: 'optional - precomputed dependency index')
|
|
1143
1368
|
|
|
1144
1369
|
# Index exact dependency file paths and uncertain stores for cleanup and listing.
|
|
1145
1370
|
#{self}.dependency_index
|
|
@@ -1180,7 +1405,7 @@ module PWN
|
|
|
1180
1405
|
)
|
|
1181
1406
|
|
|
1182
1407
|
# Update a rolling conversation summary and title off the input thread.
|
|
1183
|
-
#{self}.refresh_title(session_id: 'required - stable session id to summarize')
|
|
1408
|
+
#{self}.refresh_title(session_id: 'required - stable session id to summarize', ai_config: 'optional - captured active provider configuration; defaults current ai settings', on_error: 'optional - callback receiving only error class; persisted metadata and cursor stay unchanged on failure')
|
|
1184
1409
|
|
|
1185
1410
|
# Run append and return its result
|
|
1186
1411
|
#{self}.append(
|
data/lib/pwn/version.rb
CHANGED