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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -1
- data/docs/architecture.md +19 -0
- data/docs/cli.md +24 -7
- data/docs/configuration.md +44 -11
- data/docs/guardrails.md +3 -1
- data/docs/hooks.md +3 -2
- data/docs/internals/context-telemetry.md +4 -2
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +10 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
- data/lib/samagotchi/bundles/skills/plugin.rb +8 -17
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/client.rb +21 -1
- data/lib/samagotchi/config.rb +44 -40
- data/lib/samagotchi/context_status.rb +17 -16
- data/lib/samagotchi/empty_answer_retry.rb +67 -8
- data/lib/samagotchi/engine.rb +64 -20
- data/lib/samagotchi/host_registry.rb +50 -90
- data/lib/samagotchi/kernel_loop.rb +288 -305
- data/lib/samagotchi/llm/chat_loop.rb +78 -87
- data/lib/samagotchi/model_profile.rb +22 -3
- data/lib/samagotchi/model_ref.rb +60 -0
- data/lib/samagotchi/prompt_history.rb +91 -0
- data/lib/samagotchi/recap_setup.rb +15 -3
- data/lib/samagotchi/self_report.rb +11 -6
- data/lib/samagotchi/send_command.rb +8 -8
- data/lib/samagotchi/session.rb +9 -2
- data/lib/samagotchi/session_commands.rb +10 -12
- data/lib/samagotchi/session_manager.rb +20 -3
- data/lib/samagotchi/sessions_command.rb +57 -51
- data/lib/samagotchi/steer.rb +23 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
- data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
- data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
- data/lib/samagotchi/terminal_ui.rb +9 -8
- data/lib/samagotchi/tool_response.rb +67 -0
- data/lib/samagotchi/tool_runner.rb +6 -1
- data/lib/samagotchi/tools/delegate.rb +4 -3
- data/lib/samagotchi/tools/memory.rb +8 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -2
- data/lib/samagotchi/web/public/app.js +29 -2
- data/lib/samagotchi/web/public/data.js +9 -1
- data/lib/samagotchi/web/public/prompt_history.js +63 -0
- data/lib/samagotchi/worker.rb +2 -1
- metadata +6 -1
|
@@ -172,7 +172,8 @@ module Samagotchi
|
|
|
172
172
|
|
|
173
173
|
private
|
|
174
174
|
|
|
175
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
473
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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? ||
|
|
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
|
-
@
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
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" && !@
|
|
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.
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -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
|
|
95
|
+
# --model as typed: spawn_session stores the resolved ref and this name.
|
|
96
96
|
def model_ref(model)
|
|
97
|
-
ModelProfile.required_model_name(
|
|
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 "../
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|