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.
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.map do |f|
82
- naming = title_status(session_id: File.basename(f, '.jsonl'))
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
- records = path.end_with?('.jsonl') ? File.foreach(path).map { |line| JSON.parse(line) } : [JSON.parse(File.read(path))]
137
- raise IOError, 'Invalid dependency records' unless records.all?(Hash)
138
- raise IOError, 'Invalid mistake entries' if path == File.join(root, 'mistakes.json') && !records.first.values.all?(Hash)
139
-
140
- if path == File.join(root, 'learning.jsonl')
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
- begin
145
- PWN::AI::Agent::Learning.evidence_transcript(row: record)
146
- hash = record['evidence_ref']['sha256']
147
- evidence_dependencies[File.join(root, 'learning', 'evidence', "#{hash}.json")] = [path]
148
- rescue IOError
149
- errors << path
150
- end
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
- dependency_ids(path: path, root: root, records: records).uniq.each do |id|
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 { |item| dependency_select(value: item, selector: rest) }
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's durable key is reflect_<session>_<lesson digest>.
238
- # Memory values, sources and arbitrary user-selected keys are not links.
239
- record.keys.filter_map { |key| key[/\Areflect_(\d{8}_\d{6}_[0-9a-f]{8})_[0-9a-f]{8}\z/, 1] }
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
- dependency_select(value: record, selector: ['data', :items, 'runs', :items, 'session_id'])
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(File.join(sessions_dir, ".#{sid}.cleanup.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- caller owns the lease lifetime
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 = File.join(sessions_dir, ".activity-owner-#{Process.pid}-#{start}.lock")
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(File.join(sessions_dir, ".#{sid}.cleanup.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
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 = File.join(sessions_dir, "#{sid}.meta.json")
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 = path.sub(/\.jsonl\z/, '.meta.json')
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
- return original unless parsed.is_a?(Hash) && parsed[:title].is_a?(String) && parsed[:summary].is_a?(String)
521
- return original if !summary_title_valid?(title: parsed[:title]) || parsed[:summary].strip.empty?
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 = File.join(sessions_dir, "#{sid}.meta.json")
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 = File.join(sessions_dir, ".#{sid}.cleanup.lock")
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(File.join(sessions_dir, "#{sid}.meta.json"))
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], File.join(dir, "#{e[:id]}.meta.json")]) unless dry
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], File.join(dir, "#{e[:id]}.meta.json")]) unless dry
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
- # Mistakes refs
969
- mist_file = File.join(Dir.home, '.pwn', 'mistakes.json')
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(File.join(sessions_dir, ".#{id}.cleanup.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.782'
4
+ VERSION = '0.5.783'
5
5
  end