samagotchi 0.96.0 → 0.98.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +12 -2
  5. data/docs/bundle-authoring.md +7 -4
  6. data/docs/cli.md +13 -4
  7. data/docs/configuration.md +13 -0
  8. data/docs/hooks.md +59 -0
  9. data/docs/internals/background-tasks.md +1 -1
  10. data/docs/internals/bundles.md +2 -2
  11. data/docs/internals/engine.md +21 -1
  12. data/docs/memory.md +43 -3
  13. data/docs/plugins.md +8 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/answer_refs.rb +16 -9
  16. data/lib/samagotchi/bundle_command/check.rb +5 -3
  17. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  18. data/lib/samagotchi/bundle_command.rb +2 -2
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/engine.rb +75 -40
  24. data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
  25. data/lib/samagotchi/hooks.rb +4 -1
  26. data/lib/samagotchi/llm/errors.rb +135 -3
  27. data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
  28. data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
  29. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  30. data/lib/samagotchi/memory_command.rb +237 -0
  31. data/lib/samagotchi/output_formatter.rb +8 -2
  32. data/lib/samagotchi/pending_question.rb +7 -3
  33. data/lib/samagotchi/plugin/api.rb +12 -3
  34. data/lib/samagotchi/question_asked.rb +83 -0
  35. data/lib/samagotchi/question_desk.rb +3 -2
  36. data/lib/samagotchi/send_command.rb +11 -11
  37. data/lib/samagotchi/session_inbox.rb +29 -0
  38. data/lib/samagotchi/session_manager.rb +11 -0
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  40. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  41. data/lib/samagotchi/terminal_ui.rb +1 -0
  42. data/lib/samagotchi/testing/bundle.rb +18 -1
  43. data/lib/samagotchi/tools/memory.rb +1 -1
  44. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +30 -9
  47. data/lib/samagotchi/web/public/app.js +44 -11
  48. data/lib/samagotchi/web/public/index.html +36 -13
  49. data/lib/samagotchi/web/public/info_bar_model.js +0 -2
  50. data/lib/samagotchi/web/public/lists_view.js +32 -8
  51. data/lib/samagotchi/web/public/markdown_place.js +25 -8
  52. data/lib/samagotchi/web/public/question_cards.js +23 -6
  53. data/lib/samagotchi/web/public/session_rename.js +88 -15
  54. data/lib/samagotchi/web/public/session_title.js +6 -0
  55. data/lib/samagotchi/web/public/stage_frame.js +24 -14
  56. data/lib/samagotchi/web/public/stage_model.js +4 -2
  57. data/lib/samagotchi/web/public/stage_slots.js +41 -9
  58. data/lib/samagotchi/web/public/stage_view.js +149 -8
  59. data/lib/samagotchi/web/public/stream_handlers.js +5 -1
  60. data/lib/samagotchi/web/public/turn_model.js +20 -0
  61. data/lib/samagotchi/web/public/turn_view.js +22 -9
  62. data/lib/samagotchi/worker.rb +19 -5
  63. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  64. metadata +3 -1
@@ -6,15 +6,22 @@ module Samagotchi
6
6
  module MemoryBundle
7
7
  # One hook's metadata, as a bundle's manifest.yml declares it and its
8
8
  # installed record (manifest.json) keeps it: the file's sha256
9
- # ("sha256:<hex>", "" when none is recorded), the event it registers
10
- # for ("" when none: it isn't loaded), on_error (skip, log or
11
- # fail_closed) and priority. The one place the defaults live.
12
- class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
9
+ # ("sha256:<hex>", "" when none is recorded), the events it registers
10
+ # for (manifest `event:` for one, `events:` a list; none: it isn't
11
+ # loaded), on_error (skip, log or fail_closed) and priority. One
12
+ # instance of the hook serves every event it lists. The one place the
13
+ # defaults live.
14
+ class BundleHook < Data.define(:sha256, :events, :on_error, :priority)
13
15
  DEFAULT_ON_ERROR = "skip"
14
16
  DEFAULT_PRIORITY = 100
15
17
 
16
- def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
17
- super
18
+ # @param event [String, nil] one event (manifest `event:`)
19
+ # @param events [Array<String>, String, nil] the events (manifest
20
+ # `events:`); `event:` and `events:` together are joined, blanks
21
+ # and repeats dropped
22
+ def initialize(sha256: "", event: nil, events: nil, on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
23
+ names = [*event, *events].map { |e| e.to_s.strip }.reject(&:empty?).uniq.freeze
24
+ super(sha256: sha256, events: names, on_error: on_error, priority: priority)
18
25
  end
19
26
 
20
27
  # @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
@@ -25,7 +32,7 @@ module Samagotchi
25
32
 
26
33
  h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
27
34
  on_error = h["on_error"].to_s.strip
28
- new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
35
+ new(sha256: prefixed(h["sha256"]), event: h["event"], events: h["events"],
29
36
  on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
30
37
  priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
31
38
  end
@@ -44,8 +51,20 @@ module Samagotchi
44
51
  # The sha's hex, "" when none is recorded.
45
52
  def hex = sha256.delete_prefix("sha256:")
46
53
 
47
- # The string-keyed form manifest.yml and manifest.json hold.
48
- def to_record = to_h.transform_keys(&:to_s)
54
+ # Whether the hook fails closed: on_error fail_closed applies to its
55
+ # before_tool_call registration only.
56
+ def fail_closed?(event) = on_error == "fail_closed" && event.to_s == "before_tool_call"
57
+
58
+ # How `chi bundle status` / `diff` name its events: `event=x`, or
59
+ # `events=x,y` for more than one.
60
+ def events_field = events.size > 1 ? "events=#{events.join(",")}" : "event=#{events.first}"
61
+
62
+ # The string-keyed form manifest.yml and manifest.json hold: `event`
63
+ # for one event (as an older chi reads it), `events` for more.
64
+ def to_record
65
+ listed = events.size > 1 ? { "events" => events.dup } : { "event" => events.first.to_s }
66
+ { "sha256" => sha256, **listed, "on_error" => on_error, "priority" => priority }
67
+ end
49
68
  end
50
69
  end
51
70
  end
@@ -375,9 +375,26 @@ module Samagotchi
375
375
  next unless k.is_a?(String) && !k.empty?
376
376
  next unless v.is_a?(Hash)
377
377
 
378
+ check_hook_events(k, v)
378
379
  acc[k] = BundleHook.parse(v)
379
380
  end
380
381
  end
382
+
383
+ HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
384
+
385
+ # A hook's events: list holds event names (one instance registered
386
+ # for each); it and event: are one or the other.
387
+ def check_hook_events(file, entry)
388
+ return unless entry.key?("events")
389
+
390
+ raw = entry["events"]
391
+ raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
392
+ raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
393
+
394
+ raw.each do |item|
395
+ raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
396
+ end
397
+ end
381
398
  end
382
399
  end
383
400
  end
@@ -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
@@ -65,6 +65,10 @@ module Samagotchi
65
65
  # Qwen tool_call XML blocks that survive when a tool call is rendered as text
66
66
  # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
67
67
  QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
68
+ # Gemma 4's closed tool-call block, <|tool_call>call:NAME{…}<tool_call|>:
69
+ # a native step's call, whose body #strip_markup drops (#strip keeps it:
70
+ # a streamed chunk may hold only one marker).
71
+ GEMMA_TOOL_CALL_RE = /<\|tool_call>.*?<tool_call\|>/m
68
72
 
69
73
  module_function
70
74
 
@@ -85,9 +89,11 @@ module Samagotchi
85
89
 
86
90
  # The same tokens as #strip, but the text keeps its layout (indentation,
87
91
  # runs of spaces); only the blank lines removed blocks leave collapse.
88
- # For showing a whole saved message (the attached TUI's join, the web history).
92
+ # For showing a whole saved message (the attached TUI's join, the web history):
93
+ # a native Gemma step's closed tool-call blocks go with their bodies, as
94
+ # Qwen's do (a step that only calls a tool has no text).
89
95
  def strip_markup(text)
90
- remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
96
+ remove_tokens(text.to_s.gsub(GEMMA_TOOL_CALL_RE, "")).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
91
97
  end
92
98
 
93
99
  def remove_tokens(text)
@@ -17,15 +17,19 @@ module Samagotchi
17
17
  # relayed approval's facts (Relay.card); limit, context: the step-limit
18
18
  # question's turn limit and offer context (ContinueOffer); status:
19
19
  # "pending" or "cancelled"; created_at: iso8601; relayed_to: the relay
20
- # mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate).
21
- # approval, relay, context and relayed_to stay Hashes as given (string
20
+ # mark ({parent_id:, parent_short:, relay_id:}, QuestionDesk#annotate);
21
+ # refs: the refs on the reply that asks ({match:, kind:, target:, …},
22
+ # the :question_asked hooks', QuestionAsked; display only, checked again
23
+ # where a saved one reaches a UI).
24
+ # approval, relay, context, relayed_to and refs stay as given (string
22
25
  # keys inside after a load). A field may be nil (unset).
23
26
  #
24
27
  # Crosses the process as a Hash: #to_h for an event, a Bridge snapshot or
25
28
  # a UI (symbol keys), #to_file for session.json (string keys); both in
26
29
  # member order with unset fields left out. .from_file reads either back.
27
30
  PendingQuestion = Data.define(:id, :question, :options, :header, :multi_select, :allow_freeform, :effects, :user_only,
28
- :kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to) do
31
+ :kind, :hook, :approval, :relay, :limit, :context, :status, :created_at, :relayed_to,
32
+ :refs) do
29
33
  def initialize(**fields)
30
34
  super(**members.to_h { |m| [m, nil] }, **fields)
31
35
  end
@@ -21,8 +21,17 @@ 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.
30
+ # Events aren't listed: chi.on takes any symbol and an older chi never
31
+ # fires one it doesn't know (harmless; chi bundle check warns on it),
32
+ # and a manifest hook's events: list (BundleHook) needs the bundle's
33
+ # requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
34
+ # scan covers manifest.yml.
26
35
  SINCE = {
27
36
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
28
37
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -31,7 +40,7 @@ module Samagotchi
31
40
  "ctx.prompt_files" => "0.60.0", "chi.annotation" => "0.66.0", "chi.toolkit" => "0.66.0",
32
41
  "ctx.use_toolkit" => "0.66.0", "ctx.toolkit" => "0.66.0", "ctx.declared_tools" => "0.66.0",
33
42
  "chi.tool_source" => "0.67.0", "chi.peer_source" => "0.67.0", "ctx.http" => "0.80.0",
34
- "ctx.cost" => "0.83.0"
43
+ "ctx.cost" => "0.83.0", "ctx.sessions.children" => "0.34.0"
35
44
  }.freeze
36
45
 
37
46
  # @param bundle [String]
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "answer_refs"
4
+ require_relative "log"
5
+ require_relative "output_formatter"
6
+
7
+ module Samagotchi
8
+ # The :question_asked hook event: a question of the model's own (its
9
+ # ask_user_question, kind "question") or an approval of its tool call
10
+ # (kind "approval") is about to open. Fired from the two Engine seams
11
+ # (Engine#request_question, the guardrail wiring's ask:), never from
12
+ # QuestionDesk, so a relayed delegate approval, a hook's ask_user and the
13
+ # step-limit question never fire it.
14
+ #
15
+ # A hook attaches refs to the reply that asks (event[:refs], after_turn's
16
+ # call shape and checks, AnswerRefs): they are kept as `display_refs` on
17
+ # the step's model message (the live conversation's last model entry,
18
+ # changed in place: the loops and the forget layer hold it by identity),
19
+ # display only, and go on the pending question (PendingQuestion#refs), so
20
+ # a UI pairs them in the asking text, on the card and in the history.
21
+ #
22
+ # The hooks run on the turn thread before the question opens: their time
23
+ # is the question's wait. A raising hook is logged (Registry#fire) and the
24
+ # question opens without its refs; event[:ask_user] is a no-op (nil).
25
+ module QuestionAsked
26
+ EVENT = :question_asked
27
+ KIND_QUESTION = "question"
28
+ KIND_APPROVAL = "approval"
29
+ # Lower than an answer's (AnswerRefs): cards.json holds up to 20 cards.
30
+ MAX_REFS = 50
31
+ MAX_BYTES = 32 * 1024
32
+
33
+ module_function
34
+
35
+ # Fire the event and return the question's fields with the step's refs
36
+ # (+fields+ as given when it has none, or with no subscriber: then
37
+ # nothing is built).
38
+ # @param hooks [Hooks::Registry]
39
+ # @param conversation [Array<Hash>, nil] the running turn's live
40
+ # conversation (Engine#live_conversation)
41
+ # @param kind [String] KIND_QUESTION or KIND_APPROVAL
42
+ # @param fields [Hash] the question's fields (no id yet)
43
+ # @param cwd [String, nil] the session's folder, for file refs
44
+ # @param synchronize [#call, nil] runs the step's change under the
45
+ # event lock (a snapshot reads the conversation under it)
46
+ # @param visible [#call, nil] (content) → the step's text as the asking
47
+ # slot shows it; default OutputFormatter.strip_markup (the Engine's
48
+ # drops the native loop's tool-call bodies first)
49
+ # @return [Hash] the fields to open the question with
50
+ def fire(hooks:, conversation:, kind:, fields:, cwd:, synchronize: nil, visible: nil)
51
+ return fields unless hooks&.any?(EVENT)
52
+
53
+ step = Array(conversation).reverse_each.find { |m| m.is_a?(Hash) && (m[:role] || m["role"]).to_s == "model" }
54
+ refs = AnswerRefs.new(step ? [step] : [], cwd: cwd, max_refs: MAX_REFS, max_bytes: MAX_BYTES)
55
+ visible ||= ->(content) { OutputFormatter.strip_markup(content) }
56
+ event = { type: EVENT, kind: kind, text: step ? visible.call((step[:content] || step["content"]).to_s) : "",
57
+ question: deep_freeze(Marshal.load(Marshal.dump(fields.to_h))),
58
+ # A question from here would wait inside the one opening.
59
+ ask_user: ->(**) {} }
60
+ event[:refs] = refs.adder(event)
61
+ hooks.fire(EVENT, event)
62
+ refs.close!
63
+ list = refs.to_a
64
+ if refs.added?
65
+ store = -> { step[AnswerRefs::KEY] = list }
66
+ synchronize ? synchronize.call(&store) : store.call
67
+ end
68
+ # The step's whole list: a second question in it gets the first's too.
69
+ list.empty? ? fields : fields.merge(refs: list)
70
+ rescue StandardError => e
71
+ Log.warn(:hooks, "question_asked_failed", error: e.class.name, msg: e.message.to_s[0, 200])
72
+ fields
73
+ end
74
+
75
+ def deep_freeze(value)
76
+ case value
77
+ when Hash then value.each_value { |v| deep_freeze(v) }
78
+ when Array then value.each { |v| deep_freeze(v) }
79
+ end
80
+ value.freeze
81
+ end
82
+ end
83
+ end
@@ -120,10 +120,11 @@ module Samagotchi
120
120
  # question_handler, called on the turn thread with the payload
121
121
  # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
122
122
  # returns the answer as JSON for the tool result.
123
- # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
123
+ # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:},
124
+ # and refs: (the asking step's, QuestionAsked) when it has any
124
125
  # @return [String] normalized answer JSON
125
126
  def request(payload)
126
- result = open_question(payload.slice(:question, :options, :header)
127
+ result = open_question(payload.slice(:question, :options, :header, :refs)
127
128
  .merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
128
129
  # Dismissed (the card's dismiss, Esc): an answer of its own, not a
129
130
  # tool failure the model learns to avoid the tool from.
@@ -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