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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +67 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +19 -7
- data/docs/cli.md +32 -1
- data/docs/configuration.md +55 -5
- data/docs/guardrails.md +119 -7
- data/docs/internals/engine.md +11 -2
- data/docs/internals/participants.md +76 -20
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +14 -4
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +8 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
- data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/engine.rb +289 -12
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +46 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +85 -0
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +42 -10
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +14 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_access/remote.rb +32 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
- data/lib/samagotchi/session_manager.rb +21 -7
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +13 -6
- data/lib/samagotchi/system_one/client.rb +16 -7
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +11 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +149 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +320 -0
- data/lib/samagotchi/toolkit_suggest.rb +377 -0
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/update_command.rb +7 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -13
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +8 -4
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +34 -10
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +12 -13
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +7 -5
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +31 -2
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +7 -3
- data/lib/samagotchi/web/session_access_routes.rb +5 -4
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_hub.rb +24 -3
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/web/update_check.rb +179 -0
- data/lib/samagotchi/worker.rb +60 -8
- data/lib/samagotchi/worker_link.rb +1 -0
- metadata +8 -1
data/lib/samagotchi/toolkit.rb
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
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:
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# @param errors [Array<String>] gets a line per
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
47
|
-
|
|
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
|
-
|
|
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
|