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
|
@@ -262,7 +262,9 @@ module PWN
|
|
|
262
262
|
|
|
263
263
|
private_class_method def self.evidence_write(opts = {})
|
|
264
264
|
path = opts[:path]
|
|
265
|
-
|
|
265
|
+
# Verification is byte equality, not Ruby's encoding-sensitive text equality.
|
|
266
|
+
# Retag a copy only: do not transcode, normalize or scrub evidence/backups.
|
|
267
|
+
body = opts[:body].b
|
|
266
268
|
dir = File.dirname(path)
|
|
267
269
|
ancestor = dir
|
|
268
270
|
ancestor = File.dirname(ancestor) until File.exist?(ancestor) || File.symlink?(ancestor)
|
|
@@ -332,7 +334,8 @@ module PWN
|
|
|
332
334
|
# Reentrant across note_outcome -> pruning; flock serializes cooperating processes.
|
|
333
335
|
private_class_method def self.learning_lock
|
|
334
336
|
key = :pwn_learning_write_lock
|
|
335
|
-
|
|
337
|
+
owner = Thread.current[key]
|
|
338
|
+
return nil if owner.is_a?(Hash) && owner[:pid] == Process.pid && owner[:thread] == Thread.current && !owner[:file].closed?
|
|
336
339
|
|
|
337
340
|
root = File.dirname(LEARNING_FILE)
|
|
338
341
|
FileUtils.mkdir_p(root, mode: 0o700)
|
|
@@ -340,9 +343,9 @@ module PWN
|
|
|
340
343
|
raise IOError, 'Unsafe learning lock' if File.symlink?(path) || File.realpath(root) != File.expand_path(root)
|
|
341
344
|
|
|
342
345
|
# Ownership transfers to the caller's ensure/learning_unlock.
|
|
343
|
-
file = File.open(path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
|
|
346
|
+
file = File.open(path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen
|
|
344
347
|
file.flock(File::LOCK_EX)
|
|
345
|
-
Thread.current[key] = file
|
|
348
|
+
Thread.current[key] = { pid: Process.pid, thread: Thread.current, file: file }
|
|
346
349
|
file
|
|
347
350
|
end
|
|
348
351
|
|
|
@@ -1250,6 +1253,8 @@ module PWN
|
|
|
1250
1253
|
# stays high-signal.
|
|
1251
1254
|
|
|
1252
1255
|
public_class_method def self.consolidate(opts = {})
|
|
1256
|
+
return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { consolidate(opts) } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
|
|
1257
|
+
|
|
1253
1258
|
cap = opts[:max_entries] || MAX_MEMORY_ENTRIES
|
|
1254
1259
|
return { removed: 0 } unless defined?(PWN::Memory)
|
|
1255
1260
|
|
|
@@ -2221,6 +2226,8 @@ module PWN
|
|
|
2221
2226
|
# after upgrading; subsequent writes never produce these.
|
|
2222
2227
|
|
|
2223
2228
|
public_class_method def self.purge_noise
|
|
2229
|
+
return PWN::Memory.transaction(path: PWN::Memory::MEMORY_FILE) { purge_noise } unless PWN::Memory.transaction_owned?(path: PWN::Memory::MEMORY_FILE)
|
|
2230
|
+
|
|
2224
2231
|
return { removed: 0 } unless defined?(PWN::Memory)
|
|
2225
2232
|
|
|
2226
2233
|
mem = nil
|
|
@@ -63,15 +63,18 @@ module PWN
|
|
|
63
63
|
FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
|
|
64
64
|
return {} unless File.exist?(MISTAKES_FILE)
|
|
65
65
|
|
|
66
|
-
JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
store = JSON.parse(File.read(MISTAKES_FILE), symbolize_names: true)
|
|
67
|
+
raise IOError, 'Invalid mistakes map' unless store.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
store
|
|
69
70
|
end
|
|
70
71
|
|
|
71
72
|
# Supported Method Parameters::
|
|
72
73
|
# PWN::AI::Agent::Mistakes.save(store: hash)
|
|
73
74
|
|
|
74
75
|
public_class_method def self.save(opts = {})
|
|
76
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
77
|
+
|
|
75
78
|
store = opts[:store] ||= {}
|
|
76
79
|
FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
|
|
77
80
|
atomic_write(path: MISTAKES_FILE, body: JSON.pretty_generate(store))
|
|
@@ -195,6 +198,8 @@ module PWN
|
|
|
195
198
|
# the caller (Loop.run) can drive cross-session repeat detection.
|
|
196
199
|
|
|
197
200
|
public_class_method def self.record(opts = {})
|
|
201
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { record(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
202
|
+
|
|
198
203
|
tool = opts[:tool].to_s
|
|
199
204
|
error = opts[:error].to_s
|
|
200
205
|
return nil if tool.empty? || error.strip.empty?
|
|
@@ -272,6 +277,8 @@ module PWN
|
|
|
272
277
|
# )
|
|
273
278
|
|
|
274
279
|
public_class_method def self.resolve(opts = {})
|
|
280
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { resolve(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
281
|
+
|
|
275
282
|
sig = opts[:signature].to_s
|
|
276
283
|
fix = opts[:fix].to_s
|
|
277
284
|
raise 'ERROR: signature is required' if sig.empty?
|
|
@@ -416,6 +423,8 @@ module PWN
|
|
|
416
423
|
}.freeze
|
|
417
424
|
|
|
418
425
|
public_class_method def self.extinguish!(opts = {})
|
|
426
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
427
|
+
|
|
419
428
|
sig = opts[:signature].to_s
|
|
420
429
|
return nil if sig.empty?
|
|
421
430
|
|
|
@@ -457,6 +466,8 @@ module PWN
|
|
|
457
466
|
# Auto-resolve parked items that already have a known extinguish recipe
|
|
458
467
|
# so the operator inbox does not keep scars the loop can close itself.
|
|
459
468
|
public_class_method def self.extinguish_parked!(opts = {})
|
|
469
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { extinguish_parked!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
470
|
+
|
|
460
471
|
limit = (opts[:limit] || 20).to_i
|
|
461
472
|
dry = opts[:dry_run] ? true : false
|
|
462
473
|
rows = load.values.select do |m|
|
|
@@ -495,6 +506,8 @@ module PWN
|
|
|
495
506
|
end
|
|
496
507
|
|
|
497
508
|
public_class_method def self.park(opts = {})
|
|
509
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { park(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
510
|
+
|
|
498
511
|
sig = opts[:signature].to_s
|
|
499
512
|
raise 'ERROR: signature is required' if sig.empty?
|
|
500
513
|
|
|
@@ -692,6 +705,8 @@ module PWN
|
|
|
692
705
|
end
|
|
693
706
|
|
|
694
707
|
public_class_method def self.note_hint_outcome(opts = {})
|
|
708
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { note_hint_outcome(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
709
|
+
|
|
695
710
|
sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
|
|
696
711
|
return nil if sig.empty?
|
|
697
712
|
|
|
@@ -777,6 +792,8 @@ module PWN
|
|
|
777
792
|
# drop after Memory already holds mistake_fix_<sig>.
|
|
778
793
|
|
|
779
794
|
public_class_method def self.lean!(opts = {})
|
|
795
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
796
|
+
|
|
780
797
|
dry = opts[:dry_run] ? true : false
|
|
781
798
|
max_resolved = (opts[:max_resolved_kept] || MAX_RESOLVED_KEPT).to_i
|
|
782
799
|
min_age = (opts[:resolved_min_age_days] || RESOLVED_MIN_AGE_DAYS).to_f
|
|
@@ -878,6 +895,8 @@ module PWN
|
|
|
878
895
|
# PWN::AI::Agent::Mistakes.reset
|
|
879
896
|
|
|
880
897
|
public_class_method def self.reset
|
|
898
|
+
return PWN::Memory.transaction(path: MISTAKES_FILE) { reset } unless PWN::Memory.transaction_owned?(path: MISTAKES_FILE)
|
|
899
|
+
|
|
881
900
|
FileUtils.rm_f(MISTAKES_FILE)
|
|
882
901
|
{}
|
|
883
902
|
end
|
|
@@ -52,23 +52,23 @@ PWN::AI::Agent::Registry.register(
|
|
|
52
52
|
toolset: 'sessions',
|
|
53
53
|
schema: {
|
|
54
54
|
name: 'sessions_list',
|
|
55
|
-
description: 'List
|
|
56
|
-
'(id, title,
|
|
55
|
+
description: 'List the current active session and valid summarized or dependency-backed pwn-ai sessions in ~/.pwn/sessions ' \
|
|
56
|
+
'as paginated concise rows (id, title, mtime, dependencies), oldest-first, with total and global diagnostics. ' \
|
|
57
57
|
'dependencies lists files referencing this session through structured links, not prose mentions. ' \
|
|
58
58
|
'Use to discover a session_id for learning_reflect / ' \
|
|
59
59
|
'learning_distill_skill / sessions_view.',
|
|
60
60
|
parameters: {
|
|
61
61
|
type: 'object',
|
|
62
62
|
properties: {
|
|
63
|
-
limit: { type: 'integer', default: 25, description: 'Max sessions to return (
|
|
63
|
+
limit: { type: 'integer', default: 25, description: 'Max sessions to return (oldest first).' },
|
|
64
|
+
offset: { type: 'integer', description: 'Number of retained sessions to skip; omitted selects newest page. Current active session is also included.' }
|
|
64
65
|
},
|
|
65
66
|
required: []
|
|
66
67
|
}
|
|
67
68
|
},
|
|
68
69
|
check: -> { defined?(PWN::Sessions) },
|
|
69
70
|
handler: lambda { |args|
|
|
70
|
-
limit
|
|
71
|
-
PWN::Sessions.list.sort_by { |s| s[:mtime].to_s }.reverse.first(limit)
|
|
71
|
+
PWN::Sessions.catalog(limit: args[:limit] || 25, offset: args[:offset], current_session_id: Thread.current[:pwn_session_id])
|
|
72
72
|
}
|
|
73
73
|
)
|
|
74
74
|
|
|
@@ -152,7 +152,7 @@ PWN::AI::Agent::Registry.register(
|
|
|
152
152
|
},
|
|
153
153
|
check: -> { defined?(PWN::Sessions) },
|
|
154
154
|
handler: lambda { |_args|
|
|
155
|
-
listing = PWN::Sessions.list
|
|
155
|
+
listing = PWN::Sessions.list(include_hidden: true)
|
|
156
156
|
PWN::Sessions.stats.merge(
|
|
157
157
|
disk_bytes: listing.sum { |s| s[:size].to_i },
|
|
158
158
|
dir: PWN::Sessions.sessions_dir
|
data/lib/pwn/ai/http_retry.rb
CHANGED
|
@@ -31,6 +31,9 @@ module PWN
|
|
|
31
31
|
# Resolve provider defaults from the active request without mutating Env.
|
|
32
32
|
public_class_method def self.provider_config(opts = {})
|
|
33
33
|
engine = opts[:engine]
|
|
34
|
+
title_config = Thread.current[:pwn_session_title_config]
|
|
35
|
+
return title_config[engine] if title_config
|
|
36
|
+
|
|
34
37
|
owner = Thread.current[:pwn_request_runtime]
|
|
35
38
|
return owner.provider_config(engine) if owner.respond_to?(:provider_config)
|
|
36
39
|
|
data/lib/pwn/ai/router.rb
CHANGED
|
@@ -226,7 +226,9 @@ module PWN
|
|
|
226
226
|
private_class_method def self.env_dig(opts = {})
|
|
227
227
|
return {} unless defined?(PWN::Env) && PWN::Env.respond_to?(:dig)
|
|
228
228
|
|
|
229
|
-
|
|
229
|
+
keys = opts[:keys]
|
|
230
|
+
config = Thread.current[:pwn_session_title_config]
|
|
231
|
+
val = config && keys.first == :ai ? config.dig(*keys.drop(1)) : PWN::Env.dig(*keys)
|
|
230
232
|
if val.is_a?(Hash)
|
|
231
233
|
val.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
232
234
|
else
|
data/lib/pwn/cron.rb
CHANGED
|
@@ -255,7 +255,7 @@ module PWN
|
|
|
255
255
|
{
|
|
256
256
|
name: 'session_cleanup',
|
|
257
257
|
schedule: '45 3 * * *',
|
|
258
|
-
ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.
|
|
258
|
+
ruby: "require File.join(PWN::Cron.cron_dir, 'session_cleanup.rb'); PWN::SessionCleanup.maintain(dry_run: false)",
|
|
259
259
|
enabled: true
|
|
260
260
|
},
|
|
261
261
|
{
|
data/lib/pwn/memory.rb
CHANGED
|
@@ -23,6 +23,31 @@ module PWN
|
|
|
23
23
|
EPHEMERAL_KEY_PREFIXES = %w[session_].freeze
|
|
24
24
|
EPHEMERAL_TTL_SECS = 7 * 86_400
|
|
25
25
|
|
|
26
|
+
# Permanent writer inode; nested calls share ownership only in this thread
|
|
27
|
+
# and process. Never unlink this lock when replacing a data file.
|
|
28
|
+
public_class_method def self.transaction(opts = {})
|
|
29
|
+
path = File.expand_path(opts[:path] || MEMORY_FILE)
|
|
30
|
+
return yield if transaction_owned?(path: path)
|
|
31
|
+
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
33
|
+
File.open("#{path}.write.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
34
|
+
lock.flock(File::LOCK_EX)
|
|
35
|
+
owners = (Thread.current[:pwn_store_transactions] ||= {})
|
|
36
|
+
owners[path] = Process.pid
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
owners.delete(path)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Test ownership without mistaking an inherited post-fork marker for a lock.
|
|
46
|
+
public_class_method def self.transaction_owned?(opts = {})
|
|
47
|
+
path = File.expand_path(opts[:path] || MEMORY_FILE)
|
|
48
|
+
Thread.current[:pwn_store_transactions]&.fetch(path, nil) == Process.pid
|
|
49
|
+
end
|
|
50
|
+
|
|
26
51
|
# Supported Method Parameters::
|
|
27
52
|
# memory = PWN::Memory.load
|
|
28
53
|
public_class_method def self.load
|
|
@@ -47,6 +72,8 @@ module PWN
|
|
|
47
72
|
# Supported Method Parameters::
|
|
48
73
|
# PWN::Memory.save(mem: memory_hash)
|
|
49
74
|
public_class_method def self.save(opts = {})
|
|
75
|
+
return transaction(path: MEMORY_FILE) { save(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
76
|
+
|
|
50
77
|
mem = opts[:mem] ||= {}
|
|
51
78
|
force = opts[:force] ? true : false
|
|
52
79
|
FileUtils.mkdir_p(File.dirname(MEMORY_FILE))
|
|
@@ -58,8 +85,15 @@ module PWN
|
|
|
58
85
|
warn "[pwn-ai/memory] refusing empty overwrite of #{File.size(path)}B #{path} (pass force:true to clear)"
|
|
59
86
|
return load_raw_or_empty(path: path)
|
|
60
87
|
end
|
|
88
|
+
atomic_write(path: path, body: JSON.pretty_generate(mem))
|
|
89
|
+
mem
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Caller holds the permanent store transaction across read and replacement.
|
|
93
|
+
private_class_method def self.atomic_write(opts = {})
|
|
94
|
+
path = opts[:path]
|
|
95
|
+
body = opts[:body]
|
|
61
96
|
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
62
|
-
body = JSON.pretty_generate(mem)
|
|
63
97
|
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
|
|
64
98
|
f.flock(File::LOCK_EX)
|
|
65
99
|
f.write(body)
|
|
@@ -67,7 +101,7 @@ module PWN
|
|
|
67
101
|
f.fsync
|
|
68
102
|
end
|
|
69
103
|
File.rename(tmp, path)
|
|
70
|
-
|
|
104
|
+
File.open(File.dirname(path), File::RDONLY, &:fsync)
|
|
71
105
|
ensure
|
|
72
106
|
FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
73
107
|
end
|
|
@@ -93,6 +127,8 @@ module PWN
|
|
|
93
127
|
# ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)'
|
|
94
128
|
# )
|
|
95
129
|
public_class_method def self.remember(opts = {})
|
|
130
|
+
return transaction(path: MEMORY_FILE) { remember(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
131
|
+
|
|
96
132
|
key = opts[:key]
|
|
97
133
|
value = opts[:value]
|
|
98
134
|
category = opts[:category] || :fact
|
|
@@ -479,7 +515,9 @@ module PWN
|
|
|
479
515
|
# force: 'optional - Boolean bypass protect policy (default false)'
|
|
480
516
|
# )
|
|
481
517
|
# Refuses PROTECT_KEY_PREFIXES / PROTECT_CATEGORIES unless force:true.
|
|
482
|
-
public_class_method def self.forget(opts = {})
|
|
518
|
+
public_class_method def self.forget(opts = {})
|
|
519
|
+
return transaction(path: MEMORY_FILE) { forget(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
520
|
+
|
|
483
521
|
key = opts[:key]
|
|
484
522
|
raise 'ERROR: key is required' if key.nil?
|
|
485
523
|
|
|
@@ -498,6 +536,8 @@ module PWN
|
|
|
498
536
|
# PWN::Memory.clear(force: true)
|
|
499
537
|
# Requires force:true — protected prefs/SOPs must not vanish via bare clear.
|
|
500
538
|
public_class_method def self.clear(opts = {})
|
|
539
|
+
return transaction(path: MEMORY_FILE) { clear(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
540
|
+
|
|
501
541
|
force = if opts.is_a?(Hash)
|
|
502
542
|
opts[:force] ? true : false
|
|
503
543
|
else
|
|
@@ -514,6 +554,8 @@ module PWN
|
|
|
514
554
|
# context = PWN::Memory.to_context(limit: 20)
|
|
515
555
|
# (used internally by pwn-ai hook to inject into system prompt)
|
|
516
556
|
public_class_method def self.to_context(opts = {})
|
|
557
|
+
return transaction(path: MEMORY_FILE) { to_context(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
558
|
+
|
|
517
559
|
limit = (opts[:limit] || 12).to_i
|
|
518
560
|
limit = 12 if limit <= 0
|
|
519
561
|
mem = load
|
|
@@ -556,14 +598,20 @@ module PWN
|
|
|
556
598
|
end
|
|
557
599
|
|
|
558
600
|
private_class_method def self.archive_expired!(opts = {})
|
|
601
|
+
return transaction(path: MEMORY_FILE) { archive_expired!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
602
|
+
|
|
559
603
|
rows = opts[:rows]
|
|
560
604
|
return if rows.nil? || rows.empty?
|
|
561
605
|
|
|
606
|
+
mem = load
|
|
607
|
+
rows = rows.select { |key, value| mem[key] == value }
|
|
608
|
+
return if rows.empty?
|
|
609
|
+
|
|
562
610
|
arch = File.file?(ARCHIVE_FILE) ? JSON.parse(File.read(ARCHIVE_FILE), symbolize_names: true) : {}
|
|
611
|
+
raise IOError, 'Invalid memory archive' unless arch.is_a?(Hash)
|
|
612
|
+
|
|
563
613
|
arch.merge!(rows)
|
|
564
|
-
|
|
565
|
-
File.write(ARCHIVE_FILE, JSON.pretty_generate(arch))
|
|
566
|
-
mem = load
|
|
614
|
+
atomic_write(path: ARCHIVE_FILE, body: JSON.pretty_generate(arch))
|
|
567
615
|
rows.each_key { |k| mem.delete(k) }
|
|
568
616
|
save(mem: mem, force: mem.empty?)
|
|
569
617
|
rescue StandardError
|
|
@@ -590,6 +638,8 @@ module PWN
|
|
|
590
638
|
# Compact overlong values and drop expired ephemeral session_* keys.
|
|
591
639
|
# Never removes protected prefs/SOPs/fix lessons. Safe with empty-save guard.
|
|
592
640
|
public_class_method def self.lean!(opts = {})
|
|
641
|
+
return transaction(path: MEMORY_FILE) { lean!(opts) } unless PWN::Memory.transaction_owned?(path: MEMORY_FILE)
|
|
642
|
+
|
|
593
643
|
dry = opts[:dry_run] ? true : false
|
|
594
644
|
vmax = (opts[:value_max_chars] || VALUE_MAX_CHARS).to_i
|
|
595
645
|
ttl_secs = (opts[:ephemeral_ttl_secs] || EPHEMERAL_TTL_SECS).to_i
|
|
@@ -650,6 +700,12 @@ module PWN
|
|
|
650
700
|
# Display Usage for this Module
|
|
651
701
|
public_class_method def self.help
|
|
652
702
|
puts "USAGE:
|
|
703
|
+
# Serialize a complete read-modify-write operation.
|
|
704
|
+
#{self}.transaction(path: 'optional - data store path, defaults to memory') { operation }
|
|
705
|
+
|
|
706
|
+
# Check current process and thread ownership.
|
|
707
|
+
#{self}.transaction_owned?(path: 'optional - data store path, defaults to memory')
|
|
708
|
+
|
|
653
709
|
# Run load and return its result
|
|
654
710
|
#{self}.load
|
|
655
711
|
|
data/lib/pwn/plugins/repl/ai.rb
CHANGED
|
@@ -129,7 +129,9 @@ module PWN
|
|
|
129
129
|
case cmd
|
|
130
130
|
when 'list', nil
|
|
131
131
|
output.puts('Session dependencies: referenced by these files; text mentions are not links.')
|
|
132
|
-
PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.
|
|
132
|
+
PWN::Plugins::REPL.pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: pry_instance.config.pwn_ai_session_id), output: output)
|
|
133
|
+
when 'cleanup'
|
|
134
|
+
PWN::Plugins::REPL.pwn_ai_run_sessions(args: args)
|
|
133
135
|
when 'resume'
|
|
134
136
|
sid = args[1]
|
|
135
137
|
hist = PWN::Sessions.to_response_history(session_id: sid)
|
|
@@ -262,7 +264,7 @@ module PWN
|
|
|
262
264
|
'/mcp' => %w[list backends use current connect disconnect ping tools call status help],
|
|
263
265
|
'/model' => %w[list],
|
|
264
266
|
'/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
|
|
265
|
-
'/sessions' => %w[list resume delete stats],
|
|
267
|
+
'/sessions' => %w[list resume delete stats cleanup],
|
|
266
268
|
|
|
267
269
|
'/skills' => %w[list recall],
|
|
268
270
|
'/learning' => %w[list requeue]
|
|
@@ -599,6 +601,8 @@ module PWN
|
|
|
599
601
|
end
|
|
600
602
|
true
|
|
601
603
|
rescue StandardError => e
|
|
604
|
+
raise if opts[:raise_errors]
|
|
605
|
+
|
|
602
606
|
warn "[pwn-ai] #{cmd}: #{e.class}: #{e.message}"
|
|
603
607
|
true
|
|
604
608
|
end
|
|
@@ -1038,7 +1042,13 @@ module PWN
|
|
|
1038
1042
|
case sub
|
|
1039
1043
|
when 'list'
|
|
1040
1044
|
puts('Session dependencies: referenced by these files; text mentions are not links.')
|
|
1041
|
-
pwn_ai_print_result(value: PWN::Sessions.
|
|
1045
|
+
pwn_ai_print_result(value: PWN::Sessions.catalog(offset: args[1], limit: args[2] || 25, current_session_id: opts[:pry]&.config&.pwn_ai_session_id))
|
|
1046
|
+
when 'cleanup'
|
|
1047
|
+
raise ArgumentError, 'usage: /sessions cleanup [--dry-run]' unless args.drop(1).empty? || args.drop(1) == ['--dry-run']
|
|
1048
|
+
|
|
1049
|
+
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
1050
|
+
result = PWN::SessionCleanup.maintain(dry_run: args.include?('--dry-run'), current_session_id: opts[:pry]&.config&.pwn_ai_session_id)
|
|
1051
|
+
pwn_ai_print_result(value: PWN::SessionCleanup.summary(result))
|
|
1042
1052
|
when 'resume'
|
|
1043
1053
|
sid = PWN::Sessions.resolve(identifier: opts[:identifier] || args.drop(1).join(' '))
|
|
1044
1054
|
lease = PWN::Sessions.activate(session_id: sid)
|
|
@@ -637,15 +637,14 @@ module PWN
|
|
|
637
637
|
finish_request_metrics
|
|
638
638
|
@running = false
|
|
639
639
|
@control = nil
|
|
640
|
-
queue_title_update(text) if text
|
|
640
|
+
queue_title_update(*text) if text
|
|
641
641
|
when :session_title
|
|
642
642
|
@session_metadata = text if text && text[:id] == @pry.config.pwn_ai_session_id
|
|
643
643
|
when :title_done
|
|
644
644
|
@title_worker.join
|
|
645
645
|
@title_worker = nil
|
|
646
|
-
pending = @title_pending
|
|
647
|
-
|
|
648
|
-
queue_title_update(pending) if pending
|
|
646
|
+
pending = @title_pending&.shift
|
|
647
|
+
queue_title_update(*pending) if pending
|
|
649
648
|
else
|
|
650
649
|
add(type, text)
|
|
651
650
|
end
|
|
@@ -654,13 +653,16 @@ module PWN
|
|
|
654
653
|
nil
|
|
655
654
|
end
|
|
656
655
|
|
|
657
|
-
def queue_title_update(session_id)
|
|
656
|
+
def queue_title_update(session_id, ai_config = Marshal.load(Marshal.dump(PWN::Env[:ai])))
|
|
657
|
+
@title_pending ||= {}
|
|
658
658
|
if @title_worker
|
|
659
|
-
@title_pending =
|
|
659
|
+
@title_pending[session_id] = ai_config
|
|
660
660
|
return
|
|
661
661
|
end
|
|
662
662
|
@title_worker = Thread.new do
|
|
663
|
-
@events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id
|
|
663
|
+
@events << [:session_title, PWN::Sessions.refresh_title(session_id: session_id, ai_config: ai_config, on_error: lambda { |kind|
|
|
664
|
+
@events << [:warning, "Session title refresh unavailable for #{session_id} (#{kind}); previous title retained. Resume this session to retry."]
|
|
665
|
+
})]
|
|
664
666
|
ensure
|
|
665
667
|
@events << [:title_done, nil]
|
|
666
668
|
end
|
|
@@ -689,8 +691,9 @@ module PWN
|
|
|
689
691
|
PWN::Plugins::Log.mirror_tui!(msg: "#{type.to_s.upcase}\n#{clean}") if @pry.config.pwn_ai_debug && type != :notice
|
|
690
692
|
end
|
|
691
693
|
|
|
692
|
-
def start_request(request, command: false)
|
|
694
|
+
def start_request(request, command: false, &operation)
|
|
693
695
|
session_id = @pry.config.pwn_ai_session_id
|
|
696
|
+
title_config = Marshal.load(Marshal.dump(PWN::Env[:ai]))
|
|
694
697
|
@locals = Thread.current.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current[key]] }
|
|
695
698
|
@thread_variables = Thread.current.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, Thread.current.thread_variable_get(key)] }
|
|
696
699
|
# A fresh forwarding pipe prevents unread input crossing requests.
|
|
@@ -725,16 +728,24 @@ module PWN
|
|
|
725
728
|
end
|
|
726
729
|
if command
|
|
727
730
|
control.checkpoint(messages: [], phase: :tool)
|
|
728
|
-
if
|
|
731
|
+
if operation
|
|
732
|
+
operation.call
|
|
733
|
+
elsif request.match?(%r{\A/model(?:\s|$)})
|
|
729
734
|
REPL.pwn_ai_run_model(args: request.split.drop(1), prompt: lambda { |selection|
|
|
730
735
|
control.checkpoint(messages: [])
|
|
731
736
|
@events << [:model_prompt, selection]
|
|
732
737
|
:deferred
|
|
733
738
|
})
|
|
734
739
|
else
|
|
735
|
-
REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry)
|
|
740
|
+
REPL.pwn_ai_dispatch_slash!(request: request, pry: @pry, raise_errors: true)
|
|
741
|
+
end
|
|
742
|
+
# Maintenance is one safe transaction: never label its committed
|
|
743
|
+
# deletions cancelled merely because stop was requested mid-write.
|
|
744
|
+
if request.match?(%r{\A/sessions\s+cleanup(?:\s|$)})
|
|
745
|
+
@events << [:notice, 'Session cleanup finished; completed work is not undone.']
|
|
746
|
+
else
|
|
747
|
+
control.checkpoint(messages: [])
|
|
736
748
|
end
|
|
737
|
-
control.checkpoint(messages: [])
|
|
738
749
|
else
|
|
739
750
|
workspace = File.realpath(Dir.pwd)
|
|
740
751
|
workspace_lock = File.open(File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(workspace)}.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) # rubocop:disable Style/FileOpen -- worker ensure owns the lease
|
|
@@ -757,7 +768,7 @@ module PWN
|
|
|
757
768
|
@events << [:warning, "#{e.class}: #{e.message}"]
|
|
758
769
|
ensure
|
|
759
770
|
workspace_lock&.close
|
|
760
|
-
@events << [:done, command ? nil : session_id]
|
|
771
|
+
@events << [:done, command ? nil : [session_id, title_config]]
|
|
761
772
|
end
|
|
762
773
|
@worker.report_on_exception = false
|
|
763
774
|
@control = ready.pop
|
|
@@ -810,7 +821,7 @@ module PWN
|
|
|
810
821
|
clear_view
|
|
811
822
|
elsif ['/sessions', '/sessions picker'].include?(line.strip)
|
|
812
823
|
open_operator(:sessions)
|
|
813
|
-
elsif line.strip == '/sessions cleanup'
|
|
824
|
+
elsif line.strip == '/sessions cleanup --dry-run'
|
|
814
825
|
open_operator(:cleanup)
|
|
815
826
|
elsif line.strip == '/evidence'
|
|
816
827
|
open_operator(:evidence)
|
|
@@ -837,7 +848,7 @@ module PWN
|
|
|
837
848
|
open_vault_edit(line)
|
|
838
849
|
elsif line.strip == '/system-role'
|
|
839
850
|
open_system_role
|
|
840
|
-
elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$))})
|
|
851
|
+
elsif line.match?(%r{\A/(?:mcp(?:\s|$)|cron\s+run(?:\s|$)|model(?:\s|$)|sessions\s+cleanup(?:\s|$))})
|
|
841
852
|
start_request(line, command: true)
|
|
842
853
|
elsif line.start_with?('/') || line.start_with?('ai.profile ', 'ai.memory ')
|
|
843
854
|
if line.match?(%r{\A/sessions[[:space:]]+resume(?:[[:space:]]|$)})
|
|
@@ -845,6 +856,7 @@ module PWN
|
|
|
845
856
|
|
|
846
857
|
REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
|
|
847
858
|
@session_metadata = PWN::Sessions.metadata(session_id: @pry.config.pwn_ai_session_id)
|
|
859
|
+
queue_title_update(@pry.config.pwn_ai_session_id)
|
|
848
860
|
elsif !REPL.pwn_ai_dispatch_slash!(request: line, pry: @pry)
|
|
849
861
|
add(:warning, 'Unknown command. Use / or Tab for commands.')
|
|
850
862
|
end
|
|
@@ -2027,7 +2039,7 @@ module PWN
|
|
|
2027
2039
|
view[:rows] = PWN::Sessions.list.sort_by { |row| [row[:mtime].to_s, row[:id]] } if view[:kind] == :sessions
|
|
2028
2040
|
if view[:kind] == :cleanup
|
|
2029
2041
|
require File.expand_path('../../../../etc/default_cron/session_cleanup', __dir__)
|
|
2030
|
-
view[:plan] = PWN::SessionCleanup.
|
|
2042
|
+
view[:plan] = PWN::SessionCleanup.maintain(dry_run: true, current_session_id: session_id)
|
|
2031
2043
|
view[:rows] = view[:plan][:records].map do |row|
|
|
2032
2044
|
category = row[:skip] || row[:apply_blocked_by] || (if row[:classification] == :named
|
|
2033
2045
|
:summarized
|
|
@@ -2352,7 +2364,7 @@ module PWN
|
|
|
2352
2364
|
['START HERE · local guide; no work is launched here.',
|
|
2353
2365
|
'Write a task in MISSION CONTROL. /readiness checks routes, tools and workspace locally.',
|
|
2354
2366
|
'Ctrl+S opens the team workspace. v previews your exact mission draft before confirmation.',
|
|
2355
|
-
'/evidence inspects actual checks and revisions. /sessions
|
|
2367
|
+
'/evidence inspects actual checks and revisions. /sessions cleanup runs maintenance; add --dry-run to preview.',
|
|
2356
2368
|
'/learning inbox reviews retained evidence. /steer queues guidance; Ctrl+C requests cancellation, not undo.',
|
|
2357
2369
|
'1 Insert: Inspect this workspace and explain its entry points without changing files.',
|
|
2358
2370
|
'2 Insert: Add a regression test for the reported bug, fix it, and execute the test.',
|
|
@@ -2390,13 +2402,17 @@ module PWN
|
|
|
2390
2402
|
end
|
|
2391
2403
|
|
|
2392
2404
|
def apply_operator_cleanup
|
|
2405
|
+
return add(:warning, 'Maintenance waits until the active request or swarm finishes.') if busy?
|
|
2406
|
+
|
|
2393
2407
|
view = @operator_view
|
|
2394
|
-
approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], row[:files]] }
|
|
2395
|
-
|
|
2408
|
+
approved = view[:plan][:records].select { |row| view[:plan][:candidate_ids].include?(row[:id]) }.to_h { |row| [row[:id], { files: row[:files], dependency_records: row[:dependency_records] }] }
|
|
2409
|
+
session_id = @pry.config.pwn_ai_session_id
|
|
2396
2410
|
view.delete(:confirm)
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2411
|
+
@operator_view = nil
|
|
2412
|
+
start_request('/sessions cleanup (approved preview)', command: true) do
|
|
2413
|
+
result = PWN::SessionCleanup.run(dry_run: false, current_session_id: session_id, approved_candidates: approved)
|
|
2414
|
+
@events << [:command, REPL.pwn_ai_result_text(value: result)]
|
|
2415
|
+
end
|
|
2400
2416
|
end
|
|
2401
2417
|
|
|
2402
2418
|
def operator_viewport(page)
|
data/lib/pwn/session_trace.rb
CHANGED
|
@@ -119,7 +119,7 @@ module PWN
|
|
|
119
119
|
id = opts[:session_id].to_s
|
|
120
120
|
raise ArgumentError, 'Invalid session id' unless id.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]{0,127}\z/)
|
|
121
121
|
|
|
122
|
-
dir = File.
|
|
122
|
+
dir = File.dirname(PWN::Sessions.support_path(kind: :trace, session_id: id))
|
|
123
123
|
raise ArgumentError, 'Symlinked trace directory' if File.symlink?(dir)
|
|
124
124
|
|
|
125
125
|
File.join(dir, 'trace.jsonl')
|