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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +2 -0
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +5 -1
- data/docs/configuration.md +13 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +3 -1
- data/docs/memory.md +43 -3
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +49 -38
- data/lib/samagotchi/llm/errors.rb +100 -2
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/plugin/api.rb +7 -3
- data/lib/samagotchi/send_command.rb +11 -11
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +1 -0
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +2 -1
|
@@ -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
|
-
"
|
|
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
|
|
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
|
|
25
|
-
#
|
|
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 =
|
|
693
|
-
@sent_cut =
|
|
694
|
-
@info.puts("#{short} sent#{with_images}#{delivery_note(
|
|
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
|
|
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(
|
|
711
|
+
def delivery_note(result, delivery:, running:, owner:)
|
|
712
712
|
return " (started its worker)" if owner.nil?
|
|
713
|
-
return queue_note(
|
|
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) ?
|
|
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,
|
|
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(
|
|
734
|
-
queued =
|
|
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 = [
|
|
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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
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.
|
|
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
|