samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -40,6 +40,10 @@ module Samagotchi
40
40
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
41
41
  # Names as given; the engine normalizes them.
42
42
  attr_accessor :preloaded_memory_names, :muted_memory_names
43
+ # The name model_name was typed as when that differs from the resolved
44
+ # ref stored in model_name (an alias: `small` for `gemma-small`), else
45
+ # nil. The models: lookup takes it (HostRegistry#lookup_names).
46
+ attr_accessor :model_typed
43
47
  # The project the session was started in (ProjectScope.root_for its
44
48
  # folder), stored because worktrees are deleted after a merge and a
45
49
  # deleted folder no longer leads to its repository. nil outside a repo,
@@ -70,11 +74,12 @@ module Samagotchi
70
74
  first_preview: "", test_run: false, pending_question: nil,
71
75
  used_memory_names: [], project_root: nil,
72
76
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
73
- last_turn: nil)
77
+ last_turn: nil, model_typed: nil)
74
78
  @id = id
75
79
  @metadata_version = metadata_version
76
80
  @mode = mode
77
81
  @model_name = model_name
82
+ @model_typed = model_typed
78
83
  @working_directory = working_directory
79
84
  @messages = messages
80
85
  @created_at = created_at
@@ -113,7 +118,7 @@ module Samagotchi
113
118
  # seed); [] by default
114
119
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
115
120
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
116
- scratch: false)
121
+ scratch: false, model_typed: nil)
117
122
  now = Time.now.iso8601(3)
118
123
  resolved_test = if test_run.nil?
119
124
  test_session_env?
@@ -124,6 +129,7 @@ module Samagotchi
124
129
  id: SecureRandom.uuid,
125
130
  mode: mode.to_s,
126
131
  model_name: model_name.to_s,
132
+ model_typed: model_typed,
127
133
  working_directory: working_directory.to_s,
128
134
  messages: Array(messages).map(&:dup),
129
135
  created_at: now,
@@ -173,6 +179,7 @@ module Samagotchi
173
179
  "id" => REQUIRED,
174
180
  "mode" => REQUIRED,
175
181
  "model_name" => REQUIRED,
182
+ "model_typed" => nil,
176
183
  "working_directory" => REQUIRED,
177
184
  "messages" => [],
178
185
  "created_at" => REQUIRED,
@@ -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
@@ -403,9 +404,9 @@ module Samagotchi
403
404
 
404
405
  def persist_session_model
405
406
  session = @engine.session
406
- return unless session.respond_to?(:model_name=)
407
+ return unless session
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
@@ -415,7 +416,7 @@ module Samagotchi
415
416
 
416
417
  # "; served: <name>" when the server serves another model than asked.
417
418
  def served_note
418
- served, asked = @engine.respond_to?(:served_model) ? @engine.served_model : nil
419
+ served, asked = @engine.served_model
419
420
  ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
420
421
  rescue StandardError
421
422
  ""
@@ -423,7 +424,7 @@ module Samagotchi
423
424
 
424
425
  # "; sampling: temperature=0.6 (hosts.work)" when the model has any.
425
426
  def sampling_note
426
- summary = @engine.respond_to?(:sampling_summary) ? @engine.sampling_summary : nil
427
+ summary = @engine.sampling_summary
427
428
  summary ? "; sampling: #{summary}" : ""
428
429
  rescue StandardError
429
430
  ""
@@ -431,7 +432,7 @@ module Samagotchi
431
432
 
432
433
  # "; thinking: off (models: qwen)" when the model has a level set.
433
434
  def thinking_note
434
- summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
435
+ summary = @engine.thinking_summary
435
436
  summary ? "; thinking: #{summary}" : ""
436
437
  rescue StandardError
437
438
  ""
@@ -446,7 +447,7 @@ module Samagotchi
446
447
  end
447
448
 
448
449
  def chat_model?
449
- @engine.respond_to?(:chat_model?) && @engine.chat_model?
450
+ @engine.chat_model?
450
451
  rescue StandardError
451
452
  false
452
453
  end
@@ -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
@@ -748,7 +765,7 @@ module Samagotchi
748
765
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
749
766
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
750
767
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
751
- manager.resume_session(session_id, state_dir: state_dir) if manager.respond_to?(:resume_session)
768
+ manager.resume_session(session_id, state_dir: state_dir)
752
769
  if (client = bridge.call)
753
770
  begin
754
771
  options = { prompt: prompt, client_id: client_id }
@@ -785,9 +802,7 @@ module Samagotchi
785
802
  end
786
803
  # A worker that idle-exited since the resume never reads it either:
787
804
  # wake a new one. (The exiting worker also looks for input it left.)
788
- if owner.nil? && manager.respond_to?(:session_owner) && manager.respond_to?(:resume_session)
789
- manager.resume_session(session_id, state_dir: state_dir)
790
- end
805
+ manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
791
806
  { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
792
807
  end
793
808
 
@@ -797,8 +812,6 @@ module Samagotchi
797
812
  end
798
813
 
799
814
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
800
- return nil unless manager.respond_to?(:session_owner)
801
-
802
815
  manager.session_owner(session_id, state_dir: state_dir)
803
816
  rescue StandardError
804
817
  nil
@@ -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.