samagotchi 0.10.0 → 0.11.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/docs/architecture.md +19 -0
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +44 -11
  6. data/docs/guardrails.md +3 -1
  7. data/docs/hooks.md +3 -2
  8. data/docs/internals/context-telemetry.md +4 -2
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +10 -3
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/skills/plugin.rb +8 -17
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/client.rb +21 -1
  19. data/lib/samagotchi/config.rb +44 -40
  20. data/lib/samagotchi/context_status.rb +17 -16
  21. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  22. data/lib/samagotchi/engine.rb +64 -20
  23. data/lib/samagotchi/host_registry.rb +50 -90
  24. data/lib/samagotchi/kernel_loop.rb +288 -305
  25. data/lib/samagotchi/llm/chat_loop.rb +78 -87
  26. data/lib/samagotchi/model_profile.rb +22 -3
  27. data/lib/samagotchi/model_ref.rb +60 -0
  28. data/lib/samagotchi/prompt_history.rb +91 -0
  29. data/lib/samagotchi/recap_setup.rb +15 -3
  30. data/lib/samagotchi/self_report.rb +11 -6
  31. data/lib/samagotchi/send_command.rb +8 -8
  32. data/lib/samagotchi/session.rb +9 -2
  33. data/lib/samagotchi/session_commands.rb +10 -12
  34. data/lib/samagotchi/session_manager.rb +20 -3
  35. data/lib/samagotchi/sessions_command.rb +57 -51
  36. data/lib/samagotchi/steer.rb +23 -0
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  39. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  40. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  41. data/lib/samagotchi/terminal_ui.rb +9 -8
  42. data/lib/samagotchi/tool_response.rb +67 -0
  43. data/lib/samagotchi/tool_runner.rb +6 -1
  44. data/lib/samagotchi/tools/delegate.rb +4 -3
  45. data/lib/samagotchi/tools/memory.rb +8 -6
  46. data/lib/samagotchi/version.rb +1 -1
  47. data/lib/samagotchi/web/app.rb +28 -2
  48. data/lib/samagotchi/web/public/app.js +29 -2
  49. data/lib/samagotchi/web/public/data.js +9 -1
  50. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  51. data/lib/samagotchi/worker.rb +2 -1
  52. metadata +6 -1
@@ -172,7 +172,8 @@ module Samagotchi
172
172
 
173
173
  private
174
174
 
175
- def model_name = @engine.effective_model_name
175
+ # The resolved ref (what the session stores and --alias/--default write).
176
+ def model_name = @engine.effective_model_ref
176
177
 
177
178
  # A bundle plugin's command: its handler gets the text after the name;
178
179
  # what it returns is the output (nil: nothing to show), and a raise is
@@ -293,7 +294,7 @@ module Samagotchi
293
294
  session = @engine.session
294
295
  return unless session
295
296
 
296
- session.model_name = model_name
297
+ @engine.store_model!(session)
297
298
  @save.call(session)
298
299
  end
299
300
 
@@ -309,7 +310,7 @@ module Samagotchi
309
310
  def model_command(input)
310
311
  suffix = input.delete_prefix(MODEL_COMMAND).strip
311
312
  if suffix.empty?
312
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @default_model
313
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @engine.model_ref_for(@default_model)
313
314
 
314
315
  return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
315
316
  end
@@ -405,7 +406,7 @@ module Samagotchi
405
406
  session = @engine.session
406
407
  return unless session.respond_to?(:model_name=)
407
408
 
408
- session.model_name = model_name
409
+ @engine.store_model!(session)
409
410
  begin
410
411
  @save.call(session)
411
412
  rescue StandardError
@@ -468,13 +469,11 @@ module Samagotchi
468
469
 
469
470
  aliases = ConfigFile.model_aliases
470
471
  by_model = Hash.new { |h, k| h[k] = [] }
472
+ # A bare alias shows next to its id on every host, a host:model one
473
+ # only under its host.
471
474
  aliases.each do |alias_name, model_id|
472
- # normalize bare comparison for orphan detection (strip host prefix if present)
473
- _, bare = registry.parse_qualified_model(model_id)
474
- key = (bare.empty? ? model_id : bare).to_s.downcase
475
- by_model[key] << alias_name
476
- # also index full ref for exact alias display
477
- by_model[model_id.downcase] << alias_name unless key == model_id.downcase
475
+ host, bare = registry.parse_qualified_model(model_id)
476
+ by_model[host ? "#{host}:#{bare}".downcase : model_id.downcase] << alias_name
478
477
  end
479
478
  by_model.each_value { |v| v.uniq!; v.sort! }
480
479
 
@@ -500,7 +499,6 @@ module Samagotchi
500
499
  seen << identifier.to_s.downcase
501
500
  # also track host-qualified seen for orphan logic
502
501
  seen << "#{hname}:#{identifier}".downcase
503
- seen << "#{hname}/#{identifier}".downcase
504
502
  next if batch_variants.include?(identifier)
505
503
 
506
504
  shown << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
@@ -527,7 +525,7 @@ module Samagotchi
527
525
  end
528
526
  end
529
527
  # Warnings for unreachable hosts are already in lines; no failover
530
- orphans = aliases.reject { |_, model_id| seen.include?(model_id.downcase) || seen.include?(registry.bare_name(model_id).downcase) }
528
+ orphans = aliases.reject { |_, model_id| seen.include?(model_id.downcase) || seen.include?(registry.parse_qualified_model(model_id).last.downcase) }
531
529
  unless orphans.empty? || !needle.empty?
532
530
  lines << ""
533
531
  lines << "orphan aliases (target not discovered):"
@@ -7,6 +7,7 @@ require "time"
7
7
  require "securerandom"
8
8
  require "rbconfig"
9
9
  require_relative "atomic_file"
10
+ require_relative "config"
10
11
 
11
12
  require_relative "session"
12
13
  require_relative "session_inbox"
@@ -126,9 +127,14 @@ module Samagotchi
126
127
  memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
127
128
  title: nil)
128
129
  sd = state_dir || Session.default_state_dir
130
+ # The resolved ref is stored (a resumed session keeps its model when an
131
+ # alias is retargeted), with the name as typed beside it.
132
+ typed = Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name))
133
+ ref = ConfigFile.model_ref(typed).ref
129
134
  session = Session.new_session(
130
135
  mode: mode,
131
- model_name: Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name)),
136
+ model_name: ref,
137
+ model_typed: typed == ref ? nil : typed,
132
138
  working_directory: working_directory || Dir.pwd,
133
139
  preloaded_memory_names: memories,
134
140
  muted_memory_names: muted_memories,
@@ -466,7 +472,7 @@ module Samagotchi
466
472
  # when the session was never saved
467
473
  def self.discardable?(session_id, default_model:, state_dir: nil, model_name: default_model, used_memory_names: [],
468
474
  unsaved: nil)
469
- return false unless discard_empty? && model_name == default_model && Array(used_memory_names).empty?
475
+ return false unless discard_empty? && same_model?(model_name, default_model) && Array(used_memory_names).empty?
470
476
 
471
477
  sd = state_dir || Session.default_state_dir
472
478
  if unsaved && !File.exist?(Session.session_file(session_id, state_dir: sd))
@@ -488,13 +494,24 @@ module Samagotchi
488
494
  session = Session.load(session_id, state_dir: sd)
489
495
  return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
490
496
  return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
491
- return false unless session.mode.to_s == "assist" && !default_model.nil? && session.model_name.to_s == default_model.to_s
497
+ return false unless session.mode.to_s == "assist" && !default_model.nil? && same_model?(session.model_name, default_model)
492
498
 
493
499
  empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
494
500
  rescue ArgumentError, SystemCallError, JSON::ParserError
495
501
  false
496
502
  end
497
503
 
504
+ # Whether two model names resolve to the same ref (ModelRef#ref): a
505
+ # session stores the resolved ref, the default may be an alias.
506
+ def self.same_model?(one, other)
507
+ return true if one.to_s == other.to_s
508
+ return false if one.nil? || other.nil?
509
+
510
+ ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
511
+ rescue StandardError
512
+ false
513
+ end
514
+
498
515
  # Only the system prompt (the REPL seeds one, a saved session keeps
499
516
  # it); a context note is a system message too, but with its kind.
500
517
  # A turn note (the tail system message after a failed, cancelled or empty
@@ -9,6 +9,8 @@ require_relative "session_metrics"
9
9
  require_relative "recap_store"
10
10
  require_relative "session_delete_command"
11
11
  require_relative "session_archive_command"
12
+ require_relative "cli/command"
13
+ require_relative "cli/flags"
12
14
 
13
15
  module Samagotchi
14
16
  # `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
@@ -16,6 +18,8 @@ module Samagotchi
16
18
  # the subcommands have their own flags). Not SessionCommands, the REPL's
17
19
  # slash commands for sessions (session_commands.rb).
18
20
  class SessionsCommand
21
+ include CLI::Command
22
+
19
23
  USAGE = <<~TEXT
20
24
  Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
21
25
  list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
@@ -32,15 +36,32 @@ module Samagotchi
32
36
  Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
33
37
  TEXT
34
38
 
35
- # The value flags: "--flag V" or "--flag=V", converted with the method
36
- # given (nil: the string as it is). Every argument is scanned, the values
37
- # too (--cwd --live takes "--live" as the folder and turns --live on),
38
- # a flag with nothing after it is ignored, and so is an unknown one.
39
- VALUE_FLAGS = {
40
- "--days" => [:days, :to_i], "--keep" => [:keep, :to_i], "--keep-status" => [:keep_status, nil],
41
- "--sort" => [:sort, nil], "--order" => [:order, nil], "--limit" => [:limit, :to_i],
42
- "--cwd" => [:cwd, nil], "--format" => [:format, nil], "--scope" => [:scope, nil]
39
+ STOP_USAGE = "Usage: chi sessions stop ID...\n"
40
+ SUBCOMMANDS = %w[list stop archive unarchive delete prune clean].freeze
41
+
42
+ # Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
43
+ # unknown flag, a value flag with nothing after it or an argument where
44
+ # none is taken is a usage error (exit 2). The numbers become integers
45
+ # (#integers).
46
+ FLAGS = {
47
+ "list" => CLI::Flags.new(args: false) do |f|
48
+ %w[--sort --order --limit --cwd --format --scope].each { |name| f.value name }
49
+ f.switch "--live"
50
+ f.switch "--archived", key: :include_archived
51
+ end,
52
+ "stop" => CLI::Flags.new,
53
+ "prune" => CLI::Flags.new(args: false) do |f|
54
+ %w[--days --keep --keep-status].each { |name| f.value name }
55
+ f.switch "--dry-run"
56
+ f.switch "--test-only", "--test", key: :test_only
57
+ end,
58
+ "clean" => CLI::Flags.new(args: false) do |f|
59
+ f.value "--days"
60
+ f.switch "--dry-run"
61
+ f.switch "--all"
62
+ end
43
63
  }.freeze
64
+ INTEGER_OPTIONS = %i[days keep limit].freeze
44
65
 
45
66
  # @param argv [Array<String>] the arguments after "sessions"
46
67
  def initialize(argv, stdout: $stdout, stderr: $stderr)
@@ -50,61 +71,49 @@ module Samagotchi
50
71
  end
51
72
 
52
73
  # @return [Integer] exit status
74
+ # @return [Integer] exit status: 2 on a usage error
53
75
  def run
54
- sub = @argv[0]
55
- if sub.nil? || %w[-h --help help].include?(sub)
76
+ @sub = @argv[0]
77
+ if @sub.nil? || HELP_WORDS.include?(@sub)
56
78
  @stdout.puts USAGE
57
79
  return 0
58
80
  end
59
- @opts = parse_flags
60
- case sub
81
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
82
+
83
+ case @sub
61
84
  when "delete"
62
- Samagotchi::SessionDeleteCommand.new(@argv[1..], stdout: @stdout, stderr: @stderr).run
85
+ return Samagotchi::SessionDeleteCommand.new(@argv[1..], stdout: @stdout, stderr: @stderr).run
63
86
  when "archive", "unarchive"
64
- Samagotchi::SessionArchiveCommand.new(sub, @argv[1..], stdout: @stdout, stderr: @stderr).run
87
+ return Samagotchi::SessionArchiveCommand.new(@sub, @argv[1..], stdout: @stdout, stderr: @stderr).run
88
+ end
89
+
90
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
91
+ return parsed if parsed.is_a?(Integer)
92
+
93
+ @opts = integers(parsed.options)
94
+ case @sub
65
95
  when "list" then list
66
- when "stop" then stop
67
- when "prune", "clean" then prune(sub)
68
- else
69
- @stderr.puts "Unknown sessions subcommand: #{sub}. Use: list, stop, archive, unarchive, delete, prune, clean"
70
- 1
96
+ when "stop" then stop(parsed.args)
97
+ else prune(@sub)
71
98
  end
72
99
  end
73
100
 
74
101
  private
75
102
 
76
- def parse_flags
77
- opts = {
78
- dry_run: @argv.include?("--dry-run"),
79
- test_only: @argv.include?("--test-only") || @argv.include?("--test"),
80
- live: @argv.include?("--live"),
81
- include_archived: @argv.include?("--archived")
82
- }
83
- @argv.each_with_index do |arg, i|
84
- nxt = @argv[i + 1]
85
- VALUE_FLAGS.each do |flag, (key, convert)|
86
- value = if arg == flag && nxt then nxt
87
- elsif arg.start_with?("#{flag}=") then arg.split("=", 2).last
88
- end
89
- next unless value
103
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
104
+ def usage_text = @sub == "stop" ? STOP_USAGE : USAGE
90
105
 
91
- opts[key] = convert ? value.public_send(convert) : value
92
- break
93
- end
94
- end
95
- opts
106
+ # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
107
+ def integers(options)
108
+ options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? value.to_i : value] }
96
109
  end
97
110
 
98
111
  def list
99
112
  format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
100
113
  unless format.nil? || %w[text json tsv].include?(format)
101
- @stderr.puts "Unknown format #{format.inspect}: use --format text|json|tsv"
102
- return 1
103
- end
104
- unless scope.nil? || %w[project all].include?(scope)
105
- @stderr.puts "Unknown scope #{scope.inspect}: use --scope=project|all"
106
- return 1
114
+ return usage_error("unknown format #{format.inspect}: use --format text|json|tsv")
107
115
  end
116
+ return usage_error("unknown scope #{scope.inspect}: use --scope=project|all") unless scope.nil? || %w[project all].include?(scope)
108
117
  # The current git project's sessions (Samagotchi::ProjectScope), like
109
118
  # chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
110
119
  # project's.
@@ -202,12 +211,9 @@ module Samagotchi
202
211
  0
203
212
  end
204
213
 
205
- def stop
206
- ids = @argv[1..]
207
- if ids.empty? || ids.any? { |arg| arg.start_with?("-") }
208
- @stderr.puts "Usage: chi sessions stop ID..."
209
- return 1
210
- end
214
+ def stop(ids)
215
+ return usage_error("give session ids") if ids.empty?
216
+
211
217
  # Each id in turn, like chi sessions delete: stdout is flushed before an
212
218
  # error line, so the output keeps the order of the ids given.
213
219
  ok = ids.uniq.map do |given|
@@ -236,7 +242,7 @@ module Samagotchi
236
242
 
237
243
  def prune(sub)
238
244
  days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
239
- test_only = true if sub == "clean" && !@argv.include?("--all")
245
+ test_only = true if sub == "clean" && !@opts[:all]
240
246
  # Test sessions are throwaway: clean takes them whatever their age,
241
247
  # unless --days asks for the older ones only. Live workers and
242
248
  # keep_status still protect a session.
@@ -74,6 +74,29 @@ module Samagotchi
74
74
  nil
75
75
  end
76
76
 
77
+ # One injection at an iteration boundary: drain +pending_input+ and, when
78
+ # anything was waiting, append the merge on the conversation tail (head
79
+ # mutation would invalidate the server's prefix KV cache) and emit
80
+ # :pending_input_merged. After a cancel the input stays queued: it runs
81
+ # as the next turn instead of dying with this one. +answer+ (a String,
82
+ # or a Proc called only on a merge) is the answer the merge follows, for
83
+ # the UIs; given, the drain is told it is the after-answer site (plugin
84
+ # steers are dropped there). A blank answer is none.
85
+ # @return [Boolean] whether anything was injected
86
+ def inject!(conversation, pending_input, iteration:, emit:, cancel_controller:, answer: nil)
87
+ return false unless pending_input
88
+ return false if cancel_controller&.cancelled?
89
+
90
+ merge = merge(drain(pending_input, at_answer: !answer.nil?))
91
+ return false if merge.empty?
92
+
93
+ answer = (answer.respond_to?(:call) ? answer.call : answer).to_s
94
+ conversation.concat(merge.messages)
95
+ emit.call({ type: :pending_input_merged, iteration: iteration, **merge.event_fields,
96
+ answer: answer.strip.empty? ? nil : answer })
97
+ true
98
+ end
99
+
77
100
  # @return [Merge]
78
101
  def merge(items)
79
102
  items = Array(items)
@@ -92,9 +92,9 @@ module Samagotchi
92
92
  raise Error, e.message
93
93
  end
94
94
 
95
- # --model as the REPL reads it: an alias resolved, a host prefix kept.
95
+ # --model as typed: spawn_session stores the resolved ref and this name.
96
96
  def model_ref(model)
97
- ModelProfile.required_model_name(ConfigFile.resolve_model_alias(model))
97
+ ModelProfile.required_model_name(model)
98
98
  end
99
99
 
100
100
  # An existing session's prompt is built from its own session fields:
@@ -3,6 +3,7 @@
3
3
  require "set"
4
4
  require_relative "event_renderer"
5
5
  require_relative "formatting"
6
+ require_relative "../prompt_history"
6
7
  require_relative "attached_view"
7
8
  require_relative "status_row"
8
9
  require_relative "input_support"
@@ -292,7 +293,7 @@ module Samagotchi
292
293
  show_recap
293
294
  elsif command_registry.command?(text)
294
295
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
295
- persist_recent_history(text) if shell_line?(text)
296
+ persist_recent_history(text) if PromptHistory.shell_line?(text)
296
297
  send_command(text)
297
298
  else
298
299
  send_prompt(text)
@@ -435,7 +436,7 @@ module Samagotchi
435
436
  output = event[:output].to_s
436
437
  if output.empty?
437
438
  nil
438
- elsif event[:status] == "busy" || shell_line?(event[:line])
439
+ elsif event[:status] == "busy" || PromptHistory.shell_line?(event[:line])
439
440
  @screen.commit(output)
440
441
  put_back_busy_command(event) if event[:status] == "busy"
441
442
  else
@@ -456,10 +457,6 @@ module Samagotchi
456
457
  @screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
457
458
  end
458
459
 
459
- def shell_line?(line)
460
- line.to_s.start_with?(SessionCommands::SHELL_BANG_PREFIX) && line.to_s.strip != SessionCommands::ROLLBACK_COMMAND
461
- end
462
-
463
460
  def offer_continue(event)
464
461
  @continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
465
462
  sync_prompt
@@ -6,9 +6,11 @@ require "reline"
6
6
 
7
7
  require_relative "../config"
8
8
  require_relative "../memory_paths"
9
- require_relative "../paths"
9
+ require_relative "../prompt_history"
10
10
  require_relative "../tools/memory"
11
11
  require_relative "../session_commands"
12
+ require_relative "line_reader"
13
+ require_relative "reline_history_hook"
12
14
 
13
15
  module Samagotchi
14
16
  class TerminalUI
@@ -21,9 +23,6 @@ module Samagotchi
21
23
  # Included for private use; it keeps state in @next_input_prefill and
22
24
  # reads @no_default_input.
23
25
  module InputSupport
24
- PROMPT_HISTORY_FILE = "history.json"
25
- PROMPT_HISTORY_STATE_DIR = "samagotchi"
26
- PROMPT_HISTORY_LIMIT = 20
27
26
  AT_PATH_COMPLETION_PREFIX = "@"
28
27
  MEMORY_COMPLETION_PREFIX = "#"
29
28
  AT_PATH_COMPLETION_MAX_CANDIDATES = 200
@@ -43,12 +42,17 @@ module Samagotchi
43
42
  # One read at the main prompt. In multiline mode Enter submits, while
44
43
  # Meta+Enter/Alt+Enter inserts a newline on terminals that emit that
45
44
  # distinct sequence (for example kitty); Tab completes; a queued prefill
46
- # is typed in first.
45
+ # is typed in first. Other processes' history lines are picked up
46
+ # before the read and again on the first ↑ of a walk.
47
47
  # @return [String, nil] the line, nil on Ctrl-D
48
48
  def read_prompt_line(prompt)
49
+ pick_up_history_lines
50
+ RelineHistoryHook.install if RelineHistoryHook.supported?
49
51
  input = with_scoped_at_path_completion do
50
52
  with_next_input_prefill do
51
- Reline.readmultiline(prompt, true) { true }
53
+ RelineHistoryHook.with_refresh(method(:pick_up_history_lines)) do
54
+ Reline.readmultiline(prompt, true) { true }
55
+ end
52
56
  end
53
57
  end
54
58
  return nil if input.nil?
@@ -203,16 +207,12 @@ module Samagotchi
203
207
  }
204
208
  end
205
209
 
206
- def history_file_path
207
- explicit = Samagotchi::Config.get("history.file").to_s.strip
208
- return explicit unless explicit.empty?
209
-
210
- File.join(Paths.state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
211
- end
212
-
213
210
  def load_persistent_history
214
- entries = load_history_entries_from_disk
215
- entries.last(PROMPT_HISTORY_LIMIT).each { |entry| Reline::HISTORY << entry }
211
+ @history_lock ||= Mutex.new
212
+ @history_own_lines ||= []
213
+ @history_signature = PromptHistory.signature
214
+ @history_seen = PromptHistory.entries
215
+ @history_seen.last(PromptHistory::LIMIT).each { |entry| Reline::HISTORY << entry }
216
216
  rescue StandardError
217
217
  nil
218
218
  end
@@ -221,31 +221,76 @@ module Samagotchi
221
221
  def persist_recent_history(input)
222
222
  return if @scratch
223
223
 
224
- entries = normalize_history_entries(load_history_entries_from_disk)
225
- entries << input
226
- trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
227
- path = history_file_path
228
- FileUtils.mkdir_p(File.dirname(path))
229
- File.write(path, JSON.pretty_generate(trimmed_entries) + "\n")
224
+ own = PromptHistory.normalize([input]).first
225
+ history_lock.synchronize { history_own_lines << own } if own
226
+ begin
227
+ PromptHistory.append(input)
228
+ rescue StandardError
229
+ history_lock.synchronize { forget_own_line(own) } if own
230
+ raise
231
+ end
230
232
  rescue StandardError
231
233
  nil
232
234
  end
233
235
 
234
- def load_history_entries_from_disk
235
- path = history_file_path
236
- return [] unless File.file?(path)
236
+ # Before a main-prompt read, and on the first ↑ of a walk at an open
237
+ # one (RelineHistoryHook): the lines other processes (the web,
238
+ # another TUI) added to the history file since we last looked join the
239
+ # ring. Only the file's new tail is appended, so a line this TUI read
240
+ # and didn't persist (/model, !rollback: Reline rings every read) stays
241
+ # put; other processes' lines land after it, the order between the two
242
+ # approximate. Our own appends are skipped (they're in the ring as
243
+ # read). Runs on the reader thread, while the main thread may persist
244
+ # the line just read; @history_own_lines is filled before each append
245
+ # lands, so either way a line is in the ring once. A scratch session
246
+ # keeps Reline's ring as it is.
247
+ #
248
+ # It runs inside LineReader's read, where a Reprompt or Stop lands at
249
+ # once. Both wait until the ring and what we last saw of the file agree
250
+ # (a raise in between would lose the new lines for good), then go on
251
+ # to LineReader; an ordinary error is swallowed.
252
+ def pick_up_history_lines
253
+ return if @scratch
237
254
 
238
- raw = File.read(path)
239
- parsed = JSON.parse(raw)
240
- normalize_history_entries(parsed)
241
- rescue JSON::ParserError
242
- normalize_history_entries(raw.to_s.lines.map(&:chomp))
243
- rescue StandardError
244
- []
255
+ Thread.handle_interrupt(LineReader::Reprompt => :never, LineReader::Stop => :never) do
256
+ signature = PromptHistory.signature
257
+ next if signature == @history_signature
258
+
259
+ entries = PromptHistory.entries
260
+ fresh = PromptHistory.new_tail(@history_seen || [], entries)
261
+ @history_signature = signature
262
+ @history_seen = entries
263
+ history_lock.synchronize { add_others_lines(fresh) }
264
+ rescue StandardError
265
+ nil
266
+ end
267
+ end
268
+
269
+ # The lines in +fresh+ go into the ring, except our own (each counted
270
+ # once). Under the history lock.
271
+ def add_others_lines(fresh)
272
+ own = history_own_lines
273
+ fresh.each do |line|
274
+ index = own.index(line)
275
+ next own.delete_at(index) if index
276
+
277
+ Reline::HISTORY << line
278
+ end
279
+ end
280
+
281
+ def forget_own_line(own)
282
+ index = history_own_lines.rindex(own)
283
+ history_own_lines.delete_at(index) if index
284
+ end
285
+
286
+ def history_own_lines
287
+ @history_own_lines ||= []
245
288
  end
246
289
 
247
- def normalize_history_entries(entries)
248
- Array(entries).map { |entry| entry.to_s.gsub(/\r\n?/, "\n").strip }.reject(&:empty?)
290
+ # Guards @history_own_lines: the main thread persists lines, the
291
+ # reader thread picks up others'. Never held across file IO.
292
+ def history_lock
293
+ @history_lock ||= Mutex.new
249
294
  end
250
295
 
251
296
  # The text as given ("Please " keeps its space); a blank one is none.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ require_relative "line_reader"
6
+
7
+ module Samagotchi
8
+ class TerminalUI
9
+ # Prepended to Reline::LineEditor: the first ↑ of a history walk at the
10
+ # main prompt runs the refresh first, so lines other processes (the web,
11
+ # another TUI) added to the shared history since the prompt opened are
12
+ # in the ring it walks. Set per read with .with_refresh; any other read
13
+ # (a question, a continue prompt) gets Reline's own ↑.
14
+ #
15
+ # Kept apart from RelineSeam, whose supported? is all-or-nothing: drift
16
+ # here only turns this hook off. It overrides two private methods of
17
+ # reline 0.6.x (the Gemfile pins it), both with Reline's signature
18
+ # (key, arg: 1), which Reline's dispatch looks at for an arg: keyword:
19
+ # - ed_prev_history: ↑, Ctrl-P, vi command k and -.
20
+ # - previous_history: its alias, which inputrc's previous-history binds.
21
+ # It reads two ivars: @history_pointer (nil while not walking the
22
+ # history) and @line_index (the cursor's line in a multi-line buffer;
23
+ # ↑ off line 0 only moves up a line). Without them it is plain Reline.
24
+ # A failing refresh leaves plain ↑; LineReader's Reprompt and Stop go on.
25
+ module RelineHistoryHook
26
+ # Reline's methods the hook overrides, with their arities.
27
+ METHODS = { ed_prev_history: -2, previous_history: -2 }.freeze
28
+
29
+ class << self
30
+ # @return [Boolean] this Reline has every method the hook overrides
31
+ def supported?
32
+ METHODS.all? { |name, arity| reline_method(name)&.arity == arity }
33
+ end
34
+
35
+ def install
36
+ Reline::LineEditor.prepend(self) unless Reline::LineEditor.include?(self)
37
+ end
38
+
39
+ # Run a read (on this thread) whose first ↑ of a walk calls
40
+ # +refresh+ first.
41
+ # @param refresh [#call, nil]
42
+ def with_refresh(refresh)
43
+ was = Thread.current[:samagotchi_reline_history_refresh]
44
+ Thread.current[:samagotchi_reline_history_refresh] = refresh
45
+ yield
46
+ ensure
47
+ Thread.current[:samagotchi_reline_history_refresh] = was
48
+ end
49
+
50
+ # @return [#call, nil] the refresh of the read on this thread
51
+ def refresh = Thread.current[:samagotchi_reline_history_refresh]
52
+
53
+ private
54
+
55
+ # Reline's own implementation, past this module once it is prepended.
56
+ def reline_method(name)
57
+ method = Reline::LineEditor.instance_method(name)
58
+ method = method.super_method while method&.owner == self
59
+ method
60
+ rescue NameError
61
+ nil
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def ed_prev_history(key, arg: 1)
68
+ refresh_history_before_walk
69
+ super
70
+ end
71
+
72
+ def previous_history(key, arg: 1)
73
+ refresh_history_before_walk
74
+ super
75
+ end
76
+
77
+ # Only at the start of a walk: appending to Reline::HISTORY mid-walk
78
+ # would shift what the pointer means.
79
+ def refresh_history_before_walk
80
+ refresh = RelineHistoryHook.refresh
81
+ return unless refresh
82
+ return unless instance_variable_defined?(:@history_pointer) && instance_variable_defined?(:@line_index)
83
+ return unless @history_pointer.nil? && @line_index.zero?
84
+
85
+ refresh.call
86
+ rescue LineReader::Reprompt, LineReader::Stop
87
+ raise
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end