samagotchi 0.121.1 → 0.123.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -1
  3. data/bin/chi +4 -1
  4. data/docs/architecture.md +19 -7
  5. data/docs/cli.md +32 -1
  6. data/docs/configuration.md +55 -5
  7. data/docs/guardrails.md +119 -7
  8. data/docs/internals/engine.md +11 -2
  9. data/docs/internals/participants.md +76 -20
  10. data/docs/internals/prompt-caching.md +19 -7
  11. data/docs/internals/web-push.md +7 -3
  12. data/docs/memory.md +33 -0
  13. data/docs/plugins.md +3 -2
  14. data/docs/sessions.md +14 -4
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  16. data/lib/samagotchi/bridge.rb +8 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
  18. data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  21. data/lib/samagotchi/config.rb +11 -0
  22. data/lib/samagotchi/delivery.rb +7 -0
  23. data/lib/samagotchi/engine.rb +289 -12
  24. data/lib/samagotchi/event_fanout.rb +0 -1
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +46 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/log_line.rb +1 -1
  40. data/lib/samagotchi/memory_orphans.rb +11 -0
  41. data/lib/samagotchi/participant_mode.rb +85 -0
  42. data/lib/samagotchi/participants.rb +7 -5
  43. data/lib/samagotchi/plugin/api.rb +7 -4
  44. data/lib/samagotchi/process_group.rb +13 -0
  45. data/lib/samagotchi/review_apply.rb +10 -2
  46. data/lib/samagotchi/review_start.rb +42 -10
  47. data/lib/samagotchi/run_options.rb +1 -0
  48. data/lib/samagotchi/session.rb +14 -1
  49. data/lib/samagotchi/session_access/contract.rb +1 -1
  50. data/lib/samagotchi/session_access/local.rb +5 -2
  51. data/lib/samagotchi/session_access/remote.rb +32 -12
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
  53. data/lib/samagotchi/session_manager.rb +21 -7
  54. data/lib/samagotchi/session_participants.rb +18 -3
  55. data/lib/samagotchi/session_setup.rb +8 -4
  56. data/lib/samagotchi/sessions_command.rb +1 -1
  57. data/lib/samagotchi/share_command.rb +2 -2
  58. data/lib/samagotchi/steer.rb +13 -6
  59. data/lib/samagotchi/system_one/client.rb +16 -7
  60. data/lib/samagotchi/system_one/log.rb +25 -5
  61. data/lib/samagotchi/system_prompt.rb +20 -4
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  63. data/lib/samagotchi/terminal_ui.rb +2 -1
  64. data/lib/samagotchi/tool_runner.rb +11 -1
  65. data/lib/samagotchi/toolkit.rb +27 -8
  66. data/lib/samagotchi/toolkit_binding.rb +149 -7
  67. data/lib/samagotchi/toolkit_command.rb +15 -3
  68. data/lib/samagotchi/toolkit_report_command.rb +320 -0
  69. data/lib/samagotchi/toolkit_suggest.rb +377 -0
  70. data/lib/samagotchi/tools/builtins.rb +14 -1
  71. data/lib/samagotchi/tools/memory.rb +1 -1
  72. data/lib/samagotchi/update_command.rb +7 -6
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/web/app.rb +12 -13
  75. data/lib/samagotchi/web/grants.rb +17 -5
  76. data/lib/samagotchi/web/message_display.rb +6 -1
  77. data/lib/samagotchi/web/participant_access.rb +4 -1
  78. data/lib/samagotchi/web/participant_review.rb +8 -4
  79. data/lib/samagotchi/web/participant_view.rb +12 -4
  80. data/lib/samagotchi/web/participants_admin.rb +10 -6
  81. data/lib/samagotchi/web/principal.rb +3 -4
  82. data/lib/samagotchi/web/public/app.js +34 -10
  83. data/lib/samagotchi/web/public/bubbles.js +3 -2
  84. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  85. data/lib/samagotchi/web/public/participant.html +1 -1
  86. data/lib/samagotchi/web/public/participant.js +12 -13
  87. data/lib/samagotchi/web/public/participant_model.js +20 -8
  88. data/lib/samagotchi/web/public/participant_names.js +29 -8
  89. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  90. data/lib/samagotchi/web/public/question_card.js +12 -2
  91. data/lib/samagotchi/web/public/question_cards.js +7 -0
  92. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  93. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  94. data/lib/samagotchi/web/public/turn_events.js +7 -5
  95. data/lib/samagotchi/web/public/turn_html.js +2 -2
  96. data/lib/samagotchi/web/public/turn_view.js +6 -4
  97. data/lib/samagotchi/web/public/update_notice.js +31 -2
  98. data/lib/samagotchi/web/push/notifier.rb +28 -1
  99. data/lib/samagotchi/web/server.rb +7 -3
  100. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  101. data/lib/samagotchi/web/session_control.rb +4 -2
  102. data/lib/samagotchi/web/session_hub.rb +24 -3
  103. data/lib/samagotchi/web/start_dir.rb +10 -5
  104. data/lib/samagotchi/web/update_check.rb +179 -0
  105. data/lib/samagotchi/worker.rb +60 -8
  106. data/lib/samagotchi/worker_link.rb +1 -0
  107. metadata +8 -1
@@ -188,12 +188,15 @@ module Samagotchi
188
188
  # system prompt, verbatim (stripped); nil inherits the base's, "" clears
189
189
  # it. +groups+: :all or a sorted list of source group names/globs; nil
190
190
  # inherits the base's.
191
- Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt, :groups) do
192
- def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil, groups: nil)
191
+ # +description+: optional classifier text; it is not prompt text.
192
+ Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt, :groups, :description) do
193
+ def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil, groups: nil,
194
+ description: nil)
193
195
  super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
194
196
  add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s,
195
197
  prompt: prompt.is_a?(String) ? prompt.strip.freeze : nil,
196
- groups: groups.nil? ? nil : Toolkit.group_names(groups))
198
+ groups: groups.nil? ? nil : Toolkit.group_names(groups),
199
+ description: description.is_a?(String) ? description.strip.freeze : nil)
197
200
  end
198
201
  end
199
202
 
@@ -210,6 +213,17 @@ module Samagotchi
210
213
  "prompt is over #{PROMPT_MAX} characters" if value.strip.length > PROMPT_MAX
211
214
  end
212
215
 
216
+ DESCRIPTION_MAX = 300
217
+
218
+ # Why +value+ can't describe a kit (text, at most DESCRIPTION_MAX
219
+ # characters once stripped), nil when it can (nil too: no description).
220
+ def self.description_error(value)
221
+ return nil if value.nil?
222
+ return "description must be text" unless value.is_a?(String)
223
+
224
+ "description is over #{DESCRIPTION_MAX} characters" if value.strip.length > DESCRIPTION_MAX
225
+ end
226
+
213
227
  # The built-ins `lean` declares: the file and shell tools, memories,
214
228
  # the task tools (execute's hand-over, turn notes and task_wait's tail
215
229
  # name the trio), questions, web_fetch, and context_read (core notes
@@ -354,13 +368,13 @@ module Samagotchi
354
368
  "name your kit something else"
355
369
 
356
370
  # The keys a config kit entry takes.
357
- KIT_KEYS = %w[base add remove prompt groups].freeze
371
+ KIT_KEYS = %w[base add remove prompt groups description].freeze
358
372
 
359
373
  # The kits known: the built-ins, then config's toolkit.kits, then the
360
374
  # plugins' (chi.toolkit). A config kit with a built-in's name is left
361
375
  # out (one it can't redefine; review, the participants' boundary, is
362
376
  # reserved) with a line in +errors+, and so is one
363
- # that isn't a {base:, add:, remove:, prompt:, groups:} map. One with another key
377
+ # that isn't a {base:, add:, remove:, prompt:, groups:, description:} map. One with another key
364
378
  # (a typo, a tools: list) still loads, with a line naming the key, and
365
379
  # so does one whose prompt isn't text or is over PROMPT_MAX characters:
366
380
  # without the prompt, with a line saying why. One whose groups: isn't
@@ -380,23 +394,28 @@ module Samagotchi
380
394
  elsif kits.key?(name)
381
395
  errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
382
396
  elsif !spec.is_a?(Hash)
383
- errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:, groups:}"
397
+ errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:, groups:, description:}"
384
398
  else
385
399
  spec = spec.transform_keys(&:to_s)
386
400
  (spec.keys - KIT_KEYS).each do |key|
387
401
  errors << "toolkit.kits.#{name}: unknown key #{key} " \
388
- "(a kit takes base:, add:, remove:, prompt:, groups:)"
402
+ "(a kit takes base:, add:, remove:, prompt:, groups:, description:)"
389
403
  end
390
404
  if (problem = prompt_error(spec["prompt"]))
391
405
  errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
392
406
  spec.delete("prompt")
393
407
  end
408
+ if (problem = description_error(spec["description"]))
409
+ errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
410
+ spec.delete("description")
411
+ end
394
412
  if (problem = config_groups_error(name, spec["groups"]))
395
413
  errors << "#{problem}; the kit reaches no tool-index group until it is fixed"
396
414
  spec["groups"] = []
397
415
  end
398
416
  kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
399
- prompt: spec["prompt"], groups: spec["groups"], source: "config")
417
+ prompt: spec["prompt"], groups: spec["groups"], source: "config",
418
+ description: spec["description"])
400
419
  end
401
420
  end
402
421
  plugins.each { |kit| kits[kit.name] ||= kit }
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "log"
4
+ require_relative "memory_paths"
5
+ require_relative "model_overlay"
6
+ require_relative "muted_memories"
4
7
  require_relative "toolkit"
8
+ require "yaml"
5
9
 
6
10
  module Samagotchi
7
11
  # An Engine's session toolkit: the kits it resolves against, what the
@@ -39,15 +43,26 @@ module Samagotchi
39
43
  # @return [Array<Toolkit::Kit>] the plugins' kits (Plugin::Registries' toolkits:)
40
44
  attr_reader :plugin_kits
41
45
 
42
- # The kits a session's toolkit resolves against: the built-ins, config's
43
- # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
44
- # process, like the plugins': an edit to toolkit.kits never changes a
45
- # live session's tools mid-session (a new worker reads it).
46
- # @param errors [Array<String>] gets a line per config kit left out
46
+ # The kits a session's toolkit resolves against: built-ins, config,
47
+ # plugins, then project and system memory kits. Config and memory kits
48
+ # are read once per worker, like the plugins': edits take effect when a
49
+ # new worker reads them.
50
+ # @param errors [Array<String>] gets a line per invalid or skipped kit
47
51
  # @return [Hash{String => Toolkit::Kit}]
48
52
  def kits(errors = [])
49
53
  @config_kits = Toolkit.config_kits unless defined?(@config_kits)
50
- Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
54
+ memory_kits, memory_errors = memory_kits_for(@session.call)
55
+ errors.concat(memory_errors)
56
+ kits = Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
57
+ memory_kits.each do |kit|
58
+ if kits.key?(kit.name)
59
+ errors << "#{MEMORY_KIT_PREFIX}#{kit.name}.md (#{kit.source}): " \
60
+ "toolkit name is already defined; skipping this memory kit"
61
+ else
62
+ kits[kit.name] = kit
63
+ end
64
+ end
65
+ kits
51
66
  end
52
67
 
53
68
  # The session's toolkit resolved (Toolkit::Resolved): what its next
@@ -98,14 +113,55 @@ module Samagotchi
98
113
  return false if session.nil? || !@can_stage.call
99
114
 
100
115
  toolkit = Toolkit.new(name: name)
101
- return true if session.toolkit == toolkit
116
+ if session.toolkit == toolkit
117
+ if session.toolkit_origin != "chosen"
118
+ session.toolkit_origin = "chosen"
119
+ session.save(state_dir: @state_dir.call)
120
+ end
121
+ return true
122
+ end
102
123
 
103
124
  session.toolkit = toolkit
125
+ session.toolkit_origin = "chosen"
104
126
  session.save(state_dir: @state_dir.call)
105
127
  @notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
106
128
  true
107
129
  end
108
130
 
131
+ # On mode's suggestion (Engine#settle_toolkit_suggestion), on the turn
132
+ # thread before #apply!: put +session+ on kit +name+ with origin
133
+ # suggested, saved, and tell the user through #use's notice path. Not
134
+ # when the session's toolkit isn't the default any more (a /toolkit or
135
+ # ctx.use_toolkit while it was asked) or the kit is gone.
136
+ # +deferred+: the session already has a model reply, so the switch
137
+ # applies from this turn (and re-reads the whole request).
138
+ # @return [Boolean] whether the session switched
139
+ # @raise the save's error, with the session's toolkit and origin as they were
140
+ def suggest(session, name, confidence:, deferred:)
141
+ return false if session.nil? || session.toolkit_origin != "default" || !kits.key?(name)
142
+
143
+ before = [session.toolkit, session.toolkit_origin]
144
+ session.toolkit = Toolkit.new(name: name)
145
+ session.toolkit_origin = "suggested"
146
+ begin
147
+ session.save(state_dir: @state_dir.call)
148
+ rescue StandardError
149
+ # Not saved, not switched: the turn runs the default it has on disk.
150
+ session.toolkit, session.toolkit_origin = before
151
+ raise
152
+ end
153
+ @notify.call(self.class.suggestion_notice(name, confidence, deferred: deferred), :info, "toolkit",
154
+ extra: { toolkit: explained(session.toolkit).summary })
155
+ true
156
+ end
157
+
158
+ # "toolkit: smoke (suggested, p=0.71) · /toolkit to change"; a
159
+ # deferred switch says "from this turn".
160
+ def self.suggestion_notice(name, confidence, deferred:)
161
+ when_text = deferred ? " from this turn" : ""
162
+ "toolkit: #{name}#{when_text} (suggested, p=#{format("%.2f", confidence.to_f)}) · /toolkit to change"
163
+ end
164
+
109
165
  # Resolve the session's toolkit into the Registry, on the turn thread at
110
166
  # a turn's start (before its prompt is built), and build the prompts
111
167
  # again when what it declares (or its kit's prompt text) changed. A
@@ -143,6 +199,92 @@ module Samagotchi
143
199
 
144
200
  private
145
201
 
202
+ MEMORY_KIT_PREFIX = "toolkit_"
203
+ MEMORY_KIT_KEYS = %w[base add remove groups description].freeze
204
+ MEMORY_KIT_BLOCK = /\A[ \t]*```yaml[ \t]*\r?\n(.*?)\r?\n```[ \t]*\r?\n?\z/m
205
+
206
+ # Read once per (folder, muted names): a call before the session exists
207
+ # (no project folder yet) never hides the project's kits afterwards.
208
+ def memory_kits_for(session)
209
+ key = [session&.working_directory, MutedMemories.normalize_list(session&.muted_memory_names)]
210
+ @memory_kits = { key => load_memory_kits(*key) } unless @memory_kits&.key?(key)
211
+ @memory_kits[key]
212
+ end
213
+
214
+ def load_memory_kits(cwd, muted)
215
+ candidates = []
216
+ errors = []
217
+ scopes = []
218
+ scopes << ["project", MemoryPaths.scope_dir(:project, cwd: cwd)] if cwd
219
+ scopes << ["system", MemoryPaths.scope_dir(:system, cwd: cwd)]
220
+ scopes.each do |scope, dir|
221
+ next unless dir && File.directory?(dir)
222
+
223
+ Dir.glob(File.join(dir, "#{MEMORY_KIT_PREFIX}*.md")).sort.each do |path|
224
+ next if ModelOverlay.overlay_file?(path)
225
+
226
+ memory_name = File.basename(path, ".md")
227
+ next if MutedMemories.muted?(memory_name, muted)
228
+
229
+ kit = read_memory_kit(path, scope, errors)
230
+ candidates << kit if kit
231
+ end
232
+ end
233
+ [candidates, errors]
234
+ end
235
+
236
+ def read_memory_kit(path, scope, errors)
237
+ memory_name = File.basename(path, ".md")
238
+ name = memory_name.delete_prefix(MEMORY_KIT_PREFIX)
239
+ return memory_kit_error(errors, memory_name, scope, "toolkit name is empty") if name.empty?
240
+
241
+ body = File.read(path, encoding: "UTF-8")
242
+ match = body.match(MEMORY_KIT_BLOCK)
243
+ return memory_kit_error(errors, memory_name, scope, "expected one fenced yaml block") unless match
244
+
245
+ spec = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
246
+ return memory_kit_error(errors, memory_name, scope, "yaml block must be a map") unless spec.is_a?(Hash)
247
+
248
+ spec = spec.transform_keys(&:to_s)
249
+ if spec.key?("prompt")
250
+ return memory_kit_error(errors, memory_name, scope, "prompt: is not supported in toolkit memories")
251
+ end
252
+
253
+ unknown = spec.keys - MEMORY_KIT_KEYS
254
+ if unknown.any?
255
+ return memory_kit_error(errors, memory_name, scope, "unknown key #{unknown.first}")
256
+ end
257
+
258
+ description = spec["description"]
259
+ unless spec.key?("description") && !description.nil?
260
+ return memory_kit_error(errors, memory_name, scope, "description is required")
261
+ end
262
+ if (problem = Toolkit.description_error(description))
263
+ return memory_kit_error(errors, memory_name, scope, problem)
264
+ end
265
+ return memory_kit_error(errors, memory_name, scope, "description is required") if description.strip.empty?
266
+ if !spec["base"].nil? && (!spec["base"].is_a?(String) || spec["base"].strip.empty?)
267
+ return memory_kit_error(errors, memory_name, scope, "base must be a toolkit name")
268
+ end
269
+ if %w[add remove].any? { |key| spec.key?(key) && !(spec[key].is_a?(Array) && spec[key].all?(String)) }
270
+ return memory_kit_error(errors, memory_name, scope, "add and remove must be lists of tool names")
271
+ end
272
+ if (problem = Toolkit.groups_error(spec["groups"]))
273
+ return memory_kit_error(errors, memory_name, scope, problem)
274
+ end
275
+
276
+ Toolkit::Kit.new(name: name, base: spec["base"] || Toolkit::FULL, add: spec.fetch("add", []),
277
+ remove: spec.fetch("remove", []), groups: spec["groups"], description: description,
278
+ source: "memory:#{scope}")
279
+ rescue Psych::Exception, SystemCallError, EncodingError => e
280
+ memory_kit_error(errors, memory_name, scope, e.message.lines.first.to_s.strip)
281
+ end
282
+
283
+ def memory_kit_error(errors, memory_name, scope, message)
284
+ errors << "#{memory_name} (memory:#{scope}): #{message}"
285
+ nil
286
+ end
287
+
146
288
  # The toolkit's load warnings, each once per process: a name that
147
289
  # didn't resolve, config kits left out.
148
290
  def warn_unresolved(session, resolved, errors, on_event)
@@ -43,8 +43,15 @@ module Samagotchi
43
43
  session = @engine.session or raise ArgumentError, "no session yet"
44
44
  before = @engine.toolkit_explained
45
45
  after = @engine.toolkit_explained(changed(before.toolkit, words.split))
46
- # Nothing the prompt carries changes (full +delegate on full): not saved.
47
- return [listing(before) + "\n(no change to what the next turn declares)", false, nil] if after.prompt_key == before.prompt_key
46
+ # Nothing the prompt carries changes (full +delegate on full): only
47
+ # the origin is saved, so a no-op choice still counts as user-selected.
48
+ if after.prompt_key == before.prompt_key
49
+ if session.toolkit_origin != "chosen"
50
+ session.toolkit_origin = "chosen"
51
+ @save.call(session)
52
+ end
53
+ return [listing(before) + "\n(no change to what the next turn declares)", false, nil]
54
+ end
48
55
 
49
56
  cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
50
57
  if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
@@ -53,6 +60,7 @@ module Samagotchi
53
60
  end
54
61
 
55
62
  session.toolkit = after.toolkit
63
+ session.toolkit_origin = "chosen"
56
64
  @save.call(session)
57
65
  [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
66
  end
@@ -88,7 +96,11 @@ module Samagotchi
88
96
  lines << " reachable (#{route}): #{explained.reachable.join(", ")}"
89
97
  lines << " removed: #{explained.removed.empty? ? "none" : explained.removed.join(", ")}"
90
98
  end
91
- lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
99
+ choices = @engine.toolkit_kits.map do |name, kit|
100
+ source = kit.source.start_with?("memory:") ? " (#{kit.source})" : ""
101
+ "#{name}#{source}"
102
+ end
103
+ lines << " toolkits: #{choices.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
92
104
  lines.join("\n")
93
105
  end
94
106
 
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "cli/command"
7
+ require_relative "cli/exit"
8
+ require_relative "cli/flags"
9
+ require_relative "config"
10
+ require_relative "session"
11
+ require_relative "system_one/log"
12
+ require_relative "toolkit"
13
+ require_relative "tool_response"
14
+ require_relative "toolkit_suggest"
15
+
16
+ module Samagotchi
17
+ # `chi toolkit report`: compare the toolkit on mode would have run (the
18
+ # suggestion when it would have been applied, else the default) with the
19
+ # tools the session called from that turn on, against every kit's rules
20
+ # the ask logged (ToolkitSuggest.rules).
21
+ class ToolkitReportCommand
22
+ include CLI::Command
23
+
24
+ USAGE = <<~TEXT
25
+ Usage: chi toolkit report [--days N]
26
+ Grade the kit each audit suggestion would have run (the suggested
27
+ kit when on mode would have applied it, else the default) against
28
+ the tools the session called from that turn on, call_tool targets
29
+ included: covers, short of a tool (named), wider than needed (a
30
+ narrower offered kit would also cover), or ungradable. Then a
31
+ per-kit tally with what on mode would have done. Includes archived
32
+ sessions; the report is limited to 14 days and 500 sessions.
33
+ TEXT
34
+
35
+ MAX_DAYS = 14
36
+ MAX_SESSIONS = 500
37
+ FLAGS = CLI::Flags.new(args: false) { |flags| flags.value "--days" }.freeze
38
+ ToolUse = Data.define(:name, :group, :label)
39
+
40
+ # @param argv [Array<String>] arguments after "toolkit"
41
+ # @param sessions_dir [String, nil] sessions location (a spec seam)
42
+ # @param log [SystemOne::Log, nil] suggestion log (a spec seam)
43
+ # @param now [Time] the report's clock (a spec seam)
44
+ def initialize(argv, stdout: $stdout, stderr: $stderr, sessions_dir: nil, log: nil, now: Time.now)
45
+ @argv = argv.dup
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @sessions_dir = sessions_dir || Session.default_state_dir
49
+ @log = log
50
+ @now = now
51
+ end
52
+
53
+ def run
54
+ @sub = @argv.first
55
+ if @sub.nil? || HELP_WORDS.include?(@sub)
56
+ @stdout.puts(USAGE)
57
+ return CLI::Exit::OK
58
+ end
59
+ return usage_error("unknown subcommand #{@sub}") unless @sub == "report"
60
+
61
+ parsed = parse_flags(FLAGS, @argv[1..])
62
+ return parsed if parsed.is_a?(Integer)
63
+ return usage_error("--days takes a positive whole number") if parsed.options[:days] &&
64
+ !parsed.options[:days].to_s.match?(/\A[1-9]\d*\z/)
65
+
66
+ report(parsed.options[:days] ? Integer(parsed.options[:days], 10) : MAX_DAYS)
67
+ end
68
+
69
+ private
70
+
71
+ def command_name = "chi toolkit report"
72
+
73
+ def report(requested_days)
74
+ days = effective_days(requested_days)
75
+ cutoff = @now - (days * 86_400)
76
+ max_sessions = [MAX_SESSIONS, positive_config("session.max_count", MAX_SESSIONS)].min
77
+ sessions = Session.list(state_dir: @sessions_dir, sort: "updated_at", order: "desc",
78
+ limit: max_sessions, include_archived: true)
79
+ session_summaries = sessions.to_h { |session| [session.id, session] }
80
+ records = log.records.filter_map do |entry|
81
+ next unless entry["kind"] == "suggestion" && session_summaries.key?(entry["session_id"])
82
+ next unless timestamp(entry["at"])&.>= cutoff
83
+
84
+ entry
85
+ end
86
+ if records.empty?
87
+ @stdout.puts("No toolkit suggestions in the last #{days} days.")
88
+ return CLI::Exit::OK
89
+ end
90
+
91
+ loaded = {}
92
+ tallies = Hash.new { |hash, kit| hash[kit] = Hash.new(0) }
93
+ @stdout.puts("Toolkit suggestions · last #{days} days · up to #{max_sessions} sessions")
94
+ records.sort_by { |entry| timestamp(entry["at"]) }.each do |entry|
95
+ id = entry["session_id"]
96
+ session = loaded[id] ||= Session.load(id, state_dir: @sessions_dir)
97
+ uses, window = window_uses(session, entry["turn_number"].to_i)
98
+ kit = graded_kit(entry)
99
+ grade, detail = grade(entry, kit, uses)
100
+ tallies[kit][grade] += 1
101
+ tallies[kit]["would:#{entry["would"] || "unknown"}"] += 1
102
+ confidence = entry["confidence"].nil? ? "n/a" : format("%.2f", entry["confidence"])
103
+ @stdout.puts("#{timestamp(entry["at"]).iso8601} #{id[0, 8]} turn #{entry["turn_number"]}: " \
104
+ "#{entry["raw"] || "no judgment"} (p=#{confidence}, would #{entry["would"] || "unknown"}" \
105
+ "#{", did #{entry["did"]}" if entry["did"]}) " \
106
+ "→ #{kit}: #{grade}#{detail}#{window}")
107
+ labels = uses.is_a?(Array) ? uses.map(&:label).uniq : []
108
+ @stdout.puts(" tools: #{labels.join(", ")}") unless labels.empty?
109
+ rescue StandardError => e
110
+ @stderr.puts("chi toolkit report: session #{id}: #{e.class}: #{e.message}; skipped")
111
+ end
112
+ print_tallies(tallies)
113
+ CLI::Exit::OK
114
+ end
115
+
116
+ def log
117
+ @log ||= SystemOne::Log.new(
118
+ dir: File.dirname(ToolkitSuggest.log_path(@sessions_dir)),
119
+ file: ToolkitSuggest::FILE,
120
+ warn: ->(reason) { @stderr.puts("chi toolkit report: #{reason}") }
121
+ )
122
+ end
123
+
124
+ def effective_days(requested)
125
+ retention = Config.get("session.retention_days").to_i
126
+ cap = retention.positive? ? [retention, MAX_DAYS].min : MAX_DAYS
127
+ [requested, cap].min
128
+ end
129
+
130
+ def positive_config(key, fallback)
131
+ value = Config.get(key).to_i
132
+ value.positive? ? value : fallback
133
+ end
134
+
135
+ def timestamp(value)
136
+ Time.iso8601(value.to_s)
137
+ rescue ArgumentError
138
+ nil
139
+ end
140
+
141
+ # The tools the session called from the suggestion's turn on: to the
142
+ # session's end, or only that turn when the toolkit was chosen since
143
+ # (/toolkit, ctx.use_toolkit: no message marks when, and a change
144
+ # applies from the next turn's start, so the asked turn ran on it).
145
+ # @return [Array(Array<ToolUse>|Symbol, String)] the uses (:ungradable
146
+ # when a turn's tools can't be read back) and a note on the window
147
+ def window_uses(session, turn_number)
148
+ by_turn = tool_uses_by_turn(session.messages)
149
+ changed = session.toolkit_origin == "chosen"
150
+ turns = by_turn.keys.select { |number| changed ? number == turn_number : number >= turn_number }
151
+ uses = turns.sort.flat_map { |number| by_turn[number] }
152
+ note = changed ? " (its turn only: the toolkit was chosen later)" : ""
153
+ [turns.empty? || uses.include?(:ungradable) ? :ungradable : uses, note]
154
+ end
155
+
156
+ # Tool uses grouped by the user's own prompt number; steers and merged
157
+ # input lines remain part of that turn rather than starting another.
158
+ # The chat loop saves its calls on the model's message (tool_calls);
159
+ # the raw-prompt loop saves only the model's text, so its calls are
160
+ # read back from the batch's tool_response entry (#joined_uses).
161
+ def tool_uses_by_turn(messages)
162
+ number = 0
163
+ current = nil
164
+ uses = Hash.new { |hash, key| hash[key] = [] }
165
+ Array(messages).each do |message|
166
+ case field(message, :role).to_s
167
+ when "user"
168
+ if ToolkitSuggest.owner_prompt?(message)
169
+ number += 1
170
+ current = number
171
+ uses[current]
172
+ elsif Steer.participant_author?(field(message, :author))
173
+ current = nil
174
+ end
175
+ when "model"
176
+ Array(field(message, :tool_calls)).each { |call| uses[current] << tool_use(call) } if current
177
+ when "tool_response"
178
+ uses[current].concat(joined_uses(message)) if current && !field(message, :tool_call_id)
179
+ end
180
+ end
181
+ uses.transform_values(&:compact)
182
+ end
183
+
184
+ # A ran-as line (ToolRunner, a corrected call) may precede a run's lead.
185
+ RUN_LEAD = /\A(?:ran as: [^\n]*\n)?\[([^\]\s]+)\](?:\n| |\z)/
186
+
187
+ # A raw-prompt batch's tools: each run's "[name]" lead (ToolResponse),
188
+ # a call_tool's target from its saved params line ("group/tool …").
189
+ # When the leads don't match the batch's run count, :ungradable.
190
+ def joined_uses(message)
191
+ parts = field(message, :content).to_s.split(ToolResponse::SEPARATOR, -1)
192
+ names = parts.filter_map { |part| part[RUN_LEAD, 1] }
193
+ ids = field(message, :tool_ids)
194
+ return [:ungradable] if names.empty? || (ids.is_a?(Array) && ids.size != names.size)
195
+
196
+ params = field(message, :tool_params)
197
+ names.each_with_index.map do |name, index|
198
+ next ToolUse.new(name: name, group: nil, label: name) unless name == "call_tool"
199
+
200
+ line = params.is_a?(Array) ? params[index] : nil
201
+ target = line.to_s.split(" ", 2).first.to_s
202
+ next :ungradable unless target.include?("/")
203
+
204
+ group, tool = target.split("/", 2)
205
+ ToolUse.new(name: tool, group: group, label: "call_tool→#{target}")
206
+ end
207
+ end
208
+
209
+ def tool_use(call)
210
+ return unless call.is_a?(Hash)
211
+
212
+ function = field(call, :function)
213
+ name = (field(call, :name) || field(function, :name)).to_s
214
+ arguments = field(call, :arguments) || field(function, :arguments) || field(call, :args)
215
+ arguments = JSON.parse(arguments) if arguments.is_a?(String) && !arguments.empty?
216
+ return ToolUse.new(name: name, group: nil, label: name) unless name == "call_tool"
217
+
218
+ target = arguments.is_a?(Hash) ? (field(arguments, :tool) || field(arguments, :name)) : nil
219
+ return :ungradable unless target
220
+
221
+ parts = target.to_s.split("/", 2)
222
+ group, tool = parts.size == 2 ? parts : [nil, parts.first]
223
+ ToolUse.new(name: tool, group: group, label: "call_tool→#{target}")
224
+ rescue JSON::ParserError
225
+ name == "call_tool" ? :ungradable : ToolUse.new(name: name, group: nil, label: name)
226
+ end
227
+
228
+ # The kit on mode would have run (audit), or ran (an on line's did):
229
+ # the suggestion only when it was (or would have been) applied, else
230
+ # the session's default.
231
+ def graded_kit(entry)
232
+ name = (entry["did"] || entry["would"]) == "suggested" ? entry["kit"] : entry["default_kit"]
233
+ name || "(unknown)"
234
+ end
235
+
236
+ # covers: the kit declares every tool the window used (pick ⊇ need);
237
+ # wider than needed: it covers, and so would a narrower logged kit;
238
+ # short of a tool: names what it leaves out. ungradable when the
239
+ # window's calls or the kit's rules can't be read.
240
+ def grade(entry, kit, uses)
241
+ kits = entry["kits"].is_a?(Hash) ? entry["kits"] : {}
242
+ rules = kits[kit]
243
+ core_tools = Array(entry["core_tools"])
244
+ return ["ungradable", " (no rules logged for #{kit})"] unless rules.is_a?(Hash)
245
+ return ["ungradable", " (tool calls not readable)"] if uses == :ungradable
246
+
247
+ missing = uses.reject { |use| covers?(rules, core_tools, use) }
248
+ return ["short of a tool", " (#{missing.map(&:name).uniq.join(", ")})"] unless missing.empty?
249
+
250
+ narrower = kits.filter_map do |name, other|
251
+ next if name == kit || !other.is_a?(Hash) || !narrower?(other, rules, core_tools)
252
+
253
+ name if uses.all? { |use| covers?(other, core_tools, use) }
254
+ end
255
+ return ["wider than needed", " (#{narrower.sort.join(", ")} would also cover)"] unless narrower.empty?
256
+
257
+ ["covers", ""]
258
+ end
259
+
260
+ # Whether a kit's rules declare +use+ (Toolkit::Resolved#declares?): a
261
+ # tool-index group by the kit's groups; a built-in by its core list;
262
+ # any other tool (a plugin's) unless the kit removes it.
263
+ def covers?(rules, core_tools, use)
264
+ return group_reached?(rules["groups"], use.group) if use.group && use.group != "chi"
265
+
266
+ removed = Array(rules["removed"])
267
+ return false if removed.include?(use.name)
268
+ return true unless core_tools.include?(use.name)
269
+
270
+ rules["all_core"] || Array(rules["core"]).include?(use.name) || Array(rules["added"]).include?(use.name)
271
+ end
272
+
273
+ def core_set(rules, core_tools)
274
+ declared = rules["all_core"] ? core_tools : Array(rules["core"]) + Array(rules["added"])
275
+ (declared & core_tools) - Array(rules["removed"])
276
+ end
277
+
278
+ # +other+ declares no more built-ins and reaches no more groups than
279
+ # +rules+, and less of one of them.
280
+ def narrower?(other, rules, core_tools)
281
+ mine = core_set(rules, core_tools)
282
+ theirs = core_set(other, core_tools)
283
+ return false unless (theirs - mine).empty?
284
+
285
+ groups_mine = rules["groups"]
286
+ groups_theirs = other["groups"]
287
+ groups_within = groups_mine == "all" || (groups_theirs != "all" && (Array(groups_theirs) - Array(groups_mine)).empty?)
288
+ return false unless groups_within
289
+
290
+ theirs.size < mine.size || (groups_mine == "all" && groups_theirs != "all") ||
291
+ (groups_theirs != "all" && Array(groups_theirs).size < Array(groups_mine).size)
292
+ end
293
+
294
+ def group_reached?(groups, group)
295
+ return true if ["all", :all].include?(groups)
296
+
297
+ Array(groups).any? { |selector| Toolkit.group_matches?([selector], group) }
298
+ end
299
+
300
+ GRADES = ["covers", "short of a tool", "wider than needed", "ungradable"].freeze
301
+
302
+ # Per graded kit: the grades, then what on mode would have done.
303
+ def print_tallies(tallies)
304
+ @stdout.puts("\nPer-kit")
305
+ @stdout.puts("kit | suggestions | #{GRADES.join(" | ")} | would")
306
+ tallies.sort.each do |kit, counts|
307
+ would = counts.select { |key, _| key.start_with?("would:") }.sort
308
+ .map { |key, count| "#{key.delete_prefix("would:")} #{count}" }
309
+ @stdout.puts("#{kit} | #{GRADES.sum { |grade| counts[grade] }} | " \
310
+ "#{GRADES.map { |grade| counts[grade] }.join(" | ")} | #{would.join(", ")}")
311
+ end
312
+ end
313
+
314
+ def field(hash, key)
315
+ return nil unless hash.is_a?(Hash)
316
+
317
+ hash.key?(key) ? hash[key] : hash[key.to_s]
318
+ end
319
+ end
320
+ end