samagotchi 0.95.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +2 -0
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +13 -1
  7. data/docs/configuration.md +13 -0
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +3 -1
  11. data/docs/memory.md +43 -3
  12. data/docs/plugins.md +4 -3
  13. data/docs/sessions.md +8 -2
  14. data/lib/samagotchi/bundle_command/check.rb +5 -3
  15. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/children_status.rb +1 -1
  18. data/lib/samagotchi/engine.rb +49 -38
  19. data/lib/samagotchi/llm/errors.rb +100 -2
  20. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  21. data/lib/samagotchi/memory_command.rb +237 -0
  22. data/lib/samagotchi/plugin/api.rb +7 -3
  23. data/lib/samagotchi/plugin/sessions.rb +4 -3
  24. data/lib/samagotchi/send_command.rb +12 -12
  25. data/lib/samagotchi/session.rb +13 -1
  26. data/lib/samagotchi/session_commands.rb +13 -0
  27. data/lib/samagotchi/session_continue.rb +8 -8
  28. data/lib/samagotchi/session_delete_command.rb +1 -1
  29. data/lib/samagotchi/session_inbox.rb +29 -0
  30. data/lib/samagotchi/session_manager.rb +24 -7
  31. data/lib/samagotchi/sessions_command.rb +8 -6
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  33. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  34. data/lib/samagotchi/terminal_ui.rb +1 -0
  35. data/lib/samagotchi/title_command.rb +61 -0
  36. data/lib/samagotchi/tools/memory.rb +1 -1
  37. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +4 -4
  40. data/lib/samagotchi/web/public/app.js +12 -0
  41. data/lib/samagotchi/web/public/chains.js +21 -8
  42. data/lib/samagotchi/web/public/format.js +4 -3
  43. data/lib/samagotchi/web/public/index.html +7 -0
  44. data/lib/samagotchi/web/public/info_bar_model.js +2 -0
  45. data/lib/samagotchi/web/public/session_rename.js +86 -0
  46. data/lib/samagotchi/web/public/session_state.js +4 -2
  47. data/lib/samagotchi/web/session_summary.rb +3 -0
  48. data/lib/samagotchi/worker.rb +24 -7
  49. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  50. metadata +4 -1
@@ -384,6 +384,24 @@ module Samagotchi
384
384
  # Seconds to wait before retrying a CreditsHeld without a Retry-After.
385
385
  CREDITS_SETTLE_DELAY = 20.0
386
386
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
387
+ # How much of a server's error message the summary keeps.
388
+ MESSAGE_LIMIT = 500
389
+ # The 404 model hint's bounds (the message is the server's): how much
390
+ # of the message it reads, the longest model id, how many listed ids.
391
+ HINT_SCAN_CHARS = 64 * 1024
392
+ HINT_ID_MAX = 200
393
+ HINT_IDS_MAX = 2000
394
+ # A typo the hint forgives: at most this many edits, fewer than a
395
+ # third of the name, in a name at least HINT_FUZZY_MIN long.
396
+ HINT_MAX_EDITS = 2
397
+ HINT_FUZZY_MIN = 6
398
+ # A 404's model it couldn't find: "Model 'X' not found", "model `X`
399
+ # does not exist", "model X-1 not found" (unquoted, an id-like word,
400
+ # checked after the match). Bounded quantifiers: a server's long token
401
+ # can't make the match quadratic (Ruby 3.3 has no regex memoization).
402
+ MODEL_NOT_FOUND_RE = /model\s+(?:["'`]([^"'`\s]{1,#{HINT_ID_MAX}})["'`]|([^"'`\s]{1,#{HINT_ID_MAX}}))\s+(?:was\s+)?(?:not found|does not exist)/i
403
+ # The ids a 404 lists after "Available models:".
404
+ AVAILABLE_MODELS_RE = /available models?:\s*(.+)\z/im
387
405
  # The HTTP status an Anthropic error.type stands for, for an error
388
406
  # object that carries no numeric code (a Messages stream's error event,
389
407
  # an OpenAI error whose code is null or a name).
@@ -426,6 +444,7 @@ module Samagotchi
426
444
  else
427
445
  OutOfCredits.new(text, **options)
428
446
  end
447
+ when 404 then bad_request(text, status, message, options.merge(hint: model_hint(error_message(body, limit: nil))))
429
448
  when 400..499 then bad_request(text, status, message, options)
430
449
  when 500..599
431
450
  ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
@@ -492,7 +511,7 @@ module Samagotchi
492
511
  # upstream failure (OpenRouter) often says only "Provider returned
493
512
  # error" and puts the upstream's words in error.metadata.raw and its
494
513
  # name in metadata.provider_name; both are added when present.
495
- def error_message(body)
514
+ def error_message(body, limit: MESSAGE_LIMIT)
496
515
  parsed = parse_json(body.to_s)
497
516
  message =
498
517
  if parsed.is_a?(Hash)
@@ -502,9 +521,88 @@ module Samagotchi
502
521
  message = body.to_s if message.nil? || message.to_s.strip.empty?
503
522
  message = message.to_s.strip
504
523
  message = with_upstream(message, parsed["error"]["metadata"]) if parsed.is_a?(Hash) && parsed["error"].is_a?(Hash)
505
- message.empty? ? "(empty body)" : message[0, 500]
524
+ return "(empty body)" if message.empty?
525
+
526
+ limit ? message[0, limit] : message
527
+ end
528
+
529
+ # "did you mean 'ID'?" for a 404 that names the model it couldn't find
530
+ # and lists the ones it has (oMLX: "Model 'X' not found. Available
531
+ # models: a, b, …"): the listed id that is X but for case, the one
532
+ # whose last segment (after "/" or "--") is X, else the one id a
533
+ # typo away (#typo_distance, whole or its last segment). Read from
534
+ # the message past the summary's cut (MESSAGE_LIMIT), within the
535
+ # HINT_* bounds; nil when there is no list, nothing close or a tie.
536
+ def model_hint(message)
537
+ message = message[0, HINT_SCAN_CHARS]
538
+ match = MODEL_NOT_FOUND_RE.match(message)
539
+ requested = match && (match[1] || match[2])
540
+ return nil unless requested && (match[1] || requested.match?(%r{[-/.:_\d]}))
541
+
542
+ list = message[AVAILABLE_MODELS_RE, 1]
543
+ return nil unless list
544
+
545
+ ids = list.split(/,\s*|\s+/, HINT_IDS_MAX + 1).first(HINT_IDS_MAX)
546
+ .map { |id| id.delete("[]\"'`").sub(/[.;]+\z/, "") }
547
+ .reject { |id| id.empty? || id.length > HINT_ID_MAX }.uniq
548
+ return nil if ids.empty? || ids.include?(requested)
549
+
550
+ close = closest_model_id(requested, ids)
551
+ close && "did you mean '#{close}'?"
506
552
  end
507
553
 
554
+ # The id that is +requested+ but for case, or whose last segment is,
555
+ # else the one id a typo away (#typo_distance); nil on a tie.
556
+ def closest_model_id(requested, ids)
557
+ probe = requested.downcase
558
+ exact = ids.find { |id| id.downcase == probe } || ids.find { |id| last_segment(id) == probe }
559
+ return exact if exact
560
+
561
+ scored = ids.filter_map do |id|
562
+ distance = [id.downcase, last_segment(id)].uniq.filter_map { |name| typo_distance(probe, name) }.min
563
+ [distance, id] if distance
564
+ end
565
+ return nil if scored.empty?
566
+
567
+ best = scored.map(&:first).min
568
+ closest = scored.select { |distance, _| distance == best }
569
+ closest.size == 1 ? closest.first.last : nil
570
+ end
571
+
572
+ # The edit distance of +a+ and +b+ when it is a typo's: at most
573
+ # HINT_MAX_EDITS and under a third of the shorter one, which has
574
+ # HINT_FUZZY_MIN characters or more; else nil. A banded Levenshtein:
575
+ # linear in the length.
576
+ def typo_distance(a, b)
577
+ shorter = [a.length, b.length].min
578
+ return nil if shorter < HINT_FUZZY_MIN || (a.length - b.length).abs > HINT_MAX_EDITS
579
+
580
+ distance = banded_distance(a, b, HINT_MAX_EDITS)
581
+ distance if distance && distance * 3 < shorter
582
+ end
583
+
584
+ # Levenshtein distance of +a+ and +b+ if at most +limit+, else nil;
585
+ # only cells within +limit+ of the diagonal are computed.
586
+ def banded_distance(a, b, limit)
587
+ far = limit + 1
588
+ prev = Hash.new(far)
589
+ (0..[limit, b.length].min).each { |j| prev[j] = j }
590
+ a.each_char.with_index(1) do |ca, i|
591
+ cur = Hash.new(far)
592
+ cur[0] = i if i <= limit
593
+ ([i - limit, 1].max..[i + limit, b.length].min).each do |j|
594
+ cost = ca == b[j - 1] ? 0 : 1
595
+ cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost, far].min
596
+ end
597
+ return nil if cur.empty? || cur.values.min > limit
598
+
599
+ prev = cur
600
+ end
601
+ prev[b.length] <= limit ? prev[b.length] : nil
602
+ end
603
+
604
+ def last_segment(id) = id.downcase.split(%r{/|--}).last
605
+
508
606
  # error.metadata.reason of a JSON error body (OpenRouter), or nil.
509
607
  def error_reason(body)
510
608
  parsed = parse_json(body.to_s)
@@ -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]
@@ -53,7 +53,7 @@ module Samagotchi
53
53
 
54
54
  child = SessionManager.spawn_session(
55
55
  prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
56
- parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
56
+ parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, preview: title,
57
57
  images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir,
58
58
  setup: @host.setup&.call || SessionSetup.new
59
59
  )
@@ -105,7 +105,8 @@ module Samagotchi
105
105
  # Session +id+ now: from its worker when one runs (with a running turn
106
106
  # so far), else as saved.
107
107
  # @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
108
- # (messages without the system prompt, symbol keys)
108
+ # (title: its own name, /title, else its first message's preview;
109
+ # messages without the system prompt, symbol keys)
109
110
  # @raise [Error] no such session
110
111
  def read(id)
111
112
  state_dir = self.state_dir
@@ -120,7 +121,7 @@ module Samagotchi
120
121
  messages += Bridge::TurnAccumulator.messages_of(deep_symbolize(turn))
121
122
  end
122
123
  end
123
- { id: sid, title: session.first_preview.to_s, status: session.status.to_s, parent_id: session.parent_id,
124
+ { id: sid, title: session.display_title, status: session.status.to_s, parent_id: session.parent_id,
124
125
  running: running, messages: without_system_head(messages) }
125
126
  end
126
127
 
@@ -416,7 +416,7 @@ module Samagotchi
416
416
  # says whether it ran as one.
417
417
  through_worker = images || command_like?(prompt)
418
418
  begin
419
- start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
419
+ start = through_worker ? { prompt: nil, preview: prompt } : { prompt: prompt }
420
420
  session = if options[:continues]
421
421
  SessionManager.continue_session(options[:continues], **start, model: options[:model],
422
422
  state_dir: @state_dir)
@@ -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)"
@@ -56,6 +56,10 @@ module Samagotchi
56
56
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
57
57
  # Names as given; the engine normalizes them.
58
58
  attr_accessor :preloaded_memory_names, :muted_memory_names
59
+ # The name the user gave the session (/title, TitleCommand), else nil:
60
+ # every list, card and tab shows it in place of #first_preview (see
61
+ # #display_title). A continue's new link inherits it.
62
+ attr_accessor :title
59
63
  # The files chi --append-system-prompt put into the session's system
60
64
  # prompt (absolute paths; config.yml's append_system_prompt: entries
61
65
  # aren't stored): kept on respawns, copied by a fork and a continue
@@ -276,7 +280,7 @@ module Samagotchi
276
280
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
277
281
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
278
282
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
279
- toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil)
283
+ toolkit: nil, bundle_settings: {}, interrupted_turn: nil, parent_answers: nil, title: nil)
280
284
  @id = id
281
285
  @metadata_version = metadata_version
282
286
  @mode = mode
@@ -289,6 +293,7 @@ module Samagotchi
289
293
  @status = status
290
294
  @last_prompt = last_prompt
291
295
  @first_preview = first_preview
296
+ @title = title
292
297
  @test_run = !!test_run
293
298
  self.pending_question = pending_question
294
299
  @used_memory_names = self.class.name_list(used_memory_names)
@@ -455,6 +460,7 @@ module Samagotchi
455
460
  "status" => STATUS_IDLE,
456
461
  "last_prompt" => "",
457
462
  "first_preview" => "",
463
+ "title" => nil,
458
464
  "test_run" => false,
459
465
  "pending_question" => nil,
460
466
  "used_memory_names" => [],
@@ -719,6 +725,12 @@ module Samagotchi
719
725
  File.join(Paths.state_home(env: env), STATE_SUBDIR)
720
726
  end
721
727
 
728
+ # What names the session: its #title when it has one, else its
729
+ # #first_preview.
730
+ def display_title
731
+ title.to_s.strip.empty? ? first_preview.to_s : title
732
+ end
733
+
722
734
  # Derive and cache the first user-message preview in the session record.
723
735
  # Returns true if the cached value was set or updated.
724
736
  # A session with only context notes so far previews by its first note
@@ -14,6 +14,7 @@ require_relative "llm_context_command"
14
14
  require_relative "thinking_command"
15
15
  require_relative "toolkit_command"
16
16
  require_relative "parent_answers_command"
17
+ require_relative "title_command"
17
18
  require_relative "reasoning_history_command"
18
19
  require_relative "guardrails/parent_policy"
19
20
 
@@ -37,6 +38,7 @@ module Samagotchi
37
38
  THINKING_COMMAND = ThinkingCommand::NAME
38
39
  TOOLKIT_COMMAND = ToolkitCommand::NAME
39
40
  PARENT_ANSWERS_COMMAND = ParentAnswersCommand::NAME
41
+ TITLE_COMMAND = TitleCommand::NAME
40
42
  REASONING_HISTORY_COMMAND = ReasoningHistoryCommand::NAME
41
43
  # A remote catalog has hundreds of ids (OpenRouter ~380): plain /models
42
44
  # shows this many per host; /models <text> lists every match.
@@ -112,6 +114,9 @@ module Samagotchi
112
114
  "never looser than config.yml's)",
113
115
  id: :parent_answers, mid_turn: show_mid_turn(PARENT_ANSWERS_COMMAND),
114
116
  match: ->(text) { text.match?(%r{\A/parent-answers(?:\s+.*)?\z}) }) { |text| parent_answers(text) }
117
+ # Only a name: renaming a running session doesn't wait for its turn.
118
+ registry.register(TITLE_COMMAND, "show or set this session's title (--clear: back to its first message)",
119
+ id: :title, anytime: true, match: ->(text) { text.match?(%r{\A/title(?:\s+.*)?\z}) }) { |text| title(text) }
115
120
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
116
121
  registry.register("/stats", "show the session's stats", local: true)
117
122
  registry.register("/recap", "show the session's recap", local: true)
@@ -522,6 +527,14 @@ module Samagotchi
522
527
  reply(output, status: status, changed: changed ? [:parent_answers] : [])
523
528
  end
524
529
 
530
+ # /title (TitleCommand); :title in changed when it named the session or
531
+ # cleared its name. A plain save: no setting moved, and it may run on
532
+ # an anytime thread beside a turn.
533
+ def title(text)
534
+ output, changed, status = TitleCommand.new(engine: @engine, save: @save).run(text.delete_prefix(TITLE_COMMAND))
535
+ reply(output, status: status, changed: changed ? [:title] : [])
536
+ end
537
+
525
538
  # A session setting changed (/llm-context, /thinking, /reasoning-history):
526
539
  # saved with its model.
527
540
  def save_setup(session)