pwn 0.5.758 → 0.5.760

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 004ad4f77955698492a48591db3dad3f54d141fb94ddb7952211e86dc4945526
4
- data.tar.gz: 5c76e0a2f71fdc1afd13f8af828c2027cb8183f3e73612d81f220b864b6df636
3
+ metadata.gz: 2115a19977c0539855a44ecea57a2fb74bc48ae80ff3a713d2c08b95bdb04e55
4
+ data.tar.gz: 3c237b68883205d53dee7911e18be1e5b5454184f27c7d093e655e75611f5184
5
5
  SHA512:
6
- metadata.gz: ffe44c731211488e5d07938e29dc1c503307801de30d0c2effc55d139d0f00b0a40a7deeda375f05b581ea88e379b9b82f031b589236bd5a08edd590078b3511
7
- data.tar.gz: 9c8275fd41e044aed262bcbe7fcf9fe00694b43227030ba1ce90d907c6e68b54efa53dacd3c3cb712890acc159005f5ecd4c9cf1caf065de8032cfc4123041de
6
+ metadata.gz: 01c9e98a32745f26186d2f664d62f7d1801eea05a56ffe149ea20b84aaef383d5d46d909e5be97247454cedad4781c3ce393783dcc64707a07bbd818a37872c5
7
+ data.tar.gz: 562ea7604c6e23cbe128864aca11338e9c60bd7d83bd5e54692c956f136f29f036e1fb7b63c6cdb9f0defd3168c616cfff5c9194f169b9df209094952e0ae66b
data/Gemfile CHANGED
@@ -49,7 +49,7 @@ gem 'jwt', '3.3.0'
49
49
  gem 'libusb', '0.8.0'
50
50
  gem 'luhn', '3.0.0'
51
51
  gem 'mail', '2.9.1'
52
- gem 'mcp', '1.6.1'
52
+ gem 'mcp', '1.7.0'
53
53
  gem 'meshtastic', '0.0.186'
54
54
  gem 'metasm', '1.0.6'
55
55
  gem 'mongo', '2.26.0'
@@ -68,7 +68,7 @@ gem 'ostruct', '0.6.3'
68
68
  gem 'packetfu', '2.0.0'
69
69
  gem 'packetgen', '4.1.1'
70
70
  gem 'pdf-reader', '2.16.0'
71
- gem 'pg', '1.6.3'
71
+ gem 'pg', '1.7.0'
72
72
  gem 'pry', '0.16.0'
73
73
  gem 'pry-doc', '1.7.0'
74
74
  gem 'rake', '13.4.2'
@@ -59,8 +59,78 @@ module PWN
59
59
  # (:escalation_persona) — the local model still produces the final
60
60
  # answer so Learning/Metrics stay attributed to :ollama.
61
61
  module Loop
62
+ # Provider calls run on a child. Thread#raise does not wake a native read,
63
+ # so the owner waits in Ruby and leaves within one poll on cancel.
64
+ class ProviderWindow
65
+ def request_model_cancel
66
+ @mutex.synchronize do
67
+ @cancel_requested = true
68
+ if @phase == :model && !@model_signal_sent
69
+ @model_signal_sent = true
70
+ @owner.raise(Steering::ModelCancelled)
71
+ end
72
+ end
73
+ end
74
+
75
+ def model(messages:, &block)
76
+ result = nil
77
+ worker = nil
78
+ Thread.handle_interrupt(Steering::ModelCancelled => :never) do
79
+ checkpoint(messages: messages, phase: :model)
80
+ raise Steering::ModelCancelled if cancel_requested?
81
+
82
+ worker = provider_thread(&block)
83
+ Thread.handle_interrupt(Steering::ModelCancelled => :immediate) do
84
+ loop do
85
+ raise Steering::ModelCancelled if cancel_requested?
86
+
87
+ break if worker.join(0.05)
88
+ end
89
+ end
90
+ result = worker.value
91
+ ensure
92
+ abandon_provider(worker)
93
+ @mutex.synchronize { @phase = :boundary }
94
+ begin
95
+ Thread.handle_interrupt(Steering::ModelCancelled => :immediate) { nil }
96
+ rescue Steering::ModelCancelled
97
+ nil
98
+ end
99
+ end
100
+ result
101
+ rescue Steering::ModelCancelled
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ def cancel_requested?
108
+ @mutex.synchronize { @cancel_requested }
109
+ end
110
+
111
+ def provider_thread(&block)
112
+ parent = Thread.current
113
+ locals = parent.keys.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, parent[key]] }
114
+ variables = parent.thread_variables.select { |key| key.to_s.start_with?('pwn_') }.to_h { |key| [key, parent.thread_variable_get(key)] }
115
+ Thread.new do
116
+ Thread.current.report_on_exception = false
117
+ locals.each { |key, value| Thread.current[key] = value }
118
+ variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
119
+ Thread.handle_interrupt(Steering::ModelCancelled => :never, &block)
120
+ end
121
+ end
122
+
123
+ def abandon_provider(worker)
124
+ return unless worker&.alive?
125
+
126
+ worker.raise(Steering::ModelCancelled)
127
+ worker.join(0.2)
128
+ worker.kill if worker.alive?
129
+ end
130
+ end
131
+
62
132
  # Request-owned input broker; only the provider window is interruptible.
63
- class Steering
133
+ class Steering < ProviderWindow
64
134
  INPUT_LOCK = Mutex.new
65
135
  INPUT_OWNERS = {} # rubocop:disable Style/MutableConstant -- guarded by INPUT_LOCK
66
136
 
@@ -80,12 +150,14 @@ module PWN
80
150
  attr_reader :reader
81
151
 
82
152
  def initialize(input:, output:)
153
+ super()
83
154
  @input = input
84
155
  @output = output
85
156
  @owner = Thread.current
86
157
  @mutex = Mutex.new
87
158
  @pending = []
88
159
  @phase = :boundary
160
+ @cancel_requested = false
89
161
  end
90
162
 
91
163
  def submit(text)
@@ -127,23 +199,6 @@ module PWN
127
199
  raise Restart.new(messages, instructions)
128
200
  end
129
201
 
130
- def model(messages:, &block)
131
- # Drain an in-flight signal inside this rescue, never in a tool.
132
- Thread.handle_interrupt(ModelCancelled => :never) do
133
- checkpoint(messages: messages, phase: :model)
134
- Thread.handle_interrupt(ModelCancelled => :immediate, &block)
135
- ensure
136
- @mutex.synchronize { @phase = :boundary }
137
- begin
138
- Thread.handle_interrupt(ModelCancelled => :immediate) { nil }
139
- rescue ModelCancelled
140
- nil
141
- end
142
- end
143
- rescue ModelCancelled
144
- nil
145
- end
146
-
147
202
  def with_reader
148
203
  require 'io/console'
149
204
  input_id = @input.fileno
@@ -1923,12 +1978,16 @@ module PWN
1923
1978
 
1924
1979
  content = msg[:content]
1925
1980
  tool_calls = Array(msg[:tool_calls])
1981
+ promoted = false
1926
1982
  # Local/thinking models (Ollama Qwen3, DeepSeek-R1, etc.) sometimes
1927
1983
  # return only :thinking with empty :content and no tool_calls. Promote
1928
1984
  # thinking so the agent does not print a blank final answer.
1929
1985
  if content.to_s.strip.empty? && tool_calls.empty?
1930
1986
  thinking = msg[:thinking].to_s
1931
- content = thinking unless thinking.strip.empty?
1987
+ unless thinking.strip.empty?
1988
+ content = thinking
1989
+ promoted = true
1990
+ end
1932
1991
  end
1933
1992
 
1934
1993
  out = {
@@ -1953,6 +2012,7 @@ module PWN
1953
2012
  out[:_native_content] = msg[:_native_content] if msg[:_native_content]
1954
2013
  thinking = msg[:thinking] || msg[:reasoning_content]
1955
2014
  out[:thinking] = thinking if thinking.to_s.strip != ''
2015
+ out[:_thinking_promoted] = true if promoted
1956
2016
  # Local/abliterated models sometimes emit shell(...) as plain content
1957
2017
  # with empty tool_calls. Coerce registered call-shaped text into
1958
2018
  # structured tool_calls so Loop dispatches instead of FINAL-answering.
@@ -2297,6 +2357,17 @@ module PWN
2297
2357
  nil
2298
2358
  end
2299
2359
 
2360
+ private_class_method def self.emit_thinking!(opts = {})
2361
+ text = opts[:thinking].to_s.strip
2362
+ Thread.current[:pwn_last_thinking] = text
2363
+ Thread.current[:pwn_thinking_promoted] = opts[:promoted] ? true : false
2364
+ return if text.empty?
2365
+
2366
+ opts[:on_tool]&.call('thinking', text, '')
2367
+ rescue StandardError
2368
+ nil
2369
+ end
2370
+
2300
2371
  # Pre-dispatch: one high-level brief for the whole upcoming tool
2301
2372
  # collection (pwn-ai → task is one-to-many with pwn-ai → <tool>).
2302
2373
  # Prefer opts[:tools] = [{name:, args:}, ...]; falls back to single name.
@@ -2304,18 +2375,13 @@ module PWN
2304
2375
  state = opts[:state]
2305
2376
  return nil unless state && defined?(TaskSummarizer) && TaskSummarizer.enabled?
2306
2377
 
2307
- thinking = opts[:thinking].to_s.strip
2308
- line = if thinking.empty?
2309
- TaskSummarizer.about_to(
2310
- name: opts[:name],
2311
- args: opts[:args],
2312
- state: state,
2313
- request: opts[:request],
2314
- tools: opts[:tools]
2315
- )
2316
- else
2317
- thinking
2318
- end
2378
+ line = TaskSummarizer.about_to(
2379
+ name: opts[:name],
2380
+ args: opts[:args],
2381
+ state: state,
2382
+ request: opts[:request],
2383
+ tools: opts[:tools]
2384
+ )
2319
2385
  # about_to returns nil when the brief is a duplicate of the last one
2320
2386
  emit_task_summary(line: line, on_tool: opts[:on_tool]) if line
2321
2387
  line
@@ -3409,6 +3475,11 @@ module PWN
3409
3475
 
3410
3476
  calls = Array(msg[:tool_calls])
3411
3477
  text = msg[:content].to_s
3478
+ emit_thinking!(
3479
+ thinking: msg[:thinking] || msg[:reasoning_content],
3480
+ promoted: msg[:_thinking_promoted],
3481
+ on_tool: on_tool
3482
+ )
3412
3483
 
3413
3484
  # Belt-and-suspenders: plain-text shell(...) / tool forms from local
3414
3485
  # models under weak TEMPLATE {{ .Prompt }} become real tool_calls.
@@ -3525,7 +3596,6 @@ module PWN
3525
3596
  }
3526
3597
  end,
3527
3598
  request: request,
3528
- thinking: msg[:thinking] || msg[:reasoning_content],
3529
3599
  on_tool: on_tool
3530
3600
  )
3531
3601
 
data/lib/pwn/banner.rb CHANGED
@@ -187,6 +187,59 @@ module PWN
187
187
  end
188
188
  end
189
189
 
190
+ # Caller-owned simulation: yields on the UI thread, never reads input or
191
+ # accumulates a replay. The caller supplies the clock and normalized keys.
192
+ class MiniGame
193
+ def initialize(name:, width:, height:, seed: MINI_SEED)
194
+ @name = name.to_s
195
+ raise ArgumentError, "Unknown mini-banner: #{name}" unless Banner.mini_names.map(&:to_s).include?(@name)
196
+
197
+ limit = @name == 'falling_blocks' ? 64 : MINI_WIDTH
198
+ @width = width.clamp(5, limit)
199
+ @height = height.clamp(5, limit)
200
+ @keys = []
201
+ @simulation = Fiber.new do
202
+ options = { frames: self, width: @width, height: @name == 'falling_blocks' ? @height * 2 : @height,
203
+ rng: Random.new(seed), player: self }
204
+ Banner.send("mini_simulate_#{@name}", options)
205
+ end
206
+ end
207
+
208
+ def key(key)
209
+ @keys << key if @keys.length < 16
210
+ end
211
+
212
+ def controls
213
+ keys = @keys
214
+ @keys = []
215
+ keys
216
+ end
217
+
218
+ def step
219
+ @simulation.resume
220
+ end
221
+
222
+ def cells(frame)
223
+ rows = frame[:rows].drop(1)
224
+ rows = Banner.send(:mini_block_board, board: rows) if @name == 'falling_blocks'
225
+ rows = Banner.send(:mini_quarter_board, board: rows, name: @name) if @name == 'snake'
226
+ rows.map do |row|
227
+ row.chars.map do |pixel|
228
+ glyph, foreground, background = MINI_PIXELS.fetch(pixel)
229
+ { glyph: glyph.dup, foreground: foreground, background: background || :black }
230
+ end
231
+ end
232
+ end
233
+
234
+ def <<(frame)
235
+ Fiber.yield(frame)
236
+ end
237
+
238
+ def length
239
+ 0
240
+ end
241
+ end
242
+
190
243
  # Six immutable, compact replays bound memory even when callers resize/reseed.
191
244
  private_class_method def self.mini_replay(opts = {})
192
245
  key = [opts[:name].to_s.dup.freeze, opts[:width], opts[:height], opts[:seed]].freeze
@@ -347,56 +400,99 @@ module PWN
347
400
  bag = (0...MINI_SHAPES.length).to_a.shuffle(random: rng) if bag.empty?
348
401
  type = bag.shift
349
402
  shape = MINI_SHAPES[type]
350
- landing = mini_landing(board: board, shape: shape, rng: rng)
403
+ landing = opts[:player] ? {} : mini_landing(board: board, shape: shape, rng: rng)
351
404
  x = (width - shape.map(&:first).max - 1) / 2
352
405
  unless landing && mini_fits?(board: board, shape: shape, x: x, y: 0)
353
406
  mini_reset_board(frames: frames, board: board)
354
407
  board = Array.new(height) { ' ' * width }
355
408
  next
356
409
  end
357
- rotations = mini_rotations(shape: shape)
358
- plan = [[shape, x, 0]]
359
- blocked = false
360
- (1..landing[:rotation]).each do |rotation|
361
- shape = rotations[rotation]
362
- if mini_fits?(board: board, shape: shape, x: x, y: 0)
363
- plan << [shape, x, 0]
364
- else
365
- blocked = true
410
+ if opts[:player]
411
+ shape, x, y = mini_play_piece(board: board, type: type, frames: frames, player: opts[:player])
412
+ landing = { x: x, y: y }
413
+ else
414
+ shape, x = mini_block_plan(board: board, shape: shape, x: x, landing: landing, frames: frames, type: type)
415
+ unless shape
416
+ mini_reset_board(frames: frames, board: board)
417
+ board = Array.new(height) { ' ' * width }
418
+ next
366
419
  end
367
420
  end
368
- until x == landing[:x] || blocked
369
- x += landing[:x] <=> x
370
- blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
371
- plan << [shape, x, 0] unless blocked
372
- end
373
- if blocked
374
- mini_reset_board(frames: frames, board: board)
375
- board = Array.new(height) { ' ' * width }
376
- next
377
- end
378
- (1..landing[:y]).each { |y| plan << [shape, x, y] }
379
421
  color = %w[c y m b r g g][type]
380
- plan.each do |piece, px, py|
381
- picture = board.map(&:dup)
382
- active = piece.map { |dx, dy| [px + dx, py + dy] }
383
- active.each { |ax, ay| picture[ay][ax] = color }
384
- mini_emit(frames: frames, board: picture,
385
- state: { active: active, stack: board.map(&:dup), type: type })
386
- end
387
422
  shape.each { |dx, dy| board[landing[:y] + dy][x + dx] = color }
388
423
  mini_emit(frames: frames, board: board, event: :lock, ticks: 2)
389
- full = board.each_index.reject { |y| board[y].include?(' ') }
424
+ full = board.each_index.reject { |row| board[row].include?(' ') }
390
425
  next if full.empty?
391
426
 
392
427
  flash = board.map(&:dup)
393
- full.each { |y| flash[y] = 'w' * width }
428
+ full.each { |row| flash[row] = 'w' * width }
394
429
  mini_emit(frames: frames, board: flash, event: :clear, state: { lines: full }, ticks: 3)
395
- board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, y| row unless full.include?(y) }
430
+ board = Array.new(full.length) { ' ' * width } + board.each_with_index.filter_map { |row, index| row unless full.include?(index) }
396
431
  mini_emit(frames: frames, board: board, event: :collapse, ticks: 2)
397
432
  end
398
433
  end
399
434
 
435
+ private_class_method def self.mini_block_plan(opts = {})
436
+ board, shape, x, landing, frames, type = opts.values_at(:board, :shape, :x, :landing, :frames, :type)
437
+ rotations = mini_rotations(shape: shape)
438
+ plan = [[shape, x, 0]]
439
+ blocked = false
440
+ (1..landing[:rotation]).each do |rotation|
441
+ shape = rotations[rotation]
442
+ if mini_fits?(board: board, shape: shape, x: x, y: 0)
443
+ plan << [shape, x, 0]
444
+ else
445
+ blocked = true
446
+ end
447
+ end
448
+ until x == landing[:x] || blocked
449
+ x += landing[:x] <=> x
450
+ blocked = !mini_fits?(board: board, shape: shape, x: x, y: 0)
451
+ plan << [shape, x, 0] unless blocked
452
+ end
453
+ return if blocked
454
+
455
+ (1..landing[:y]).each { |y| plan << [shape, x, y] }
456
+ color = %w[c y m b r g g][type]
457
+ plan.each do |piece, px, py|
458
+ picture = board.map(&:dup)
459
+ active = piece.map { |dx, dy| [px + dx, py + dy] }
460
+ active.each { |ax, ay| picture[ay][ax] = color }
461
+ mini_emit(frames: frames, board: picture,
462
+ state: { active: active, stack: board.map(&:dup), type: type })
463
+ end
464
+ [shape, x]
465
+ end
466
+
467
+ private_class_method def self.mini_play_piece(opts = {})
468
+ board, type, frames, player = opts.values_at(:board, :type, :frames, :player)
469
+ shape = MINI_SHAPES[type]
470
+ x = (board.first.length - shape.map(&:first).max - 1) / 2
471
+ y = 0
472
+ tick = 0
473
+ loop do
474
+ active = shape.map { |dx, dy| [x + dx, y + dy] }
475
+ picture = board.map(&:dup)
476
+ active.each { |ax, ay| picture[ay][ax] = %w[c y m b r g g][type] }
477
+ mini_emit(frames: frames, board: picture, state: { active: active, stack: board.map(&:dup), type: type })
478
+ keys = player.controls
479
+ keys.each do |key|
480
+ candidate = key == ' ' ? mini_rotations(shape: shape).fetch(1, shape) : shape
481
+ nx = x + { left: -1, right: 1 }.fetch(key, 0)
482
+ next unless mini_fits?(board: board, shape: candidate, x: nx, y: y)
483
+
484
+ shape = candidate
485
+ x = nx
486
+ end
487
+ tick += 1
488
+ next unless keys.include?(:down) || (tick % 5).zero?
489
+ break unless mini_fits?(board: board, shape: shape, x: x, y: y + 1)
490
+
491
+ y += 1
492
+ end
493
+ [shape, x, y]
494
+ end
495
+
400
496
  private_class_method def self.mini_reset_board(opts = {})
401
497
  board = opts[:board].map { |row| row.gsub(/[^ ]/, 'w') }
402
498
  mini_emit(frames: opts[:frames], board: board, event: :game_over, ticks: 8)
@@ -442,19 +538,31 @@ module PWN
442
538
  height = opts[:height]
443
539
  rng = opts[:rng]
444
540
  spaces = (0...height).flat_map { |y| (0...width).map { |x| [x, y] } }
541
+ player = opts[:player]
445
542
  while frames.length < MINI_FRAME_COUNT
446
543
  y = rng.rand(height)
447
544
  body = [[2, y], [1, y], [0, y]]
545
+ direction = [1, 0]
448
546
  food = (spaces - body).sample(random: rng)
449
547
  steps = 0
450
548
  loop do
451
549
  board = Array.new(height) { ' ' * width }
452
550
  body.each_with_index { |(x, row), index| board[row][x] = index.zero? ? 'y' : 'g' }
453
551
  board[food[1]][food[0]] = 'f' if food
454
- mini_emit(frames: frames, board: board, ticks: 2, state: { body: body.map(&:dup), food: food&.dup })
552
+ mini_emit(frames: frames, board: board, ticks: player ? 1 : 2, state: { body: body.map(&:dup), food: food&.dup })
455
553
  break if frames.length >= MINI_FRAME_COUNT
456
554
 
457
- target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng) if food
555
+ if player
556
+ turn = player.controls.filter_map { |key| { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[key] }.find do |dx, dy|
557
+ [dx, dy] != direction && [dx, dy] != direction.map(&:-@)
558
+ end
559
+ direction = turn if turn
560
+ target = [body.first[0] + direction[0], body.first[1] + direction[1]]
561
+ occupied = target == food ? body : body.take(body.length - 1)
562
+ target = nil unless spaces.include?(target) && !occupied.include?(target)
563
+ elsif food
564
+ target = mini_snake_route(body: body, food: food, width: width, height: height, rng: rng)
565
+ end
458
566
  if !food || !target || steps >= width * height * 4
459
567
  mini_reset_board(frames: frames, board: board)
460
568
  break
@@ -477,6 +585,7 @@ module PWN
477
585
  height = opts[:height]
478
586
  rng = opts[:rng]
479
587
  paddles = [(height - 1) / 2.0, (height - 1) / 2.0]
588
+ player = opts[:player]
480
589
  scores = [0, 0]
481
590
  while frames.length < MINI_FRAME_COUNT
482
591
  x = (width - 1) / 2.0
@@ -485,9 +594,14 @@ module PWN
485
594
  vy = ((rng.rand * 0.4) + 0.1) * (rng.rand(2).zero? ? -1 : 1)
486
595
  bias = Array.new(2) { rng.rand(5).zero? ? rng.rand(-2..2) : rng.rand - 0.5 }
487
596
  rally = 0
488
- mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: 16)
597
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: :serve, ticks: player ? 1 : 16)
489
598
  loop do
599
+ player&.controls&.each do |key|
600
+ paddles[0] = (paddles[0] + { up: -0.5, down: 0.5 }.fetch(key, 0)).clamp(0, height - 1)
601
+ end
490
602
  paddles.each_index do |side|
603
+ next if player && side.zero?
604
+
491
605
  incoming = side.zero? ? vx.negative? : vx.positive?
492
606
  target = incoming ? y - 0.25 + bias[side] : (height - 1) / 2.0
493
607
  speed = incoming ? 0.275 : 0.11
@@ -519,7 +633,7 @@ module PWN
519
633
  y = ny
520
634
  if x.negative? || x > width - 1
521
635
  scores[x.negative? ? 1 : 0] += 1
522
- mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: 20)
636
+ mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: nil, scores: scores, event: :score, ticks: player ? 1 : 20)
523
637
  break
524
638
  end
525
639
  mini_pong_snapshot(frames: frames, width: width, height: height, paddles: paddles, ball: [x, y], scores: scores, event: event, rally: rally)
@@ -594,13 +708,18 @@ module PWN
594
708
  end
595
709
  angle = Math.atan2(mini_delta(from: ship[1], to: target[1], span: height), mini_delta(from: ship[0], to: target[0], span: width))
596
710
  turn = mini_delta(from: ship[2], to: angle, span: Math::PI * 2).clamp(-0.1, 0.1)
711
+ keys = opts[:player]&.controls
712
+ turn = keys.sum { |key| { left: -0.2, right: 0.2 }.fetch(key, 0) } if keys
597
713
  ship[2] = (ship[2] + turn) % (Math::PI * 2)
598
- thrust = tick % 80 < 24
714
+ power = tick % 80 < 24 ? 1 : 0
715
+ power = keys.sum { |key| { up: 1, down: -1 }.fetch(key, 0) }.clamp(-1, 1) if keys
716
+ thrust = !power.zero?
599
717
  [Math.cos(ship[2]), Math.sin(ship[2])].each_with_index do |direction, axis|
600
- velocity[axis] = ((velocity[axis] * 0.99) + (thrust ? direction * 0.006 : 0)).clamp(-0.15, 0.15)
718
+ velocity[axis] = ((velocity[axis] * 0.99) + (direction * 0.006 * power)).clamp(-0.15, 0.15)
601
719
  ship[axis] = (ship[axis] + velocity[axis]) % (axis.zero? ? width : height)
602
720
  end
603
- shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if (tick % 12).zero?
721
+ fire = keys ? keys.include?(' ') : (tick % 12).zero?
722
+ shots << [ship[0], ship[1], Math.cos(ship[2]) * 0.36, Math.sin(ship[2]) * 0.36, 24] if fire
604
723
  shots.each do |shot|
605
724
  shot[0] = (shot[0] + shot[2]) % width
606
725
  shot[1] = (shot[1] + shot[3]) % height
@@ -86,6 +86,7 @@ module PWN
86
86
 
87
87
  def stop
88
88
  @stopping = true
89
+ request_model_cancel
89
90
  submit('Stop the active request.')
90
91
  end
91
92
 
@@ -266,7 +267,7 @@ module PWN
266
267
 
267
268
  # Own the terminal, event pump and the lifetime of exactly one request.
268
269
  class Console # rubocop:disable Metrics/ClassLength -- cohesive single-owner terminal state machine
269
- COLORS = { operator: :operator, task: :task, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
270
+ COLORS = { operator: :operator, task: :task, thinking: :notice, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
270
271
  PALETTE = %w[cyan green yellow red white blue magenta black].freeze
271
272
 
272
273
  def initialize(pry:, input:, curses:, getch:)
@@ -480,6 +481,8 @@ module PWN
480
481
  on_tool = lambda do |name, args, result|
481
482
  if name.to_s == 'task'
482
483
  @events << [:task, args.to_s]
484
+ elsif name.to_s == 'thinking'
485
+ @events << [:thinking, args.to_s]
483
486
  else
484
487
  @events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
485
488
  @events << [:result, result.to_s]
@@ -503,7 +506,8 @@ module PWN
503
506
  enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
504
507
  on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug, debug_tee: @output
505
508
  )
506
- @events << [:assistant, final.to_s]
509
+ promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
510
+ @events << [:assistant, final.to_s] unless promoted
507
511
  end
508
512
  rescue Control::Stopped => e
509
513
  @events << [:warning, e.message]
@@ -521,13 +525,12 @@ module PWN
521
525
 
522
526
  def cancel
523
527
  if busy?
524
- return if @cancelling
525
-
528
+ first = !@cancelling
526
529
  @cancelling = true
527
530
  @control&.stop
528
531
  @swarm&.cancel_all
529
532
  @tool_input.close unless @tool_input.closed?
530
- add(:warning, 'Cancellation requested. Waiting for the active tool to finish; work already started is not undone.')
533
+ add(:warning, 'Cancellation requested. Waiting for a safe boundary; work already started is not undone.') if first
531
534
  else
532
535
  @editor.replace('')
533
536
  end
@@ -678,6 +681,7 @@ module PWN
678
681
  def handle(key)
679
682
  return if key.nil?
680
683
  return handle_system_role(key) if @role_editor && (!@too_small || ["\e", "\u0003", "\u0004", :eof].include?(key))
684
+ return cancel if key == "\u0003" && busy?
681
685
 
682
686
  return if @too_small && ![:eof, "\u0003", "\u0004"].include?(key)
683
687
  return cancel if @too_small && key == "\u0003" && !@model_prompt
@@ -711,7 +715,10 @@ module PWN
711
715
 
712
716
  if ["\u0018", "\u0014"].include?(key)
713
717
  @menu = nil
714
- @focus = @focus == :session ? :mission : :session
718
+ panes = @header_pane_height ? %i[mission session animation] : %i[mission session]
719
+ @focus = panes[(panes.index(@focus).to_i + 1) % panes.length]
720
+ @banner_game&.controls
721
+ @banner_game_tick = nil
715
722
  return
716
723
  end
717
724
 
@@ -723,6 +730,8 @@ module PWN
723
730
  @menu = nil
724
731
  return
725
732
  end
733
+ return handle_animation(key) if @focus == :animation && ![:eof, "\u0003", "\u0004"].include?(key)
734
+
726
735
  case key
727
736
  when :eof, "\u0004"
728
737
  @leaving = true
@@ -791,6 +800,24 @@ module PWN
791
800
  end
792
801
  end
793
802
 
803
+ def handle_animation(key)
804
+ if key == :paste_start
805
+ @focus = :mission
806
+ @paste = true
807
+ @banner_game&.controls
808
+ elsif [:up, :down, :left, :right, ' '].include?(key)
809
+ @banner_game&.key(key)
810
+ elsif ["\r", "\n", "\e", :newline].include?(key)
811
+ @focus = :mission
812
+ @banner_game&.controls
813
+ elsif key.is_a?(String) && key.ord >= 32
814
+ @focus = :mission
815
+ @banner_game&.controls
816
+ @editor.insert(key)
817
+ refresh_completion
818
+ end
819
+ end
820
+
794
821
  def open_system_role
795
822
  return add(:warning, 'System role cannot change while a request or swarm job is running.') if busy?
796
823
 
@@ -1198,6 +1225,7 @@ module PWN
1198
1225
  end
1199
1226
  cadence = cells ? @banner_frame_seconds : PWN::Banner::MINI_FRAME_SECONDS
1200
1227
  frame = ((now - @banner_started) / cadence).floor % PWN::Banner::MINI_FRAME_COUNT
1228
+ return playable_banner(size, now) if cells && @focus == :animation
1201
1229
  return PWN::Banner.mini_cells(name: @banner_name, frame: frame, width: size, height: size, seed: @banner_seed, branding: false) if cells
1202
1230
 
1203
1231
  art = PWN::Banner.mini_frame(name: @banner_name, frame: frame, width: size, height: size, branding: false)
@@ -1207,6 +1235,21 @@ module PWN
1207
1235
  canvas
1208
1236
  end
1209
1237
 
1238
+ def playable_banner(size, now)
1239
+ key = [@banner_session, @banner_name, size, @banner_seed]
1240
+ if @banner_game_key != key
1241
+ @banner_game_key = key
1242
+ @banner_game = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed)
1243
+ @banner_game_tick = nil
1244
+ end
1245
+ tick = (now / @banner_frame_seconds).floor
1246
+ if @banner_game_tick != tick
1247
+ @banner_game_cells = @banner_game.cells(@banner_game.step)
1248
+ @banner_game_tick = tick
1249
+ end
1250
+ @banner_game_cells
1251
+ end
1252
+
1210
1253
  def draw_banner(size)
1211
1254
  cells = banner_frame(size, cells: true)
1212
1255
  top = [(size - cells.length) / 2, 0].max
@@ -1264,6 +1307,11 @@ module PWN
1264
1307
 
1265
1308
  def footer_text
1266
1309
  text = '/ menu · ^C=cancel · ^D=back · ^L=clear · ^T=toggle pane · ^S=swarm · ^R=search ^O=operations · ↑↓ history · Shift+Enter=newline · Enter=send'
1310
+ if @focus == :animation
1311
+ controls = { pong: '↑↓ paddle', snake: '↑↓←→ steer', falling_blocks: '←→ move · ↓ faster · Space=rotate',
1312
+ asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire' }.fetch(@banner_name, '↑↓←→ play')
1313
+ return text.sub('↑↓ history', controls).sub('Shift+Enter=newline · Enter=send', 'Enter/Esc=draft')
1314
+ end
1267
1315
  @focus == :session ? text.sub('↑↓ history', '↑↓ scroll · HOME · PGUP · PGDN · END') : text
1268
1316
  end
1269
1317
 
@@ -1292,6 +1340,7 @@ module PWN
1292
1340
  visible.each_with_index { |spans, index| put_spans(2 + index, @header_text_column, spans) }
1293
1341
  if @header_pane_height
1294
1342
  box(0, 0, height, height, '')
1343
+ mark_active(0, 0, height, 'PLAY') if @focus == :animation
1295
1344
  draw_banner(height - 2)
1296
1345
  end
1297
1346
  height
@@ -1335,6 +1384,8 @@ module PWN
1335
1384
  end
1336
1385
  engine = PWN::Env.dig(:ai, :active).to_s
1337
1386
  model = REPL.pwn_ai_engine_model(engine: engine)
1387
+ header_layout(engine, model)
1388
+ @focus = :mission if @focus == :animation && !@header_pane_height
1338
1389
  footer, compose_height, footer_height = layout_chrome
1339
1390
  @compose_height = compose_height
1340
1391
  @footer_height = footer_height
@@ -1356,9 +1407,9 @@ module PWN
1356
1407
  rows.slice(start, @page_size).to_a.each_with_index { |(color, row), index| put(header_height + 1 + index, 2, row, color) }
1357
1408
  draw_sidebar(header_height, pane_width + 1, timeline_height) if sidebar.positive?
1358
1409
  compose_y = @height - compose_height - footer_height
1359
- mission_title = @focus == :session ? 'MISSION CONTROL' : 'MISSION CONTROL · active'
1410
+ mission_title = @focus == :mission ? 'MISSION CONTROL · active' : 'MISSION CONTROL'
1360
1411
  box(compose_y, 0, compose_height, @width - 1, mission_title)
1361
- mark_active(compose_y, 0, @width - 1, mission_title) if @focus != :session
1412
+ mark_active(compose_y, 0, @width - 1, mission_title) if @focus == :mission
1362
1413
  draw_composer(compose_y, height: compose_height)
1363
1414
  footer_top = @height - footer_height
1364
1415
  footer.first(footer_height).each_with_index { |line, index| put(footer_top + index, 1, line, tone(:footer)) }
@@ -1367,7 +1418,9 @@ module PWN
1367
1418
  draw_details(engine, model) if @details
1368
1419
  draw_swarm if @workspace
1369
1420
  draw_system_role if @role_editor
1370
- if @focus == :session && !(@details || @workspace || @role_editor)
1421
+ if @focus == :animation && !(@details || @workspace || @role_editor || @model_prompt)
1422
+ @screen.setpos(1, 1)
1423
+ elsif @focus == :session && !(@details || @workspace || @role_editor)
1371
1424
  @screen.setpos(header_height + 1, 2)
1372
1425
  elsif @role_editor || !(@details || @workspace)
1373
1426
  @screen.setpos(*@cursor_position)
@@ -1652,6 +1705,7 @@ module PWN
1652
1705
  puts "USAGE:
1653
1706
  # Ctrl+L clears only the session pane, including while busy.
1654
1707
  # Ctrl+T toggles the active pane between SESSION and MISSION CONTROL. Ctrl+S opens swarm.
1708
+ # Thinking from a model that returns it is a THINKING row in the session pane, above the answer.
1655
1709
  # Up/Down select in menus, recall ~/.pwn/pwn_history in the mission pane, and scroll the session pane when it is active.
1656
1710
  # Search: Enter accepts without sending; Esc restores the draft.
1657
1711
  # Launch the single-owner curses console; non-terminals return unavailable.
@@ -15,6 +15,7 @@ module PWN
15
15
 
16
16
  def stop
17
17
  @stopping = true
18
+ request_model_cancel
18
19
  submit('Stop this swarm job.')
19
20
  end
20
21
 
@@ -279,6 +280,10 @@ module PWN
279
280
 
280
281
  def tool_callback(job, opts)
281
282
  lambda do |name, args, result|
283
+ if name.to_s == 'thinking'
284
+ opts[:on_event]&.call(:thinking, args.to_s)
285
+ return
286
+ end
282
287
  update(job, job[:state], last_tool: name)
283
288
  opts[:on_event]&.call(:tool, "#{job[:agent]} [#{job[:id]}] #{name}\n#{args.inspect}")
284
289
  opts[:on_event]&.call(:result, result.to_s)
@@ -561,6 +561,19 @@ module PWN
561
561
  mirror.call("#{header}\n#{body_out}")
562
562
  next
563
563
  end
564
+ if name.to_s == 'thinking'
565
+ body = args.is_a?(String) ? args.to_s : args.inspect
566
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
567
+ header = "[ #{timestamp} → pwn-ai → thinking ]"
568
+ puts "\e[36m#{header}\e[0m"
569
+ body_out = +''
570
+ body.to_s.each_line do |ln|
571
+ puts "\e[36m #{ln.rstrip}\e[0m"
572
+ body_out << " #{ln.rstrip}\n"
573
+ end
574
+ mirror.call("#{header}\n#{body_out}")
575
+ next
576
+ end
564
577
 
565
578
  argv = args.is_a?(String) ? args.to_s : args.inspect
566
579
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
@@ -592,9 +605,12 @@ module PWN
592
605
  debug_tee: $stdout
593
606
  )
594
607
  $stdout.flush
595
- puts "\n\e[32m#{final}\e[0m\n\n"
596
- $stdout.flush
597
- PWN::Plugins::Log.mirror_tui!(msg: "\n#{final}\n\n") if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
608
+ promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
609
+ unless promoted
610
+ puts "\n\e[32m#{final}\e[0m\n\n"
611
+ $stdout.flush
612
+ PWN::Plugins::Log.mirror_tui!(msg: "\n#{final}\n\n") if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
613
+ end
598
614
  if pi.config.pwn_ai_debug && sess_id && PWN.const_defined?(:Sessions)
599
615
  PWN::Plugins::Log.progress(
600
616
  msg: "session=#{sess_id}",
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.758'
4
+ VERSION = '0.5.760'
5
5
  end
@@ -228,7 +228,7 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
228
228
  calls = []
229
229
  owner = Thread.current
230
230
  allow(described_class).to receive(:call_engine) do |opts|
231
- expect(Thread.current).to eq(owner)
231
+ expect(Thread.current).not_to eq(owner)
232
232
  calls << Marshal.load(Marshal.dump(opts[:messages]))
233
233
  if calls.length == 1
234
234
  entered << true
@@ -2123,36 +2123,33 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
2123
2123
  expect(src).to include('byteslice(0, 2_048)')
2124
2124
  end
2125
2125
 
2126
- describe 'thinking on the task TUI line' do
2127
- it 'prefers model thinking over the TaskSummarizer brief' do
2126
+ describe 'thinking on the session pane' do
2127
+ it 'keeps the task brief and emits thinking separately' do
2128
2128
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(true)
2129
2129
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:about_to).and_return('task 1/3: scan')
2130
2130
  seen = []
2131
2131
  described_class.send(
2132
- :task_summary_about_to!,
2133
- state: { plan: ['scan'] },
2134
- tools: [{ name: 'shell' }],
2135
- request: 'scan the host',
2132
+ :emit_thinking!,
2136
2133
  thinking: 'Need to fingerprint the service before scanning.',
2137
2134
  on_tool: ->(name, args, _res) { seen << [name, args] }
2138
2135
  )
2139
- expect(seen).to eq([['task', 'Need to fingerprint the service before scanning.']])
2140
- expect(PWN::AI::Agent::TaskSummarizer).not_to have_received(:about_to)
2141
- end
2142
-
2143
- it 'falls back to the TaskSummarizer brief when thinking is blank' do
2144
- allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(true)
2145
- allow(PWN::AI::Agent::TaskSummarizer).to receive(:about_to).and_return('task 1/3: scan')
2146
- seen = []
2147
2136
  described_class.send(
2148
2137
  :task_summary_about_to!,
2149
2138
  state: { plan: ['scan'] },
2150
2139
  tools: [{ name: 'shell' }],
2151
2140
  request: 'scan the host',
2152
- thinking: ' ',
2153
2141
  on_tool: ->(name, args, _res) { seen << [name, args] }
2154
2142
  )
2155
- expect(seen).to eq([['task', 'task 1/3: scan']])
2143
+ expect(seen).to eq([
2144
+ ['thinking', 'Need to fingerprint the service before scanning.'],
2145
+ ['task', 'task 1/3: scan']
2146
+ ])
2147
+ end
2148
+
2149
+ it 'does not emit a blank thinking row' do
2150
+ seen = []
2151
+ described_class.send(:emit_thinking!, thinking: ' ', on_tool: ->(name, args, _res) { seen << [name, args] })
2152
+ expect(seen).to eq([])
2156
2153
  end
2157
2154
  end
2158
2155
 
@@ -25,7 +25,103 @@ describe PWN::Banner do
25
25
  end
26
26
  end
27
27
  end
28
+ end
29
+
30
+ describe PWN::Banner::MiniGame do
31
+ it 'renders bounded, unbranded colored frames without accumulating a replay' do
32
+ PWN::Banner.mini_names.product([5, 8, 16]).each do |name, size|
33
+ game = described_class.new(name: name, width: size, height: size)
34
+ 40.times do
35
+ frame = game.step
36
+ cells = game.cells(frame)
37
+ expect(cells.length).to eq(size)
38
+ expect(cells.map(&:length)).to all(eq(size))
39
+ expect(cells.flatten.map { |cell| cell[:glyph] }.join).not_to match(/[PWN]/)
40
+ expect(cells.flatten).to all(include(:foreground, :background))
41
+ end
42
+ expect(game.length).to eq(0)
43
+ end
44
+ end
45
+
46
+ it 'lets the player move only the left Pong paddle while the ball and opponent keep playing' do
47
+ game = PWN::Banner::MiniGame.new(name: :pong, width: 8, height: 8, seed: 73)
48
+ first = game.step
49
+ game.key(:up)
50
+ moved = game.step
51
+ expect(moved[:state][:paddles][0]).to be < first[:state][:paddles][0]
52
+ expect(moved[:state][:ball]).not_to eq(first[:state][:ball])
53
+ game.key(:down)
54
+ expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
55
+ game.key(:left)
56
+ expect(game.step[:state][:paddles][0]).to eq(first[:state][:paddles][0])
57
+ 100.times do
58
+ game.key(:up)
59
+ game.step
60
+ end
61
+ expect(game.step[:state][:paddles][0]).to eq(0)
62
+ end
63
+
64
+ it 'steers Snake with arrows without reversing into its neck or changing direction twice per tick' do
65
+ game = PWN::Banner::MiniGame.new(name: :snake, width: 16, height: 16, seed: 73)
66
+ first = game.step[:state]
67
+ game.key(:left)
68
+ moved = game.step[:state]
69
+ expect(moved[:body].first).to eq([first[:body][0][0] + 1, first[:body][0][1]])
70
+ turn = moved[:body][0][1].positive? ? :up : :down
71
+ game.key(turn)
72
+ game.key(:left)
73
+ turned = game.step[:state]
74
+ expect(turned[:body].first).to eq([moved[:body][0][0], moved[:body][0][1] + (turn == :up ? -1 : 1)])
75
+ game.key(:left)
76
+ expect(game.step[:state][:body].first[0]).to eq(turned[:body][0][0] - 1)
77
+ end
78
+
79
+ it 'moves Tetris sideways, rotates with space and soft drops one row with down' do
80
+ game = PWN::Banner::MiniGame.new(name: :falling_blocks, width: 8, height: 8, seed: 73)
81
+ first = game.step[:state]
82
+ game.key(:left)
83
+ left = game.step[:state]
84
+ expect(left[:active]).to eq(first[:active].map { |x, y| [x - 1, y] })
85
+ game.key(:right)
86
+ expect(game.step[:state][:active]).to eq(first[:active])
87
+ game.key(' ')
88
+ rotated = game.step[:state]
89
+ expect(rotated[:active]).not_to eq(first[:active])
90
+ expect(rotated[:active].map(&:last).min).to eq(0)
91
+ game.key(:down)
92
+ dropped = game.step[:state]
93
+ expect(dropped[:active]).to eq(rotated[:active].map { |x, y| [x, y + 1] })
94
+ expect(dropped[:stack]).to eq(first[:stack])
95
+ events = 150.times.map do
96
+ game.key(:down)
97
+ game.step[:event]
98
+ end
99
+ expect(events).to include(:lock)
100
+ end
28
101
 
102
+ it 'turns and thrusts Asteroids only on arrows and fires only on space' do
103
+ game = PWN::Banner::MiniGame.new(name: :asteroids, width: 16, height: 16, seed: 73)
104
+ first = game.step[:state]
105
+ idle = game.step[:state]
106
+ expect(idle[:ship]).to eq(first[:ship])
107
+ expect(idle[:shots]).to be_empty
108
+ game.key(:left)
109
+ left = game.step[:state]
110
+ expect(left[:ship][2]).to be_within(0.001).of((first[:ship][2] - 0.2) % (Math::PI * 2))
111
+ game.key(:right)
112
+ expect(game.step[:state][:ship][2]).to be_within(0.001).of(first[:ship][2])
113
+ game.key(:up)
114
+ moved = game.step[:state]
115
+ expect(moved[:ship].take(2)).not_to eq(first[:ship].take(2))
116
+ expect(moved[:thrust]).to be(true)
117
+ game.key(:down)
118
+ expect(game.step[:state][:thrust]).to be(true)
119
+ game.key(' ')
120
+ expect(game.step[:state][:shots].length).to eq(1)
121
+ end
122
+ end
123
+
124
+ describe PWN::Banner do
29
125
  def block_colors(rows)
30
126
  rows.flat_map do |row|
31
127
  [0, 2].map do |bit|
@@ -258,6 +258,27 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
258
258
  foreign&.join
259
259
  end
260
260
 
261
+ it 'leaves a provider blocked in native IO without waiting for that read to return' do
262
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
+ allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
264
+ Thread.handle_interrupt(Exception => :never) { sleep 8 }
265
+ end
266
+ keys = "what color is a lemon?\n".chars
267
+ cancelled = false
268
+ launch(keys) do
269
+ if !cancelled && @paint.any? { |row| row.include?('Elapsed') }
270
+ cancelled = true
271
+ "\u0003"
272
+ elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
273
+ "\u0004"
274
+ else
275
+ Thread.pass
276
+ nil
277
+ end
278
+ end
279
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
280
+ end
281
+
261
282
  it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
262
283
  entered = Queue.new
263
284
  calls = 0
@@ -389,6 +410,19 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
389
410
  expect(colors).to eq(operator_label: 4, operator_body: 5, assistant: 5, task: 2, tool: 1, result: 3)
390
411
  end
391
412
 
413
+ it 'shows model thinking in the session pane even when compact' do
414
+ pry = Pry.new
415
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
416
+ console.instance_variable_set(:@verbose, false)
417
+ console.add(:thinking, 'The lemon is yellow because of carotenoids.')
418
+ console.add(:assistant, 'Yellow.')
419
+ text = console.timeline_rows(60).map { |_color, row| row }.join("\n")
420
+ expect(text).to include('THINKING')
421
+ expect(text).to include('The lemon is yellow because of carotenoids.')
422
+ expect(text).to include('ASSISTANT')
423
+ expect(text).to include('Yellow.')
424
+ end
425
+
392
426
  it 'uses template colors for every unconfigured role without mutating defaults or overrides' do
393
427
  defaults = PWN::Config.env_template.dig(:ai, :tui, :theme)
394
428
  [nil, {}, 'invalid'].each do |configured|
@@ -1012,6 +1046,86 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
1012
1046
  console.draw
1013
1047
  expect(painted.map { |row| row[2] }.join).to include('SESSION · active')
1014
1048
  expect(screen).to have_received(:setpos).with(a_kind_of(Integer), 2)
1049
+ console.handle("\u0014")
1050
+ painted.clear
1051
+ console.draw
1052
+ expect(painted.map { |row| row[2] }.join).to include(' PLAY ')
1053
+ expect(painted.map { |row| row[2] }.join).not_to include('MISSION CONTROL · active')
1054
+ expect(screen).to have_received(:setpos).with(1, 1)
1055
+ allow(curses).to receive_messages(lines: 24, cols: 80)
1056
+ console.draw
1057
+ expect(console.instance_variable_get(:@focus)).to eq(:mission)
1058
+ end
1059
+
1060
+ it 'cycles mission, session and visible animation with both shortcuts without changing the draft' do
1061
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1062
+ editor = console.instance_variable_get(:@editor)
1063
+ editor.place('kept draft', 4)
1064
+ console.instance_variable_set(:@header_pane_height, 10)
1065
+ console.banner_frame(8, cells: true)
1066
+ console.handle("\u0014")
1067
+ console.handle("\u0018")
1068
+ expect(console.instance_variable_get(:@focus)).to eq(:animation)
1069
+ console.banner_frame(8, cells: true)
1070
+ game = console.instance_variable_get(:@banner_game)
1071
+ expect(game).to receive(:key).with(:up)
1072
+ expect(game).to receive(:key).with(' ')
1073
+ console.handle(:up)
1074
+ console.handle(' ')
1075
+ expect([editor.text, editor.cursor]).to eq(['kept draft', 4])
1076
+ console.handle("\u0014")
1077
+ expect(console.instance_variable_get(:@focus)).to eq(:mission)
1078
+ expect([editor.text, editor.cursor]).to eq(['kept draft', 4])
1079
+ end
1080
+
1081
+ it 'returns to the unchanged automatic animation when focus leaves the game' do
1082
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1083
+ allow(Process).to receive(:clock_gettime).and_return(10.0)
1084
+ automatic = console.banner_frame(8, cells: true)
1085
+ console.instance_variable_set(:@header_pane_height, 10)
1086
+ 2.times { console.handle("\u0014") }
1087
+ expect(console.banner_frame(8, cells: true)).not_to eq(automatic)
1088
+ console.handle("\u0018")
1089
+ expect(console.banner_frame(8, cells: true)).to eq(automatic)
1090
+ console.instance_variable_set(:@header_pane_height, nil)
1091
+ 2.times { console.handle("\u0014") }
1092
+ expect(console.instance_variable_get(:@focus)).to eq(:mission)
1093
+ end
1094
+
1095
+ it 'keeps modal, search and cancellation priority above animation controls' do
1096
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1097
+ console.instance_variable_set(:@focus, :animation)
1098
+ editor = console.instance_variable_get(:@editor)
1099
+ editor.place('draft', 2)
1100
+ game = double(key: nil)
1101
+ console.instance_variable_set(:@banner_game, game)
1102
+ console.instance_variable_set(:@details, 0)
1103
+ expect(console).to receive(:handle_details).with(:up)
1104
+ console.handle(:up)
1105
+ console.instance_variable_set(:@details, nil)
1106
+ console.instance_variable_set(:@model_prompt, {})
1107
+ expect(console).to receive(:handle_model_prompt).with(' ')
1108
+ console.handle(' ')
1109
+ console.instance_variable_set(:@model_prompt, nil)
1110
+ allow(console).to receive(:busy?).and_return(true)
1111
+ expect(console).to receive(:cancel)
1112
+ console.handle("\u0003")
1113
+ console.handle("\u0012")
1114
+ expect(editor.search).not_to be_nil
1115
+ expect(console.instance_variable_get(:@focus)).to eq(:mission)
1116
+ expect(game).not_to have_received(:key)
1117
+ expect([editor.text, editor.cursor]).to eq(['draft', 2])
1118
+ end
1119
+
1120
+ it 'preserves bracketed paste and the mission cursor when leaving animation focus' do
1121
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
1122
+ editor = console.instance_variable_get(:@editor)
1123
+ editor.place('draft', 2)
1124
+ console.instance_variable_set(:@focus, :animation)
1125
+ [:paste_start, ' ', "\n", 'x', :paste_end].each { |key| console.handle(key) }
1126
+ expect(editor.text).to eq("dr \nxaft")
1127
+ expect(editor.cursor).to eq(5)
1128
+ expect(console.instance_variable_get(:@focus)).to eq(:mission)
1015
1129
  end
1016
1130
 
1017
1131
  it 'toggles the pane with Ctrl+T and opens swarm with Ctrl+S' do
@@ -403,6 +403,7 @@
403
403
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.dispatch_fail_n Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.dispatch_fail_n`: "}]}
404
404
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.duration_unsatisfied? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.duration_unsatisfied?`: "}]}
405
405
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.emit_task_summary Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.emit_task_summary`: "}]}
406
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.emit_thinking! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.emit_thinking!`: "}]}
406
407
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.engine_transient? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.engine_transient?`: "}]}
407
408
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.escalate Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.escalate`: "}]}
408
409
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.evidence_blob Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.evidence_blob`: "}]}
@@ -486,9 +487,10 @@
486
487
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.wrap_untrusted_tool Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.wrap_untrusted_tool`: "}]}
487
488
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.write_or_read_evidenced? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.write_or_read_evidenced?`: "}]}
488
489
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.write_verified? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.write_verified?`: "}]}
490
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::ProviderWindow.model Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::ProviderWindow.model`: "}]}
491
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::ProviderWindow.request_model_cancel Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::ProviderWindow.request_model_cancel`: "}]}
489
492
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering.new Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering.new`: "}]}
490
493
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering.checkpoint Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering.checkpoint`: "}]}
491
- {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering.model Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering.model`: "}]}
492
494
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering.submit Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering.submit`: "}]}
493
495
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering.with_reader Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering.with_reader`: "}]}
494
496
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop::Steering::Restart.new Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop::Steering::Restart.new`: "}]}
@@ -1206,6 +1208,8 @@
1206
1208
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.ollama_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.ollama_rest_call`: Supported Method Parameters\n\nollama_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri (e.g. api/chat)',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1207
1209
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.parse_tool_arguments_object`: "}]}
1208
1210
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.real_config_value? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.real_config_value?`: "}]}
1211
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.strict_system_template? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.strict_system_template?`: "}]}
1212
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.template_rejects_later_system? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.template_rejects_later_system?`: "}]}
1209
1213
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.visible_from_thinking Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.visible_from_thinking`: "}]}
1210
1214
  {"messages":[{"role":"user","content":"PWN::AI::OpenAI.api_endpoint Usage"},{"role":"assistant","content":"`PWN::AI::OpenAI.api_endpoint`: "}]}
1211
1215
  {"messages":[{"role":"user","content":"PWN::AI::OpenAI.authors Usage"},{"role":"assistant","content":"`PWN::AI::OpenAI.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
@@ -1258,6 +1262,8 @@
1258
1262
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.openwebui_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.openwebui_rest_call`: Supported Method Parameters\n\nopenwebui_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1259
1263
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.parse_tool_arguments_object`: "}]}
1260
1264
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.real_config_value? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.real_config_value?`: "}]}
1265
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.strict_system_template? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.strict_system_template?`: "}]}
1266
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.template_rejects_later_system? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.template_rejects_later_system?`: "}]}
1261
1267
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.visible_from_thinking Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.visible_from_thinking`: "}]}
1262
1268
  {"messages":[{"role":"user","content":"PWN::AI::RedTeam.authors Usage"},{"role":"assistant","content":"`PWN::AI::RedTeam.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
1263
1269
  {"messages":[{"role":"user","content":"PWN::AI::RedTeam.help Usage"},{"role":"assistant","content":"`PWN::AI::RedTeam.help`: "}]}
@@ -1729,6 +1735,7 @@
1729
1735
  {"messages":[{"role":"user","content":"PWN::Banner.mini_asteroids Usage"},{"role":"assistant","content":"`PWN::Banner.mini_asteroids`: "}]}
1730
1736
  {"messages":[{"role":"user","content":"PWN::Banner.mini_asteroids_snapshot Usage"},{"role":"assistant","content":"`PWN::Banner.mini_asteroids_snapshot`: "}]}
1731
1737
  {"messages":[{"role":"user","content":"PWN::Banner.mini_block_board Usage"},{"role":"assistant","content":"`PWN::Banner.mini_block_board`: "}]}
1738
+ {"messages":[{"role":"user","content":"PWN::Banner.mini_block_plan Usage"},{"role":"assistant","content":"`PWN::Banner.mini_block_plan`: "}]}
1732
1739
  {"messages":[{"role":"user","content":"PWN::Banner.mini_cells Usage"},{"role":"assistant","content":"`PWN::Banner.mini_cells`: Supported Method Parameters\n\nReturn rows of single-column Unicode block cells, with named terminal colors. Each fresh cell has glyph, foreground and background keys; no ANSI or IO. Square means terminal-cell dimensions, not physical pixels. Caller owns borders. Use mini_frame_seconds for cadence; 1800 frames span 90 or 180 seconds. Colored blocks fill the canvas with half-cell pieces; other games cap at 16. PWN::Banner.mini_cells(\n\nname: 'optional - falling_blocks, snake, pong, or asteroids; defaults to falling_blocks',\nframe: 'optional - integer index, defaults to zero and wraps at 1800',\nwidth: 'optional - columns clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',\nheight: 'optional - rows clamped to 0..64 for blocks, 0..16 otherwise; defaults to 16',\nseed: 'optional - integer local PRNG seed, defaults to 73; retain per session',\nbranding: 'optional - include the legacy wordmark, defaults to true; false reclaims its row'\n\n)\n"}]}
1733
1740
  {"messages":[{"role":"user","content":"PWN::Banner.mini_delta Usage"},{"role":"assistant","content":"`PWN::Banner.mini_delta`: "}]}
1734
1741
  {"messages":[{"role":"user","content":"PWN::Banner.mini_emit Usage"},{"role":"assistant","content":"`PWN::Banner.mini_emit`: "}]}
@@ -1740,6 +1747,7 @@
1740
1747
  {"messages":[{"role":"user","content":"PWN::Banner.mini_landing Usage"},{"role":"assistant","content":"`PWN::Banner.mini_landing`: "}]}
1741
1748
  {"messages":[{"role":"user","content":"PWN::Banner.mini_names Usage"},{"role":"assistant","content":"`PWN::Banner.mini_names`: Supported Method Parameters\n\nList animation names; callers may sample once and retain the session choice. PWN::Banner.mini_names\n"}]}
1742
1749
  {"messages":[{"role":"user","content":"PWN::Banner.mini_pixel Usage"},{"role":"assistant","content":"`PWN::Banner.mini_pixel`: "}]}
1750
+ {"messages":[{"role":"user","content":"PWN::Banner.mini_play_piece Usage"},{"role":"assistant","content":"`PWN::Banner.mini_play_piece`: "}]}
1743
1751
  {"messages":[{"role":"user","content":"PWN::Banner.mini_pong Usage"},{"role":"assistant","content":"`PWN::Banner.mini_pong`: "}]}
1744
1752
  {"messages":[{"role":"user","content":"PWN::Banner.mini_pong_snapshot Usage"},{"role":"assistant","content":"`PWN::Banner.mini_pong_snapshot`: "}]}
1745
1753
  {"messages":[{"role":"user","content":"PWN::Banner.mini_quarter_board Usage"},{"role":"assistant","content":"`PWN::Banner.mini_quarter_board`: "}]}
@@ -1781,6 +1789,13 @@
1781
1789
  {"messages":[{"role":"user","content":"PWN::Banner::Matrix.get Usage"},{"role":"assistant","content":"`PWN::Banner::Matrix.get`: Supported Method Parameters\n\nPWN::Banner::Matrix.get\n"}]}
1782
1790
  {"messages":[{"role":"user","content":"PWN::Banner::Matrix.help Usage"},{"role":"assistant","content":"`PWN::Banner::Matrix.help`: "}]}
1783
1791
  {"messages":[{"role":"user","content":"PWN::Banner::Matrix.you? Usage"},{"role":"assistant","content":"`PWN::Banner::Matrix.you?`: Supported Method Parameters\n\nPWN::Banner::Matrix.has_you\n"}]}
1792
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.new Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.new`: "}]}
1793
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.<< Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.<<`: "}]}
1794
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.cells Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.cells`: "}]}
1795
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.controls Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.controls`: "}]}
1796
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.key Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.key`: "}]}
1797
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.length Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.length`: "}]}
1798
+ {"messages":[{"role":"user","content":"PWN::Banner::MiniGame.step Usage"},{"role":"assistant","content":"`PWN::Banner::MiniGame.step`: "}]}
1784
1799
  {"messages":[{"role":"user","content":"PWN::Banner::Ninja.authors Usage"},{"role":"assistant","content":"`PWN::Banner::Ninja.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
1785
1800
  {"messages":[{"role":"user","content":"PWN::Banner::Ninja.get Usage"},{"role":"assistant","content":"`PWN::Banner::Ninja.get`: Supported Method Parameters\n\nPWN::Banner::Ninja.get\n"}]}
1786
1801
  {"messages":[{"role":"user","content":"PWN::Banner::Ninja.help Usage"},{"role":"assistant","content":"`PWN::Banner::Ninja.help`: "}]}
@@ -3334,6 +3349,7 @@
3334
3349
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.footer_lines Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.footer_lines`: "}]}
3335
3350
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.footer_text Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.footer_text`: "}]}
3336
3351
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.handle Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.handle`: "}]}
3352
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.handle_animation Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.handle_animation`: "}]}
3337
3353
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.handle_details Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.handle_details`: "}]}
3338
3354
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.handle_model_prompt Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.handle_model_prompt`: "}]}
3339
3355
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.handle_search Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.handle_search`: "}]}
@@ -3349,6 +3365,7 @@
3349
3365
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.open_swarm Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.open_swarm`: "}]}
3350
3366
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.open_system_role Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.open_system_role`: "}]}
3351
3367
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.operation_lines Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.operation_lines`: "}]}
3368
+ {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.playable_banner Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.playable_banner`: "}]}
3352
3369
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.prepare_swarm_mission Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.prepare_swarm_mission`: "}]}
3353
3370
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.put Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.put`: "}]}
3354
3371
  {"messages":[{"role":"user","content":"PWN::Plugins::REPL::AIConsole::Console.put_spans Usage"},{"role":"assistant","content":"`PWN::Plugins::REPL::AIConsole::Console.put_spans`: "}]}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.758
4
+ version: 0.5.760
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.
@@ -505,14 +505,14 @@ dependencies:
505
505
  requirements:
506
506
  - - '='
507
507
  - !ruby/object:Gem::Version
508
- version: 1.6.1
508
+ version: 1.7.0
509
509
  type: :runtime
510
510
  prerelease: false
511
511
  version_requirements: !ruby/object:Gem::Requirement
512
512
  requirements:
513
513
  - - '='
514
514
  - !ruby/object:Gem::Version
515
- version: 1.6.1
515
+ version: 1.7.0
516
516
  - !ruby/object:Gem::Dependency
517
517
  name: meshtastic
518
518
  requirement: !ruby/object:Gem::Requirement
@@ -771,14 +771,14 @@ dependencies:
771
771
  requirements:
772
772
  - - '='
773
773
  - !ruby/object:Gem::Version
774
- version: 1.6.3
774
+ version: 1.7.0
775
775
  type: :development
776
776
  prerelease: false
777
777
  version_requirements: !ruby/object:Gem::Requirement
778
778
  requirements:
779
779
  - - '='
780
780
  - !ruby/object:Gem::Version
781
- version: 1.6.3
781
+ version: 1.7.0
782
782
  - !ruby/object:Gem::Dependency
783
783
  name: pry
784
784
  requirement: !ruby/object:Gem::Requirement