brute 0.4.1 → 1.0.0

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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/lib/brute/agent.rb +14 -0
  3. data/lib/brute/diff.rb +18 -28
  4. data/lib/brute/loop/agent_stream.rb +118 -0
  5. data/lib/brute/loop/agent_turn.rb +520 -0
  6. data/lib/brute/{compactor.rb → loop/compactor.rb} +2 -0
  7. data/lib/brute/{doom_loop.rb → loop/doom_loop.rb} +2 -0
  8. data/lib/brute/loop/step.rb +332 -0
  9. data/lib/brute/loop/tool_call_step.rb +90 -0
  10. data/lib/brute/middleware/compaction_check.rb +60 -146
  11. data/lib/brute/middleware/doom_loop_detection.rb +95 -92
  12. data/lib/brute/middleware/llm_call.rb +78 -80
  13. data/lib/brute/middleware/message_tracking.rb +115 -162
  14. data/lib/brute/middleware/otel/span.rb +25 -106
  15. data/lib/brute/middleware/otel/token_usage.rb +29 -84
  16. data/lib/brute/middleware/otel/tool_calls.rb +23 -107
  17. data/lib/brute/middleware/otel/tool_results.rb +22 -86
  18. data/lib/brute/middleware/reasoning_normalizer.rb +78 -103
  19. data/lib/brute/middleware/retry.rb +95 -76
  20. data/lib/brute/middleware/session_persistence.rb +38 -37
  21. data/lib/brute/middleware/token_tracking.rb +64 -63
  22. data/lib/brute/middleware/tool_error_tracking.rb +108 -82
  23. data/lib/brute/middleware/tool_use_guard.rb +57 -90
  24. data/lib/brute/middleware/tracing.rb +53 -63
  25. data/lib/brute/middleware.rb +18 -0
  26. data/lib/brute/orchestrator/turn.rb +105 -0
  27. data/lib/brute/pipeline.rb +77 -133
  28. data/lib/brute/prompts/build_switch.rb +21 -25
  29. data/lib/brute/prompts/environment.rb +31 -35
  30. data/lib/brute/prompts/identity.rb +22 -29
  31. data/lib/brute/prompts/instructions.rb +15 -18
  32. data/lib/brute/prompts/max_steps.rb +18 -25
  33. data/lib/brute/prompts/plan_reminder.rb +18 -26
  34. data/lib/brute/prompts/skills.rb +8 -30
  35. data/lib/brute/prompts.rb +28 -0
  36. data/lib/brute/providers/ollama.rb +135 -0
  37. data/lib/brute/providers/shell.rb +2 -2
  38. data/lib/brute/providers/shell_response.rb +2 -2
  39. data/lib/brute/providers.rb +62 -0
  40. data/lib/brute/queue/base_queue.rb +222 -0
  41. data/lib/brute/{file_mutation_queue.rb → queue/file_mutation_queue.rb} +28 -26
  42. data/lib/brute/queue/parallel_queue.rb +66 -0
  43. data/lib/brute/queue/sequential_queue.rb +63 -0
  44. data/lib/brute/store/message_store.rb +362 -0
  45. data/lib/brute/store/session.rb +106 -0
  46. data/lib/brute/{snapshot_store.rb → store/snapshot_store.rb} +2 -0
  47. data/lib/brute/{todo_store.rb → store/todo_store.rb} +2 -0
  48. data/lib/brute/system_prompt.rb +81 -194
  49. data/lib/brute/tools/delegate.rb +46 -116
  50. data/lib/brute/tools/fs_patch.rb +36 -37
  51. data/lib/brute/tools/fs_remove.rb +2 -2
  52. data/lib/brute/tools/fs_undo.rb +2 -2
  53. data/lib/brute/tools/fs_write.rb +29 -41
  54. data/lib/brute/tools/todo_read.rb +1 -1
  55. data/lib/brute/tools/todo_write.rb +1 -1
  56. data/lib/brute/tools.rb +31 -0
  57. data/lib/brute/version.rb +1 -1
  58. data/lib/brute.rb +40 -204
  59. metadata +31 -20
  60. data/lib/brute/agent_stream.rb +0 -181
  61. data/lib/brute/hooks.rb +0 -84
  62. data/lib/brute/message_store.rb +0 -463
  63. data/lib/brute/orchestrator.rb +0 -550
  64. data/lib/brute/session.rb +0 -161
@@ -1,550 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "async/barrier"
5
-
6
- module Brute
7
- # The core agent loop. Drives the cycle of:
8
- #
9
- # prompt → LLM → tool calls → execute → send results → repeat
10
- #
11
- # All cross-cutting concerns (retry, compaction, doom loop detection,
12
- # token tracking, session persistence, tracing, reasoning) are implemented
13
- # as Rack-style middleware in the Pipeline. The orchestrator is now a
14
- # thin loop that:
15
- #
16
- # 1. Sends input through the pipeline (which wraps the LLM call)
17
- # 2. Executes any tool calls the LLM requested
18
- # 3. Repeats until done or a limit is hit
19
- #
20
- # Tool execution is always deferred until after the LLM response (including
21
- # streaming) completes. Tools then run concurrently with each other via
22
- # Async::Barrier. on_tool_call_start fires once with the full batch before
23
- # execution begins; on_tool_result fires per-tool as each finishes.
24
- #
25
- class Orchestrator
26
- MAX_REQUESTS_PER_TURN = 100
27
-
28
- attr_reader :context, :session, :pipeline, :env, :barrier, :message_store
29
-
30
- def initialize(
31
- provider:,
32
- model: nil,
33
- tools: Brute::TOOLS,
34
- cwd: Dir.pwd,
35
- session: nil,
36
- compactor_opts: {},
37
- reasoning: {},
38
- agent_name: nil,
39
- on_content: nil,
40
- on_reasoning: nil,
41
- on_tool_call_start: nil,
42
- on_tool_result: nil,
43
- on_question: nil,
44
- logger: nil
45
- )
46
- @provider = provider
47
- @model = model
48
- @agent_name = agent_name
49
- @tool_classes = tools
50
- @cwd = cwd
51
- @session = session || Session.new
52
- @logger = logger || Logger.new($stderr, level: Logger::INFO)
53
- @message_store = @session.message_store
54
-
55
- # Build system prompt via deferred builder
56
- @system_prompt_builder = SystemPrompt.default
57
- @system_prompt = @system_prompt_builder.prepare(
58
- provider_name: @provider&.name,
59
- model_name: @model || @provider&.default_model,
60
- cwd: @cwd,
61
- custom_rules: load_custom_rules,
62
- agent: @agent_name,
63
- ).to_s
64
-
65
- # Initialize the LLM context (with streaming when callbacks provided)
66
- @stream = if on_content || on_reasoning
67
- AgentStream.new(
68
- on_content: on_content,
69
- on_reasoning: on_reasoning,
70
- on_question: on_question,
71
- )
72
- end
73
- ctx_opts = { tools: @tool_classes }
74
- ctx_opts[:model] = @model if @model
75
- ctx_opts[:stream] = @stream if @stream
76
- @context = LLM::Context.new(@provider, **ctx_opts)
77
-
78
- # Build the middleware pipeline
79
- compactor = Compactor.new(provider, **compactor_opts)
80
- @pipeline = build_pipeline(
81
- compactor: compactor,
82
- session: @session,
83
- logger: @logger,
84
- reasoning: reasoning,
85
- message_store: @message_store,
86
- )
87
-
88
- # The shared env hash — passed to every pipeline.call()
89
- @env = {
90
- context: @context,
91
- provider: @provider,
92
- tools: @tool_classes,
93
- input: nil,
94
- params: {},
95
- metadata: {},
96
- tool_results: nil,
97
- streaming: !!@stream,
98
- callbacks: {
99
- on_content: on_content,
100
- on_reasoning: on_reasoning,
101
- on_tool_call_start: on_tool_call_start,
102
- on_tool_result: on_tool_result,
103
- on_question: on_question,
104
- },
105
- }
106
- end
107
-
108
- # Run a single user turn. Loops internally until the agent either
109
- # completes (no more tool calls) or hits a limit.
110
- #
111
- # Returns the final assistant response.
112
- def run(user_message)
113
- unless @provider
114
- raise "No LLM provider configured. Set LLM_API_KEY and optionally LLM_PROVIDER (default: opencode_zen)"
115
- end
116
-
117
- @request_count = 0
118
-
119
- # Build the initial prompt with system message on first turn
120
- input = if first_turn?
121
- @context.prompt do |p|
122
- p.system @system_prompt
123
- p.user user_message
124
- end
125
- else
126
- user_message
127
- end
128
-
129
- # --- First LLM call ---
130
- @env[:input] = input
131
- @env[:tool_results] = nil
132
- last_response = @pipeline.call(@env)
133
- sync_context!
134
-
135
- # --- Agent loop ---
136
- loop do
137
- # Collect pending tools from either source:
138
- # - Streaming: AgentStream deferred tools (collected during stream)
139
- # - Non-streaming: ctx.functions (populated by llm.rb after response)
140
- pending = collect_pending_tools
141
- break if pending.empty?
142
-
143
- # Fire on_tool_call_start ONCE with the full batch
144
- on_start = @env.dig(:callbacks, :on_tool_call_start)
145
- on_start&.call(pending.map { |tool, _| { name: tool.name, arguments: tool.arguments } })
146
-
147
- # Separate errors (tool not found) from executable tools
148
- errors = pending.select { |_, err| err }
149
- executable = pending.reject { |_, err| err }.map(&:first)
150
-
151
- # Execute tools concurrently, collect results
152
- results = execute_tool_calls(executable)
153
-
154
- # Append error results (tool not found, etc.)
155
- errors.each do |_, err|
156
- on_result = @env.dig(:callbacks, :on_tool_result)
157
- on_result&.call(err.name, result_value(err))
158
- results << err
159
- end
160
-
161
- # Send results back through the pipeline
162
- @env[:input] = results
163
- @env[:tool_results] = extract_tool_result_pairs(results)
164
- last_response = @pipeline.call(@env)
165
- sync_context!
166
-
167
- @request_count += 1
168
-
169
- # Check limits
170
- break if !has_pending_tools?
171
- break if @request_count >= MAX_REQUESTS_PER_TURN
172
- break if @env[:metadata][:tool_error_limit_reached]
173
- end
174
-
175
- last_response
176
- end
177
-
178
- private
179
-
180
- # ------------------------------------------------------------------
181
- # Pipeline construction
182
- # ------------------------------------------------------------------
183
-
184
- def build_pipeline(compactor:, session:, logger:, reasoning:, message_store:)
185
- sys_prompt = @system_prompt
186
- tools = @tool_classes
187
- stream = @stream
188
-
189
- Pipeline.new do
190
- # OTel span lifecycle (outermost — creates env[:span])
191
- use Middleware::OTel::Span
192
-
193
- # Timing and logging
194
- use Middleware::Tracing, logger: logger
195
-
196
- # OTel: record tool results being sent back (pre-call)
197
- use Middleware::OTel::ToolResults
198
-
199
- # Retry transient errors (wraps everything below)
200
- use Middleware::Retry
201
-
202
- # Save after each successful LLM call
203
- use Middleware::SessionPersistence, session: session
204
-
205
- # Record structured messages in OpenCode {info, parts} format
206
- use Middleware::MessageTracking, store: message_store
207
-
208
- # Track cumulative token usage
209
- use Middleware::TokenTracking
210
-
211
- # OTel: record token usage from response (post-call)
212
- use Middleware::OTel::TokenUsage
213
-
214
- # Check context size and compact if needed
215
- use Middleware::CompactionCheck,
216
- compactor: compactor,
217
- system_prompt: sys_prompt,
218
- tools: tools,
219
- stream: stream
220
-
221
- # Track per-tool errors
222
- use Middleware::ToolErrorTracking
223
-
224
- # Detect and break doom loops (pre-call)
225
- use Middleware::DoomLoopDetection
226
-
227
- # Handle reasoning params and model-switch normalization (pre-call)
228
- use Middleware::ReasoningNormalizer, **reasoning unless reasoning.empty?
229
-
230
- # Guard against tool-only responses dropping the assistant message
231
- use Middleware::ToolUseGuard
232
-
233
- # OTel: record tool calls the LLM requested (post-call, after ToolUseGuard)
234
- use Middleware::OTel::ToolCalls
235
-
236
- # Innermost: the actual LLM call
237
- run Middleware::LLMCall.new
238
- end
239
- end
240
-
241
- # ------------------------------------------------------------------
242
- # Pending tool collection
243
- # ------------------------------------------------------------------
244
-
245
- # Check whether there are pending tools without consuming them.
246
- def has_pending_tools?
247
- return true if @stream&.pending_tools&.any?
248
- return true if @context.functions.any?
249
- false
250
- end
251
-
252
- # Collect pending tools from the stream (streaming) or context (non-streaming).
253
- # Returns an array of [tool, error_or_nil] pairs.
254
- # Clears the stream's deferred state after consumption.
255
- def collect_pending_tools
256
- if @stream&.pending_tools&.any?
257
- tools = @stream.pending_tools.dup
258
- @stream.clear_pending_tools!
259
- tools
260
- elsif @context.functions.any?
261
- @context.functions.to_a.map { |fn| [fn, nil] }
262
- else
263
- []
264
- end
265
- end
266
-
267
- # ------------------------------------------------------------------
268
- # Tool execution
269
- # ------------------------------------------------------------------
270
-
271
- def execute_tool_calls(functions)
272
- return [] if functions.empty?
273
-
274
- # Questions block execution — they must complete before other tools
275
- # run, since the LLM may need the answer to inform subsequent work.
276
- # Execute any question tools first (sequentially), then dispatch
277
- # the remaining tools concurrently.
278
- questions, others = functions.partition { |fn| fn.name == "question" }
279
-
280
- results = []
281
- results.concat(execute_sequential(questions)) if questions.any?
282
- if others.size <= 1
283
- results.concat(execute_sequential(others))
284
- else
285
- results.concat(execute_parallel(others))
286
- end
287
- results
288
- end
289
-
290
- # Run a single tool call synchronously.
291
- def execute_sequential(functions)
292
- on_result = @env.dig(:callbacks, :on_tool_result)
293
- on_question = @env.dig(:callbacks, :on_question)
294
-
295
- functions.map do |fn|
296
- Thread.current[:on_question] = on_question
297
- result = fn.call
298
- on_result&.call(fn.name, result_value(result))
299
- result
300
- end
301
- end
302
-
303
- # Run all pending tool calls concurrently via Async::Barrier.
304
- #
305
- # Each tool runs in its own fiber. File-mutating tools are safe because
306
- # they go through FileMutationQueue, whose Mutex is fiber-scheduler-aware
307
- # in Ruby 3.4 — a fiber blocked on a per-file mutex yields to other
308
- # fibers instead of blocking the thread.
309
- #
310
- # The barrier is stored in @barrier so abort! can cancel in-flight tools.
311
- #
312
- def execute_parallel(functions)
313
- on_result = @env.dig(:callbacks, :on_tool_result)
314
- on_question = @env.dig(:callbacks, :on_question)
315
-
316
- results = Array.new(functions.size)
317
-
318
- Async do
319
- @barrier = Async::Barrier.new
320
-
321
- functions.each_with_index do |fn, i|
322
- @barrier.async do
323
- Thread.current[:on_question] = on_question
324
- results[i] = fn.call
325
- r = results[i]
326
- on_result&.call(r.name, result_value(r))
327
- end
328
- end
329
-
330
- @barrier.wait
331
- ensure
332
- @barrier&.stop
333
- @barrier = nil
334
- end
335
-
336
- results
337
- end
338
-
339
- public
340
-
341
- # Cancel any in-flight tool execution. Safe to call from a signal
342
- # handler, another thread, or an interface layer (TUI, web, RPC).
343
- #
344
- # When called, Async::Stop is raised in each running fiber, unwinding
345
- # through ensure blocks — so FileMutationQueue mutexes release cleanly
346
- # and SnapshotStore stays consistent.
347
- #
348
- def abort!
349
- @barrier&.stop
350
- end
351
-
352
- private
353
-
354
- # ------------------------------------------------------------------
355
- # Helpers
356
- # ------------------------------------------------------------------
357
-
358
- # After a pipeline call, the compaction middleware may have replaced
359
- # the context. Sync our local reference.
360
- def sync_context!
361
- @context = @env[:context]
362
- end
363
-
364
- def first_turn?
365
- @context.messages.to_a.empty?
366
- end
367
-
368
- def result_value(result)
369
- result.respond_to?(:value) ? result.value : result
370
- end
371
-
372
- # Build [name, value] pairs from tool results for ToolErrorTracking.
373
- def extract_tool_result_pairs(results)
374
- results.filter_map do |r|
375
- name = r.respond_to?(:name) ? r.name : "unknown"
376
- val = result_value(r)
377
- [name, val]
378
- end
379
- end
380
-
381
- # Load AGENTS.md or .brute/rules from the working directory.
382
- def load_custom_rules
383
- candidates = [
384
- File.join(@cwd, "AGENTS.md"),
385
- File.join(@cwd, ".brute", "rules.md"),
386
- ]
387
- found = candidates.find { |p| File.exist?(p) }
388
- found ? File.read(found) : nil
389
- end
390
- end
391
- end
392
-
393
- if __FILE__ == $0
394
- require_relative "../../spec/spec_helper"
395
-
396
- RSpec.describe Brute::Orchestrator, "system prompt" do
397
- let(:provider) { MockProvider.new }
398
-
399
- def build_orchestrator(agent_name: nil, cwd: Dir.pwd)
400
- described_class.new(
401
- provider: provider,
402
- model: "test-model",
403
- tools: [],
404
- cwd: cwd,
405
- agent_name: agent_name,
406
- logger: Logger.new(File::NULL),
407
- )
408
- end
409
-
410
- def system_prompt_for(orchestrator)
411
- orchestrator.instance_variable_get(:@system_prompt)
412
- end
413
-
414
- # ── Build mode (default) ──
415
-
416
- context "build mode (default, agent_name: nil)" do
417
- subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: nil)) }
418
-
419
- it "does NOT contain PlanReminder" do
420
- expect(prompt).not_to include("Plan Mode - System Reminder")
421
- expect(prompt).not_to include("READ-ONLY")
422
- end
423
-
424
- it "does NOT contain BuildSwitch" do
425
- expect(prompt).not_to include("operational mode has changed")
426
- end
427
-
428
- it "does NOT contain MaxSteps" do
429
- expect(prompt).not_to include("MAXIMUM STEPS REACHED")
430
- end
431
-
432
- it "contains identity section" do
433
- expect(prompt).to include("Brute")
434
- end
435
-
436
- it "contains environment section" do
437
- expect(prompt).to include("<env>")
438
- expect(prompt).to include("Working directory:")
439
- end
440
- end
441
-
442
- context "build mode (explicit agent_name: 'build')" do
443
- subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "build")) }
444
-
445
- it "does NOT contain PlanReminder" do
446
- expect(prompt).not_to include("Plan Mode - System Reminder")
447
- expect(prompt).not_to include("READ-ONLY")
448
- end
449
-
450
- it "contains identity section" do
451
- expect(prompt).to include("Brute")
452
- end
453
- end
454
-
455
- # ── Plan mode ──
456
-
457
- context "plan mode (agent_name: 'plan')" do
458
- subject(:prompt) { system_prompt_for(build_orchestrator(agent_name: "plan")) }
459
-
460
- it "includes PlanReminder" do
461
- expect(prompt).to include("Plan Mode - System Reminder")
462
- expect(prompt).to include("<system-reminder>")
463
- expect(prompt).to include("READ-ONLY")
464
- end
465
-
466
- it "includes the supersede warning" do
467
- expect(prompt).to include("supersedes any other instructions")
468
- end
469
-
470
- it "does NOT include BuildSwitch" do
471
- expect(prompt).not_to include("operational mode has changed")
472
- end
473
-
474
- it "still includes identity section" do
475
- expect(prompt).to include("Brute")
476
- end
477
-
478
- it "still includes environment section" do
479
- expect(prompt).to include("<env>")
480
- end
481
- end
482
-
483
- # ── Switching from plan to build (simulating mid-session agent recreation) ──
484
-
485
- context "switching from plan to build (new orchestrator, same session)" do
486
- let(:session) { Brute::Session.new }
487
-
488
- it "plan orchestrator has PlanReminder, build orchestrator does not" do
489
- plan_orch = described_class.new(
490
- provider: provider,
491
- model: "test-model",
492
- tools: [],
493
- session: session,
494
- agent_name: "plan",
495
- logger: Logger.new(File::NULL),
496
- )
497
- plan_prompt = system_prompt_for(plan_orch)
498
- expect(plan_prompt).to include("READ-ONLY")
499
-
500
- build_orch = described_class.new(
501
- provider: provider,
502
- model: "test-model",
503
- tools: [],
504
- session: session,
505
- agent_name: "build",
506
- logger: Logger.new(File::NULL),
507
- )
508
- build_prompt = system_prompt_for(build_orch)
509
- expect(build_prompt).not_to include("READ-ONLY")
510
- expect(build_prompt).not_to include("Plan Mode")
511
- end
512
-
513
- it "build orchestrator does NOT contain BuildSwitch (agent_switched never set)" do
514
- build_orch = described_class.new(
515
- provider: provider,
516
- model: "test-model",
517
- tools: [],
518
- session: session,
519
- agent_name: "build",
520
- logger: Logger.new(File::NULL),
521
- )
522
- build_prompt = system_prompt_for(build_orch)
523
- # This documents the current behavior: BuildSwitch is dead code
524
- expect(build_prompt).not_to include("operational mode has changed from plan to build")
525
- end
526
- end
527
-
528
- # ── Provider-specific stacks ──
529
-
530
- context "provider-specific identity text" do
531
- it "uses mock provider (falls back to default stack)" do
532
- prompt = system_prompt_for(build_orchestrator)
533
- # MockProvider.name returns :mock, which isn't a known stack, so falls back to "default"
534
- expect(prompt).to be_a(String)
535
- expect(prompt).not_to be_empty
536
- end
537
- end
538
-
539
- # ── Working directory is embedded ──
540
-
541
- context "cwd propagation" do
542
- it "embeds the given cwd in the system prompt" do
543
- Dir.mktmpdir do |dir|
544
- prompt = system_prompt_for(build_orchestrator(cwd: dir))
545
- expect(prompt).to include(dir)
546
- end
547
- end
548
- end
549
- end
550
- end
data/lib/brute/session.rb DELETED
@@ -1,161 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "fileutils"
5
- require "securerandom"
6
-
7
- module Brute
8
- # Manages session persistence. Each session is a conversation that can be
9
- # saved to disk and resumed later.
10
- #
11
- # New directory-based layout (per-session directory):
12
- #
13
- # ~/.brute/sessions/{session-id}/
14
- # session.meta.json # session metadata
15
- # context.json # llm.rb context blob (for resumption)
16
- # msg_0001.json # structured messages (OpenCode format)
17
- # msg_0002.json
18
- # ...
19
- #
20
- # Also supports the legacy flat layout for reading:
21
- #
22
- # ~/.brute/sessions/{session-id}.json
23
- # ~/.brute/sessions/{session-id}.meta.json
24
- #
25
- class Session
26
- attr_reader :id, :title, :path
27
-
28
- def initialize(id: nil, dir: nil)
29
- @id = id || SecureRandom.uuid
30
- @base_dir = dir || File.join(Dir.home, ".brute", "sessions")
31
- @session_dir = File.join(@base_dir, @id)
32
- @path = File.join(@session_dir, "context.json")
33
- @title = nil
34
- @metadata = {}
35
- FileUtils.mkdir_p(@session_dir)
36
-
37
- # Check for legacy flat-file layout and migrate path if present
38
- @legacy_path = File.join(@base_dir, "#{@id}.json")
39
- @legacy_meta = File.join(@base_dir, "#{@id}.meta.json")
40
- end
41
-
42
- # Returns a MessageStore for this session's structured messages.
43
- def message_store
44
- @message_store ||= MessageStore.new(session_id: @id, dir: @session_dir)
45
- end
46
-
47
- # Save a context to this session.
48
- def save(context, title: nil, metadata: {})
49
- @title = title if title
50
- @metadata.merge!(metadata)
51
-
52
- # Use llm.rb's built-in serialization for context (used for resumption)
53
- context.save(path: @path)
54
-
55
- # Write metadata sidecar
56
- save_meta
57
- end
58
-
59
- # Restore a context from this session.
60
- # Returns true if restored successfully, false if no session file found.
61
- def restore(context)
62
- # Try new layout first, then legacy
63
- ctx_path = if File.exist?(@path)
64
- @path
65
- elsif File.exist?(@legacy_path)
66
- @legacy_path
67
- end
68
-
69
- return false unless ctx_path
70
-
71
- context.restore(path: ctx_path)
72
-
73
- # Load metadata
74
- load_meta
75
-
76
- true
77
- end
78
-
79
- # List all saved sessions, newest first.
80
- # Scans both new directory-based layout and legacy flat files.
81
- def self.list(dir: nil)
82
- dir ||= File.join(Dir.home, ".brute", "sessions")
83
- return [] unless File.directory?(dir)
84
-
85
- sessions = {}
86
-
87
- # New layout: {id}/session.meta.json
88
- Dir.glob(File.join(dir, "*", "session.meta.json")).each do |meta_path|
89
- data = JSON.parse(File.read(meta_path), symbolize_names: true)
90
- id = data[:id]
91
- next unless id
92
- sessions[id] = {
93
- id: id,
94
- title: data[:title],
95
- saved_at: data[:saved_at],
96
- path: File.join(File.dirname(meta_path), "context.json"),
97
- }
98
- end
99
-
100
- # Legacy layout: {id}.meta.json (only if not already found)
101
- Dir.glob(File.join(dir, "*.meta.json")).each do |meta_path|
102
- # Skip files inside session subdirectories
103
- next if meta_path.include?("/session.meta.json")
104
- data = JSON.parse(File.read(meta_path), symbolize_names: true)
105
- id = data[:id]
106
- next unless id
107
- next if sessions.key?(id) # new layout takes precedence
108
- sessions[id] = {
109
- id: id,
110
- title: data[:title],
111
- saved_at: data[:saved_at],
112
- path: meta_path.sub(/\.meta\.json$/, ".json"),
113
- }
114
- end
115
-
116
- sessions.values.sort_by { |s| s[:saved_at] || "" }.reverse
117
- end
118
-
119
- # Delete a session from disk (both new and legacy layouts).
120
- def delete
121
- # New layout: remove the whole directory
122
- FileUtils.rm_rf(@session_dir) if File.directory?(@session_dir)
123
-
124
- # Legacy layout: remove flat files
125
- File.delete(@legacy_path) if File.exist?(@legacy_path)
126
- File.delete(@legacy_meta) if File.exist?(@legacy_meta)
127
- end
128
-
129
- private
130
-
131
- def meta_path
132
- File.join(@session_dir, "session.meta.json")
133
- end
134
-
135
- def save_meta
136
- data = {
137
- id: @id,
138
- title: @title,
139
- saved_at: Time.now.iso8601,
140
- metadata: @metadata,
141
- }
142
- FileUtils.mkdir_p(@session_dir)
143
- File.write(meta_path, JSON.pretty_generate(data))
144
- end
145
-
146
- def load_meta
147
- # Try new layout first
148
- path = if File.exist?(meta_path)
149
- meta_path
150
- elsif File.exist?(@legacy_meta)
151
- @legacy_meta
152
- end
153
-
154
- return unless path
155
-
156
- data = JSON.parse(File.read(path), symbolize_names: true)
157
- @title = data[:title]
158
- @metadata = data[:metadata] || {}
159
- end
160
- end
161
- end