samagotchi 0.96.0 → 0.97.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.
@@ -80,7 +80,7 @@ module Samagotchi
80
80
  # "Shipped notes").
81
81
  def kept_own_line(member, file)
82
82
  "#{member}: kept your own #{file} (not the bundle's; to take the bundle's, remove yours with " \
83
- "memory_write remove: true, then chi bundle install #{member})"
83
+ "chi memory rm #{file.delete_suffix(".md")} --scope system, then chi bundle install #{member})"
84
84
  end
85
85
 
86
86
  # The one rule.
@@ -121,19 +121,29 @@ module Samagotchi
121
121
  member_dir = File.join(shipped_dir, member)
122
122
  failure = Manifest.requires_chi_failure(Manifest.read(dir: member_dir).requires_chi, chi_version)
123
123
  return result.skipped[member] = failure if failure
124
- return result.installed << member if dry_run
125
124
 
125
+ # A dry run runs the Installer's too, which writes nothing, so it
126
+ # names the files the real run would keep as the user's own.
126
127
  installer = Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
127
- shipped_dir: shipped_dir)
128
+ shipped_dir: shipped_dir, dry_run: dry_run)
128
129
  installer.run
129
130
  result.installed << member
130
- kept = installer.results.select { |_, entry| entry[:status] == "skipped" && entry[:reason] == "already exists" }.keys
131
+ kept = installer.results.select { |_, entry| kept_own?(entry) }.keys
131
132
  result.kept_own[member] = kept.sort unless kept.empty?
132
133
  rescue StandardError => e
133
134
  result.failed[member] = e.message.lines.first.to_s.strip
134
135
  end
135
136
  private_class_method :install_member
136
137
 
138
+ # An Installer result for a file the user already had, with other
139
+ # bytes: skipped ("already exists"), or a dry run's would_skip. One
140
+ # with the bundle's bytes ("already up to date") isn't named: nothing
141
+ # of the bundle's is missing (it stays the user's all the same).
142
+ def kept_own?(entry)
143
+ entry[:status] == "would_skip" || (entry[:status] == "skipped" && entry[:reason] == "already exists")
144
+ end
145
+ private_class_method :kept_own?
146
+
137
147
  # Uninstalls each recorded member still installed, then the meta. A
138
148
  # member that won't go (an edited .md file, without +force+) is
139
149
  # reported and the rest carry on; the meta then stays, still
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cli/command"
5
+ require_relative "cli/exit"
6
+ require_relative "cli/flags"
7
+ require_relative "memory_paths"
8
+ require_relative "tools/memory"
9
+ require_relative "memory_bundle/index_updater"
10
+ require_relative "memory_bundle/provenance"
11
+
12
+ module Samagotchi
13
+ # `chi memory`: the user's side of the memories (docs/memory.md). ls lists
14
+ # both scopes' entries from their index.md lines and files, as memory_read
15
+ # and the prompt see them; rm removes one as memory_write remove: true does
16
+ # (Tools::MemoryWrite.remove: the file and its model overlays into the
17
+ # bundle trash, the index line dropped, a bundle's memory refused).
18
+ class MemoryCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi memory <ls|rm> [options]
23
+ ls [--scope project|system] [--format text|json] the memories: both scopes (this folder's project
24
+ first), or one; each with its size, date, the bundle it came from and its index description
25
+ rm NAME [--scope project|system] remove a memory: its file and its model overlays (NAME.<key>.md)
26
+ move to the bundle trash (chi bundle trash lists and empties it) and its index line is dropped.
27
+ Without --scope NAME must be in one scope only. A bundle's memory is refused: chi bundle
28
+ uninstall removes it. NAME.<key> removes one model overlay alone.
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[ls rm].freeze
32
+ SCOPES = Tools::VALID_SCOPES
33
+ # The description column's width in ls's text form (json has it all).
34
+ DESCRIPTION_WIDTH = 60
35
+ # ls's size column, right-aligned.
36
+ SIZE_COLUMN = 2
37
+
38
+ FLAGS = {
39
+ "ls" => CLI::Flags.new(help: HELP_WORDS) do |f|
40
+ f.value "--scope"
41
+ f.value "--format"
42
+ end,
43
+ "rm" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--scope" }
44
+ }.freeze
45
+
46
+ # One memory of a scope. +date+ is the index line's (the file's mtime
47
+ # when it has none); +source+ the bundle that owns it; +overlays+ the
48
+ # model keys of its overlays; +indexed+ / +file+: whether it has an
49
+ # index line / a file (a line whose file was deleted by hand has none).
50
+ Entry = Data.define(:name, :scope, :path, :bytes, :date, :source, :description, :overlays, :indexed, :file) do
51
+ def to_h = super.transform_keys(&:to_s)
52
+ end
53
+
54
+ # A managed index line: "- **name** · scope · date · size[ · from b][ — desc]"
55
+ # or a legacy "- **name**: desc" (MemoryWrite.managed_pattern).
56
+ LINE = /^- \*\*([^*\n]+)\*\*[ \t]*(?:[·•][^\n]*|:[^\n]*)?$/
57
+
58
+ # @param argv [Array<String>] the arguments after "memory"
59
+ # The project scope is the cwd's project, as for the memory tools.
60
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
61
+ @argv = argv.dup
62
+ @stdout = stdout
63
+ @stderr = stderr
64
+ end
65
+
66
+ # @return [Integer] exit status: 0 done, 1 refused or failed, 2 usage
67
+ def run
68
+ @sub = @argv[0]
69
+ if @sub.nil? || HELP_WORDS.include?(@sub)
70
+ @stdout.puts(USAGE)
71
+ return CLI::Exit::OK
72
+ end
73
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
74
+
75
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
76
+ return parsed if parsed.is_a?(Integer)
77
+
78
+ scope = parsed.options[:scope]
79
+ return usage_error("unknown scope #{scope.inspect}: use --scope project|system") if scope && !SCOPES.include?(scope)
80
+
81
+ send(:"run_#{@sub}", parsed.options, parsed.args)
82
+ end
83
+
84
+ # The memories of +scope+ (project first, then system, when nil), by
85
+ # name within a scope.
86
+ # @return [Array<Entry>]
87
+ def self.entries(scope = nil)
88
+ owners = MemoryBundle::Provenance.each_installed.reject { |_, bundle| bundle.error? }
89
+ (scope ? [scope] : SCOPES).flat_map { |s| scope_entries(s, owners) }
90
+ end
91
+
92
+ def self.scope_entries(scope, owners)
93
+ dir = MemoryPaths.scope_dir(scope)
94
+ return [] unless File.directory?(dir)
95
+
96
+ index_path = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
97
+ index_text = File.file?(index_path) ? File.read(index_path, encoding: "UTF-8") : ""
98
+ # name => its line; the first one when a name has two. A name rm
99
+ # would refuse (a path: "../x") isn't listed.
100
+ lines = {}
101
+ index_text.each_line do |l|
102
+ (m = l.chomp.match(LINE)) or next
103
+ lines[m[1]] ||= m[0] unless Tools::MemoryRead.invalid_name?(m[1])
104
+ end
105
+ stems = Dir.children(dir).filter_map { |f| f.delete_suffix(".md") if f.end_with?(".md") && File.file?(File.join(dir, f)) }.to_set
106
+ overlays = overlays_by_base(stems, lines)
107
+ overlay_stems = overlays.values.flatten(1).to_set { |_key, stem| stem }
108
+ names = (lines.keys + stems.to_a).uniq - [Tools::MEMORY_INDEX]
109
+ names.filter_map do |name|
110
+ next if overlay_stems.include?(name)
111
+
112
+ path = File.join(dir, "#{name}.md")
113
+ line = lines[name]
114
+ file = stems.include?(name)
115
+ # A bold line of prose ("- **Important**") isn't a memory: only a
116
+ # managed line ("· …") counts without a file.
117
+ next unless file || line&.include?(" · ")
118
+
119
+ entry(name, scope, path, line, file, overlays.fetch(name, []).map(&:first), owners)
120
+ end.sort_by(&:name)
121
+ end
122
+
123
+ # base name => [[key, stem]...] of its model overlays, in one pass over
124
+ # the files (+stems+, a Set): <base>.<key>.md with a key ModelOverlay
125
+ # makes, whose base is a file and whose stem has no index line of its
126
+ # own (MemoryWrite.overlay_files' rule, for every base at once).
127
+ def self.overlays_by_base(stems, lines)
128
+ stems.sort.each_with_object({}) do |stem, by_base|
129
+ base, dot, key = stem.rpartition(".")
130
+ next if dot.empty? || base.empty? || !key.match?(/\A[a-z0-9-]+\z/)
131
+ next unless stems.include?(base) && !lines.key?(stem)
132
+
133
+ (by_base[base] ||= []) << [key, stem]
134
+ end
135
+ end
136
+
137
+ def self.entry(name, scope, path, line, file, overlay_keys, owners)
138
+ source = line && MemoryBundle::IndexUpdater.extract_source(line)
139
+ source ||= owners.find { |_, bundle| bundle.effective_scope == scope && bundle.owns?("#{name}.md") }&.first
140
+ Entry.new(
141
+ name: name, scope: scope, path: path, bytes: file ? File.size(path) : nil,
142
+ date: line.to_s[/ · (\d{4}-\d{2}-\d{2})\b/, 1] || (file ? File.mtime(path).strftime("%Y-%m-%d") : nil),
143
+ source: source, description: line && line.include?(" · ") ? line.split(" — ", 2)[1]&.strip : legacy(line),
144
+ overlays: overlay_keys,
145
+ indexed: !line.nil?, file: file
146
+ )
147
+ end
148
+
149
+ # A legacy "- **name**: desc" line's description.
150
+ def self.legacy(line) = line&.split("**:", 2)&.[](1)&.strip&.then { |d| d.empty? ? nil : d }
151
+ private_class_method :scope_entries, :overlays_by_base, :entry, :legacy
152
+
153
+ private
154
+
155
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi memory #{@sub}" : "chi memory"
156
+ def usage_on_error = nil
157
+
158
+ def run_ls(options, args)
159
+ return usage_error("ls takes no NAME (unexpected #{args.first})") unless args.empty?
160
+
161
+ format = options[:format]
162
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
163
+
164
+ rows = self.class.entries(options[:scope])
165
+ if format == "json"
166
+ @stdout.puts(JSON.pretty_generate(rows.map(&:to_h)))
167
+ elsif rows.empty?
168
+ @stdout.puts("no memories#{" in #{options[:scope]} scope" if options[:scope]}")
169
+ else
170
+ print_rows(rows)
171
+ end
172
+ CLI::Exit::OK
173
+ end
174
+
175
+ def print_rows(rows)
176
+ table = rows.map do |r|
177
+ notes = []
178
+ notes << "overlays: #{r.overlays.join(", ")}" unless r.overlays.empty?
179
+ notes << "no file" unless r.file
180
+ notes << "no index line" unless r.indexed
181
+ [r.scope, r.name, r.bytes.to_s, r.date.to_s, r.source ? "from #{r.source}" : "",
182
+ [clip(r.description.to_s), *notes.map { |n| "[#{n}]" }].reject(&:empty?).join(" ")]
183
+ end
184
+ widths = table.transpose.map { |column| column.map(&:length).max }
185
+ # A column no row fills (no memory from a bundle) takes no room.
186
+ table.each do |line|
187
+ cells = line.each_with_index.map { |cell, i| i == SIZE_COLUMN ? cell.rjust(widths[i]) : cell.ljust(widths[i]) }
188
+ @stdout.puts(cells.reject.with_index { |_, i| widths[i].zero? }.join(" ").rstrip)
189
+ end
190
+ end
191
+
192
+ def clip(text) = text.length > DESCRIPTION_WIDTH ? "#{text[0, DESCRIPTION_WIDTH - 1]}…" : text
193
+
194
+ def run_rm(options, args)
195
+ name = args.shift
196
+ return usage_error("give the memory's NAME") unless name
197
+ return usage_error("rm takes one NAME (unexpected #{args.first})") unless args.empty?
198
+
199
+ scope = options[:scope] || only_scope(name)
200
+ return scope if scope.is_a?(Integer)
201
+
202
+ result = Tools::MemoryWrite.call(nil, path: name, scope: scope, remove: true)
203
+ if result.start_with?("Error: ")
204
+ error_line("#{command_name}: #{result.delete_prefix("Error: ")}")
205
+ return CLI::Exit::FAILED
206
+ end
207
+
208
+ @stdout.puts(result)
209
+ CLI::Exit::OK
210
+ end
211
+
212
+ # The one scope +name+ is in (a file or an index line), else the exit
213
+ # status: a usage error when it is in both, a failure in neither (an
214
+ # invalid name goes on to remove's own error).
215
+ # @return [String, Integer]
216
+ def only_scope(name)
217
+ return "project" if Tools::MemoryRead.invalid_name?(name) || name == Tools::MEMORY_INDEX
218
+
219
+ found = SCOPES.select { |scope| present?(name, scope) }
220
+ case found.size
221
+ when 1 then found.first
222
+ when 0
223
+ error_line("#{command_name}: no memory '#{name}' in project or system scope")
224
+ CLI::Exit::FAILED
225
+ else
226
+ usage_error("#{name} is in both scopes: chi memory rm #{name} --scope project, or --scope system")
227
+ end
228
+ end
229
+
230
+ def present?(name, scope)
231
+ dir = MemoryPaths.scope_dir(scope)
232
+ index = File.join(dir, "#{Tools::MEMORY_INDEX}.md")
233
+ File.file?(File.join(dir, "#{name}.md")) ||
234
+ (File.file?(index) && File.read(index, encoding: "UTF-8").match?(Tools::MemoryWrite.managed_pattern(name)))
235
+ end
236
+ end
237
+ end
@@ -21,8 +21,12 @@ module Samagotchi
21
21
  # The first chi release with each call a plugin may make (from git:
22
22
  # the first release tag holding it), for `chi bundle check`'s
23
23
  # requires-chi-floor: a bundle whose requires_chi admits an older chi.
24
- # A call not listed gets no floor check: it came before the first tag
25
- # (v0.2.0), or isn't listed yet. Keys have no "?" (API_CALL drops it).
24
+ # A call not listed gets no floor check: it is in the first tag (v0.2.0:
25
+ # chi.command/tool/on/service/init, ctx.settings, ctx.sessions,
26
+ # ctx.ask_model, ctx.notify, ctx.card, ctx.messages…). A member of
27
+ # one (ctx.sessions.children) is listed when it came later. Keys have
28
+ # no "?" (API_CALL drops it). A keyword added later (ctx.ask_model's
29
+ # model: in 0.80.0, ctx.sessions.send's delivery: in 0.64.0) has none.
26
30
  SINCE = {
27
31
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
28
32
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -31,7 +35,7 @@ module Samagotchi
31
35
  "ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
32
36
  "ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
33
37
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
34
- "ctx.cost" => "0.83.0"
38
+ "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
35
39
  }.freeze
36
40
 
37
41
  # @param bundle [String]
@@ -685,13 +685,12 @@ module Samagotchi
685
685
  return true
686
686
  end
687
687
 
688
- ack = result.ack
689
688
  @delivered = result
690
689
  # What the worker applied, for --format json: its ack's delivery
691
690
  # (next_step for an idle session) and a cut's outcome.
692
- @sent_delivery = ack["delivery"] || delivery
693
- @sent_cut = ack["cut"]
694
- @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
691
+ @sent_delivery = result.delivery || delivery
692
+ @sent_cut = result.cut
693
+ @info.puts("#{short} sent#{with_images}#{delivery_note(result, delivery: delivery, running: running, owner: owner)}")
695
694
  true
696
695
  rescue SessionManager::OwnedByTUI
697
696
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -706,11 +705,12 @@ module Samagotchi
706
705
  # the outcome from the ack (now, waits, off); a --queue one runs after
707
706
  # the turn (or now, when the turn ended before it went in); images keep
708
707
  # their own wording; an idle session's turn simply runs.
709
- # @param ack [Hash] the Bridge's 202 body ({} for the input file)
708
+ # @param result [SessionManager::DeliverResult] the accepted delivery
709
+ # (its ack is {} for the input file)
710
710
  # @return [String] "" or " (...)"
711
- def delivery_note(ack, delivery:, running:, owner:)
711
+ def delivery_note(result, delivery:, running:, owner:)
712
712
  return " (started its worker)" if owner.nil?
713
- return queue_note(ack, running: running) if Delivery.queue?(delivery)
713
+ return queue_note(result, running: running) if Delivery.queue?(delivery)
714
714
  return "" unless running
715
715
 
716
716
  # A busy worker runs a message with images as its own next turn
@@ -720,18 +720,18 @@ module Samagotchi
720
720
  # The TUIs' words for a cut's outcome. No outcome: a plain message,
721
721
  # or the input file (the Bridge went away), which the next step's
722
722
  # drain takes.
723
- outcome = Delivery.cut?(delivery) ? ack["cut"].to_s : ""
723
+ outcome = Delivery.cut?(delivery) ? result.cut.to_s : ""
724
724
  return " (goes in at the running turn's next step)" unless CUT_OUTCOMES.include?(outcome)
725
725
 
726
- " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, ack["cut_after"])}"
726
+ " #{TerminalUI::Formatting.steer_cut_note(outcome.to_sym, result.cut_after)}"
727
727
  end
728
728
 
729
729
  # A --queue, worded from the Bridge's ack: queue when a turn ran as it
730
730
  # went in (one another client started too), next_step when none did
731
731
  # (it runs now). The input file (no Bridge) carries the queue to
732
732
  # whichever turn runs: worded from the session as chi read it.
733
- def queue_note(ack, running:)
734
- queued = ack.key?("delivery") ? ack["delivery"] == Delivery::QUEUE : running
733
+ def queue_note(result, running:)
734
+ queued = result.delivery.nil? ? running : result.delivery == Delivery::QUEUE
735
735
  return " (queued: runs after the current turn)" if queued
736
736
 
737
737
  running ? " (the running turn had ended: it runs now)" : " (nothing running: it runs now)"
@@ -359,6 +359,35 @@ module Samagotchi
359
359
  end
360
360
  private_class_method :unmark_removed
361
361
 
362
+ # How old a per-input tombstone (input/removed/, TurnCheckpoint's
363
+ # input/settled/) gets before a worker's start prunes it (#prune_old).
364
+ # A wait reads one at its next poll (ReplyWait::POLL_INTERVAL) and a
365
+ # later `chi send --wait --turn` reads a settled one at its start, so
366
+ # only a wait whose process slept (a closed laptop) or one started long
367
+ # after needs it to stay. A removed one gone under a live wait would
368
+ # read as a merge (the wrong reply), so the age is generous: a week,
369
+ # half the session retention (14 days), for one small file per
370
+ # file-delivered command or dequeue.
371
+ TOMBSTONE_MAX_AGE = 7 * 24 * 3600
372
+
373
+ # Deletes the files in input/<+subdir+> last written more than
374
+ # +max_age+ seconds ago; one that can't be read or deleted stays.
375
+ # @return [Integer] how many went
376
+ def self.prune_old(session_dir, subdir, max_age: TOMBSTONE_MAX_AGE)
377
+ cutoff = Time.now - max_age
378
+ Dir.glob(File.join(session_dir, INPUT_DIR, subdir, "*")).count do |file|
379
+ next false unless File.mtime(file) < cutoff
380
+
381
+ File.delete(file)
382
+ true
383
+ rescue SystemCallError
384
+ false
385
+ end
386
+ end
387
+
388
+ # The removed tombstones past +max_age+ (#prune_old).
389
+ def self.prune_removed(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, REMOVED_DIR, max_age: max_age)
390
+
362
391
  # @return [Boolean] whether the queued input +enqueued_id+ was removed
363
392
  # before it ran (#take_queued_input's tombstone), or is being removed:
364
393
  # the tombstone goes first, so while its claimed file is still there
@@ -1177,6 +1177,17 @@ module Samagotchi
1177
1177
  def error = ack["error"]
1178
1178
 
1179
1179
  def detail = ack["detail"]
1180
+
1181
+ # What the worker applied (a Bridge ack only): the delivery asked for,
1182
+ # or next_step for a --queue that found no turn running; nil from the
1183
+ # input file or a worker older than delivery.
1184
+ def delivery = ack["delivery"]
1185
+
1186
+ # A --cut's outcome ("now", "waits", "off"), nil when nothing was cut.
1187
+ def cut = ack["cut"]
1188
+
1189
+ # The steer cut-after seconds, with a cut that waits only.
1190
+ def cut_after = ack["cut_after"]
1180
1191
  end
1181
1192
 
1182
1193
  # Hand a user message to the session the way a UI does, for the web
@@ -727,6 +727,8 @@ module Samagotchi
727
727
  @screen.commit("#{paint("model>", 36)} #{output}")
728
728
  end
729
729
  prefill_confirm(event)
730
+ # /title from any client: the row's title follows.
731
+ @status.update(title: event[:title]) if Array(event[:changed]).include?("title")
730
732
  return unless event[:model_name]
731
733
 
732
734
  # After the output, so the status row changes with the line saying why.
@@ -5,7 +5,7 @@ require_relative "formatting"
5
5
  module Samagotchi
6
6
  class TerminalUI
7
7
  # The status row under the prompt, the same for both TUIs:
8
- # `status> model=… | ↳ parent | ⑂ 2 running · 1 waiting | ctx=12.3% (under20) | mem: … | muted: …`.
8
+ # `status> Title | model=… | ↳ parent | ⑂ 2 running · 1 waiting | ctx=12.3% (under20) | mem: … | muted: …`.
9
9
  # A UI feeds it what it learns (attached mode from the Bridge's events,
10
10
  # the REPL from its Engine's) through #update, and the row is redrawn in
11
11
  # the surface's status slot only when its text changes.
@@ -15,6 +15,8 @@ module Samagotchi
15
15
  # Memory names shown before ", +N".
16
16
  MEMORY_LIMIT = 8
17
17
  # What the row shows:
18
+ # title the session's own title (Session#title; not
19
+ # the first message's preview), or nil
18
20
  # model, default_model the model in use, and the config's default
19
21
  # (named beside another model)
20
22
  # served [served, asked, expected]: what the server said it
@@ -29,12 +31,17 @@ module Samagotchi
29
31
  # records them as used)
30
32
  # muted its --mute list
31
33
  # running a turn runs: the row trails STEER_HINT
32
- FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
34
+ FIELDS = %i[title model default_model served parent_id children context used_memories preloaded muted running].freeze
33
35
 
34
36
  # The tail while a turn runs: where a typed line goes (the next step)
35
37
  # and the two other choices. Dropped whole first when the row is cut.
36
38
  STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
37
39
 
40
+ # The longest title shown, and the share of the row it may take on a
41
+ # narrow terminal: the rest of the row still fits.
42
+ TITLE_MAX = 32
43
+ TITLE_SHARE = 3
44
+
38
45
  # The turn events that end a turn (the hint goes).
39
46
  TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
40
47
 
@@ -74,6 +81,7 @@ module Samagotchi
74
81
  fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
75
82
  STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
76
83
  fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
84
+ fields[:title] = state[:title] if state.key?(:title)
77
85
  # The last turn's ctx, so it shows before the next turn's.
78
86
  fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
79
87
  update(**fields)
@@ -126,7 +134,8 @@ module Samagotchi
126
134
  model_text = status_model_text(model, @values[:default_model], served: served, served_for: served_for,
127
135
  expected: expected.is_a?(String))
128
136
  end
129
- segments = [model_text,
137
+ segments = [title_text(@values[:title], width),
138
+ model_text,
130
139
  parent ? "↳ #{parent.to_s[0, 8]}" : "",
131
140
  children_text(@values[:children]),
132
141
  status_context_text(estimate: @values[:context]),
@@ -147,6 +156,18 @@ module Samagotchi
147
156
  segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
148
157
  end
149
158
 
159
+ # The title cut to TITLE_MAX, and to a TITLE_SHARE-th of +width+
160
+ # (the row's other segments come after it); "" without one.
161
+ def title_text(title, width)
162
+ title = title.to_s.strip
163
+ return "" if title.empty?
164
+
165
+ max = [TITLE_MAX, width / TITLE_SHARE].min
166
+ return title if title.length <= max
167
+
168
+ max > 1 ? "#{title[0, max - 1]}…" : ""
169
+ end
170
+
150
171
  # `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
151
172
  # children, or all ended). Not "not reported": a REPL parent is never
152
173
  # given its children's replies (no wakes there), and a worker moves
@@ -889,6 +889,7 @@ module Samagotchi
889
889
  if command
890
890
  show_command_result(command)
891
891
  refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
892
+ refresh_status_row if command.changed.include?(:title)
892
893
  persist_recent_history(input) if command.shell
893
894
  return
894
895
  end
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  ([file] + overlay_files(dir, entry_name, index_text)).each { |path| trash.move(path) }
357
357
  Samagotchi::MemoryBundle::IndexUpdater.remove_index(resolved_scope, entry_name)
358
358
  "Memory '#{entry_name}' removed from #{resolved_scope} scope: moved to #{trash.dir} " \
359
- "(`chi bundle trash` lists and empties it); index line dropped."
359
+ "(`chi bundle trash` lists and empties it)#{"; index line dropped" if line}."
360
360
  end
361
361
 
362
362
  # The entry's model overlays: <name>.<key>.md with a key ModelOverlay
@@ -86,6 +86,12 @@ module Samagotchi
86
86
  settled_record(id)
87
87
  end
88
88
 
89
+ # The settled outcomes past +max_age+ (SessionInbox.prune_old).
90
+ # @return [Integer] how many went
91
+ def self.prune_settled(session_dir, max_age: SessionInbox::TOMBSTONE_MAX_AGE)
92
+ SessionInbox.prune_old(session_dir, SETTLED_DIR, max_age: max_age)
93
+ end
94
+
89
95
  def self.settled_record(id, ended_at: nil, seconds: nil)
90
96
  LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
91
97
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.96.0"
4
+ VERSION = "0.97.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -237,6 +237,7 @@ module Samagotchi
237
237
  @config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
238
238
  @engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
239
239
  end
240
+ prune_tombstones
240
241
  settle_interrupted_turn
241
242
  # Before the Bridge serves anything: a UI joining a resumed worker's
242
243
  # stream gets the session's history and status in its snapshot, not
@@ -491,6 +492,15 @@ module Samagotchi
491
492
  save_or_log(:interrupted_turn) { save_session }
492
493
  end
493
494
 
495
+ # The per-input tombstones (removed, settled) older than
496
+ # SessionInbox::TOMBSTONE_MAX_AGE, once per worker start: they only go
497
+ # with the session dir otherwise.
498
+ def prune_tombstones
499
+ removed = SessionInbox.prune_removed(@session_dir)
500
+ settled = TurnCheckpoint.prune_settled(@session_dir)
501
+ Log.info(:worker, "tombstones_pruned", removed: removed, settled: settled) if (removed + settled).positive?
502
+ end
503
+
494
504
  # A saved parent_answers that isn't a level reads as hard on every
495
505
  # answer without a word; said once here, as the session loads.
496
506
  def announce_session_warnings
@@ -958,6 +968,8 @@ module Samagotchi
958
968
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
959
969
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
960
970
  event[:anytime] = true if anytime
971
+ # The session's title after /title (nil: cleared), for the TUIs' row.
972
+ event[:title] = @engine.session&.title if changed.include?(:title)
961
973
  # Its line was shown at its command_queued (waits: turn_end).
962
974
  event[:queued] = true if command.waits_for_turn_end?
963
975
  # The chips the command moved (SessionSetting#changed_by): what the next
@@ -1259,12 +1271,14 @@ module Samagotchi
1259
1271
  # The Bridge serves before the idle-exit policy exists.
1260
1272
  return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1261
1273
 
1262
- hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
1263
- if hold == :background_tasks && !restart
1264
- # /exit names the tasks it waits for (D3).
1265
- return WorkerIdleExit::Hold.new(reason: hold, tasks: @task_wakes.watch.running, hint: TASK_STOP_HINT)
1274
+ if restart
1275
+ hold = @idle_exit.hold_for_restart
1276
+ return WorkerIdleExit::Hold.new(reason: hold) if hold
1277
+ else
1278
+ # /exit names the tasks it waits for (D3), from the one look that found them.
1279
+ hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1280
+ return hold if hold
1266
1281
  end
1267
- return WorkerIdleExit::Hold.new(reason: hold) if hold
1268
1282
 
1269
1283
  @exit_requested = true
1270
1284
  @exit_requested_by = client_id
@@ -94,15 +94,23 @@ module Samagotchi
94
94
  # still be open, untagged)
95
95
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
96
96
  # :client_connected, :reminders or :background_tasks
97
- def hold_for_request(requester:, streams: true)
98
- return :turn_running if @engine.turn_running?
99
- return :input_queued if @input_pending.call
100
- return :continue_offered if @awaiting_continue.call
101
- return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
102
- return :reminders if @engine.reminder_store&.any?
103
- return :background_tasks if @watched_running.call.any?
97
+ def hold_for_request(requester:, streams: true) = request_hold(requester: requester, streams: streams)&.reason
104
98
 
105
- nil
99
+ # #hold_for_request as a Hold: for :background_tasks, the watched tasks
100
+ # still running, read once (TaskWatch#running looks at each task's
101
+ # process), with +hint+.
102
+ # @return [Hold, nil]
103
+ def request_hold(requester:, streams: true, hint: nil)
104
+ reason = if @engine.turn_running? then :turn_running
105
+ elsif @input_pending.call then :input_queued
106
+ elsif @awaiting_continue.call then :continue_offered
107
+ elsif streams && @bridge && @bridge.open_streams_except(requester).positive? then :client_connected
108
+ elsif @engine.reminder_store&.any? then :reminders
109
+ end
110
+ return Hold.new(reason: reason) if reason
111
+
112
+ running = @watched_running.call
113
+ running.any? ? Hold.new(reason: :background_tasks, tasks: running, hint: hint) : nil
106
114
  end
107
115
 
108
116
  # What keeps the worker from restarting now (a client asked: POST /exit
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.96.0
4
+ version: 0.97.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -437,6 +437,7 @@ files:
437
437
  - lib/samagotchi/memory_bundle/system_bundle.rb
438
438
  - lib/samagotchi/memory_bundle/trash.rb
439
439
  - lib/samagotchi/memory_bundle/uninstaller.rb
440
+ - lib/samagotchi/memory_command.rb
440
441
  - lib/samagotchi/memory_paths.rb
441
442
  - lib/samagotchi/message_parts.rb
442
443
  - lib/samagotchi/model_catalog.rb