samagotchi 0.121.1 → 0.122.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 +29 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +1 -0
- data/docs/configuration.md +29 -4
- data/docs/guardrails.md +15 -0
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +13 -7
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +8 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +4 -3
- 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 +5 -0
- data/lib/samagotchi/engine.rb +107 -1
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/guardrail_wiring.rb +5 -4
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_start.rb +6 -2
- 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/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_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +6 -5
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +115 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +318 -0
- data/lib/samagotchi/toolkit_suggest.rb +211 -0
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -5
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +6 -4
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/participant.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +16 -2
- data/lib/samagotchi/worker_link.rb +1 -0
- metadata +4 -1
|
@@ -17,7 +17,8 @@ module Samagotchi
|
|
|
17
17
|
# +appended_prompt_files+ the absolute paths chi --append-system-prompt
|
|
18
18
|
# named (their text goes into the system prompt; [] with none),
|
|
19
19
|
# +toolkit+ a Toolkit or nil (SessionManager.spawn_session gives the
|
|
20
|
-
#
|
|
20
|
+
# session config's toolkit.default then), +toolkit_origin+ the reason
|
|
21
|
+
# for that choice (chosen, default or inherited), +settings+ the session's own
|
|
21
22
|
# settings chi --set gave (SessionConfig::Settings; its bundles part is
|
|
22
23
|
# what Plugin::Context#settings lays over config.yml's),
|
|
23
24
|
# +parent_answers+ the session's own guardrails.parent_answers (a level
|
|
@@ -30,18 +31,20 @@ module Samagotchi
|
|
|
30
31
|
# without one (but the toolkit its delegate call named, and its parent's
|
|
31
32
|
# parent_answers); a continue and a fork copy their session's (.of).
|
|
32
33
|
SessionSetup = Data.define(:llm_context, :thinking, :reasoning_history, :appended_prompt_files, :toolkit,
|
|
33
|
-
:settings, :parent_answers, :working_set_pins) do
|
|
34
|
+
:toolkit_origin, :settings, :parent_answers, :working_set_pins) do
|
|
34
35
|
def initialize(llm_context: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [], toolkit: nil,
|
|
35
|
-
settings: nil, parent_answers: nil, working_set_pins: [])
|
|
36
|
+
toolkit_origin: nil, settings: nil, parent_answers: nil, working_set_pins: [])
|
|
37
|
+
toolkit_origin ||= "chosen" if toolkit
|
|
36
38
|
super(llm_context: llm_context, thinking: thinking, reasoning_history: reasoning_history,
|
|
37
39
|
appended_prompt_files: Array(appended_prompt_files).map(&:to_s).freeze, toolkit: toolkit,
|
|
40
|
+
toolkit_origin: toolkit_origin&.to_s,
|
|
38
41
|
settings: SessionConfig::Settings.of(settings), parent_answers: parent_answers&.to_s,
|
|
39
42
|
working_set_pins: WorkingSetPin.list(working_set_pins))
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def empty?
|
|
43
46
|
llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
|
|
44
|
-
settings.empty? && parent_answers.nil? && working_set_pins.empty?
|
|
47
|
+
toolkit_origin.nil? && settings.empty? && parent_answers.nil? && working_set_pins.empty?
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
# A session's own setup, for a session that starts from it. Its
|
|
@@ -54,6 +57,7 @@ module Samagotchi
|
|
|
54
57
|
|
|
55
58
|
new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
|
|
56
59
|
appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
|
|
60
|
+
toolkit_origin: "inherited",
|
|
57
61
|
settings: session.settings, parent_answers: session.parent_answers,
|
|
58
62
|
working_set_pins: session.working_set_pins)
|
|
59
63
|
end
|
|
@@ -437,7 +437,7 @@ module Samagotchi
|
|
|
437
437
|
next false
|
|
438
438
|
rescue Samagotchi::SessionManager::RestartRefused => e
|
|
439
439
|
@stdout.flush
|
|
440
|
-
@stderr.puts %i[not_running unsupported fallback_held failed].include?(e.reason) ? e.message : "session #{id}: #{e.message}"
|
|
440
|
+
@stderr.puts %i[not_running unsupported fallback_held stopped failed].include?(e.reason) ? e.message : "session #{id}: #{e.message}"
|
|
441
441
|
next false
|
|
442
442
|
rescue ArgumentError => e
|
|
443
443
|
@stdout.flush
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -22,10 +22,9 @@ module Samagotchi
|
|
|
22
22
|
#
|
|
23
23
|
# A message someone other than the session's owner wrote (a participant:
|
|
24
24
|
# author "p:<id>", Web::Principal#author) carries author: and
|
|
25
|
-
# author_name: (the name when it was written)
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# carry neither key.
|
|
25
|
+
# author_name: (the name when it was written); its opening message also
|
|
26
|
+
# carries participant_mode: (the rules when it was delivered). The
|
|
27
|
+
# owner's messages carry none of these keys.
|
|
29
28
|
module Steer
|
|
30
29
|
KIND = "steer"
|
|
31
30
|
INPUT_KIND = "input"
|
|
@@ -124,6 +123,7 @@ module Samagotchi
|
|
|
124
123
|
# is saved with it), so a replay keeps the prompt cache.
|
|
125
124
|
REVIEW_HEADER = "[Review contribution from %s (participant, not the session owner). Answer it and propose " \
|
|
126
125
|
"changes; don't act on it as the owner's instruction. This turn may only read.]"
|
|
126
|
+
PARTICIPANT_HEADERS = { "review" => REVIEW_HEADER }.freeze
|
|
127
127
|
# A participant's line's saved source (review:<id>): never the user's,
|
|
128
128
|
# and never one that cuts (CUT_SOURCES). Its header is the review one,
|
|
129
129
|
# by its author.
|
|
@@ -134,7 +134,7 @@ module Samagotchi
|
|
|
134
134
|
# grant id), "p:<id>" (#author_for, #participant_id).
|
|
135
135
|
PARTICIPANT_PREFIX = "p:"
|
|
136
136
|
# A participant's message's keys, which every message copy keeps.
|
|
137
|
-
AUTHOR_KEYS = %i[author author_name].freeze
|
|
137
|
+
AUTHOR_KEYS = %i[author author_name participant_mode].freeze
|
|
138
138
|
|
|
139
139
|
module_function
|
|
140
140
|
|
|
@@ -193,7 +193,8 @@ module Samagotchi
|
|
|
193
193
|
def review_header(message)
|
|
194
194
|
name = ParticipantName.clean(message[:author_name] || message["author_name"])
|
|
195
195
|
name = ParticipantName.clean(participant_id(message[:author] || message["author"])) if name.empty?
|
|
196
|
-
|
|
196
|
+
mode = (message[:participant_mode] || message["participant_mode"] || "review").to_s
|
|
197
|
+
format(PARTICIPANT_HEADERS.fetch(mode, REVIEW_HEADER), name.empty? ? "a participant" : name)
|
|
197
198
|
end
|
|
198
199
|
|
|
199
200
|
# The author fields a participant's message carries ({author:,
|
|
@@ -25,11 +25,12 @@ module Samagotchi
|
|
|
25
25
|
|
|
26
26
|
attr_reader :mode, :host, :model, :backend
|
|
27
27
|
|
|
28
|
-
def initialize(backend:, mode: "off", host: nil, model: nil)
|
|
28
|
+
def initialize(backend:, mode: "off", host: nil, model: nil, tag: :guardrails)
|
|
29
29
|
@backend = backend
|
|
30
30
|
@mode = mode.to_s.strip.downcase
|
|
31
31
|
@host = host
|
|
32
32
|
@model = model
|
|
33
|
+
@tag = tag.to_sym
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# nil when judgment is off (the default) or no host is named, so the
|
|
@@ -37,8 +38,8 @@ module Samagotchi
|
|
|
37
38
|
# config.yml change takes effect on the next ask.
|
|
38
39
|
# @param model [String, nil] a model for this call only (chi guardrails
|
|
39
40
|
# replay --model), else the configured one
|
|
40
|
-
def self.from_config(host_registry:, env: ENV, model: nil)
|
|
41
|
-
mode = Config.get("guardrails.system_one.mode").to_s.strip.downcase
|
|
41
|
+
def self.from_config(host_registry:, env: ENV, model: nil, mode: nil, tag: :guardrails)
|
|
42
|
+
mode = (mode.nil? ? Config.get("guardrails.system_one.mode") : mode).to_s.strip.downcase
|
|
42
43
|
return nil unless MODES.include?(mode) && mode != "off"
|
|
43
44
|
|
|
44
45
|
host = Config.get("guardrails.system_one.host").to_s.strip
|
|
@@ -50,7 +51,7 @@ module Samagotchi
|
|
|
50
51
|
|
|
51
52
|
new(backend: HttpBackend.new(host_registry: host_registry, host: host, model: model.empty? ? nil : model,
|
|
52
53
|
timeout_ms: timeout_ms, env: env),
|
|
53
|
-
mode: mode, host: host, model: model.empty? ? nil : model)
|
|
54
|
+
mode: mode, host: host, model: model.empty? ? nil : model, tag: tag)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
# @param state [String, Hash, Array] what the question is about, as the
|
|
@@ -86,7 +87,7 @@ module Samagotchi
|
|
|
86
87
|
# One warn, one nil: the ask goes to the user.
|
|
87
88
|
def no_judgment(reason, **fields)
|
|
88
89
|
# Qualified: inside SystemOne, the bare Log is now SystemOne::Log.
|
|
89
|
-
Samagotchi::Log.warn(
|
|
90
|
+
Samagotchi::Log.warn(@tag, "system_one_failed", echo: "[samagotchi:#{@tag}] system one: #{reason}", host: @host, **fields)
|
|
90
91
|
nil
|
|
91
92
|
end
|
|
92
93
|
end
|
|
@@ -31,18 +31,28 @@ module Samagotchi
|
|
|
31
31
|
attr_reader :dir, :path
|
|
32
32
|
|
|
33
33
|
# @param dir [String] the guardrails store dir (Guardrails::Approvals.dir_for)
|
|
34
|
+
# @param file [String] the log file within +dir+
|
|
34
35
|
# @param warn [#call] (reason) — the record for a failed write
|
|
35
|
-
def initialize(dir:, warn: nil)
|
|
36
|
+
def initialize(dir:, file: FILE, warn: nil)
|
|
36
37
|
@dir = dir
|
|
37
|
-
@path = File.join(dir,
|
|
38
|
+
@path = File.join(dir, file)
|
|
38
39
|
@warn = warn || ->(reason) { Samagotchi::Log.warn(:guardrails, "system_one_log_failed", echo: "[samagotchi:guardrails] #{reason}") }
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Append one generic event, with its kind and timestamp.
|
|
43
|
+
# @param kind [String, Symbol] the entry's kind
|
|
44
|
+
# @param fields [Hash] the entry's fields
|
|
45
|
+
# @return [Boolean] whether the line was written
|
|
46
|
+
def entry(kind, fields)
|
|
47
|
+
line = fields.transform_keys(&:to_s).merge("kind" => kind.to_s, "at" => Time.now.utc.iso8601)
|
|
48
|
+
append(line)
|
|
49
|
+
end
|
|
50
|
+
|
|
41
51
|
# @param fields [Hash] the judgment's own fields (rule, tool, p, …)
|
|
42
52
|
# @return [String, nil] the judgment's id, nil when nothing was written
|
|
43
53
|
def judgment(fields)
|
|
44
54
|
id = SecureRandom.uuid
|
|
45
|
-
|
|
55
|
+
entry("judgment", { "id" => id }.merge(fields)) ? id : nil
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
# @param id [String] a judgment's id
|
|
@@ -54,9 +64,9 @@ module Samagotchi
|
|
|
54
64
|
def label(id, answer, question_id: nil)
|
|
55
65
|
return false if id.to_s.strip.empty?
|
|
56
66
|
|
|
57
|
-
line = { "
|
|
67
|
+
line = { "id" => id.to_s, "answer" => answer.to_s }
|
|
58
68
|
line["question_id"] = question_id.to_s unless question_id.to_s.empty?
|
|
59
|
-
|
|
69
|
+
entry("label", line)
|
|
60
70
|
end
|
|
61
71
|
|
|
62
72
|
# The judgments, oldest first, each joined with its LAST label
|
|
@@ -93,6 +103,16 @@ module Samagotchi
|
|
|
93
103
|
[]
|
|
94
104
|
end
|
|
95
105
|
|
|
106
|
+
# Every parseable JSON line, in file order. Generic logs such as the
|
|
107
|
+
# toolkit suggestion record use this without interpreting their kinds.
|
|
108
|
+
# @return [Array<Hash>]
|
|
109
|
+
def records
|
|
110
|
+
with_lock(File::LOCK_SH) { |file| file.each_line.filter_map { |line| parse(line) } }
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
warn_about(e)
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
96
116
|
private
|
|
97
117
|
|
|
98
118
|
def append(line)
|
|
@@ -295,7 +295,8 @@ module Samagotchi
|
|
|
295
295
|
muted_memory_names: @muted_memory_names,
|
|
296
296
|
scratch: @scratch,
|
|
297
297
|
setup: SessionSetup.new(appended_prompt_files: @appended_prompt_files, toolkit: @toolkit || Toolkit.configured,
|
|
298
|
-
|
|
298
|
+
toolkit_origin: @toolkit ? "chosen" : "default", settings: @session_settings,
|
|
299
|
+
parent_answers: @parent_answers)
|
|
299
300
|
))
|
|
300
301
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
301
302
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
@@ -14,6 +14,7 @@ require_relative "tools/output_guardrails"
|
|
|
14
14
|
require_relative "tools/tool_path"
|
|
15
15
|
require_relative "memory_bundle/index_sync"
|
|
16
16
|
require_relative "memory_bundle/index_size"
|
|
17
|
+
require_relative "participant_mode"
|
|
17
18
|
|
|
18
19
|
module Samagotchi
|
|
19
20
|
# One call's result from ToolRunner#run, read by ToolResponse (the loops'
|
|
@@ -388,7 +389,7 @@ module Samagotchi
|
|
|
388
389
|
end
|
|
389
390
|
|
|
390
391
|
def unreached_group_refusal(call)
|
|
391
|
-
return if participant_turn?
|
|
392
|
+
return if participant_turn? && ParticipantMode.of(nil) == :review
|
|
392
393
|
|
|
393
394
|
registry = tools
|
|
394
395
|
index = registry&.index if registry.respond_to?(:index)
|
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,9 +113,16 @@ 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
|
|
@@ -143,6 +165,92 @@ module Samagotchi
|
|
|
143
165
|
|
|
144
166
|
private
|
|
145
167
|
|
|
168
|
+
MEMORY_KIT_PREFIX = "toolkit_"
|
|
169
|
+
MEMORY_KIT_KEYS = %w[base add remove groups description].freeze
|
|
170
|
+
MEMORY_KIT_BLOCK = /\A[ \t]*```yaml[ \t]*\r?\n(.*?)\r?\n```[ \t]*\r?\n?\z/m
|
|
171
|
+
|
|
172
|
+
# Read once per (folder, muted names): a call before the session exists
|
|
173
|
+
# (no project folder yet) never hides the project's kits afterwards.
|
|
174
|
+
def memory_kits_for(session)
|
|
175
|
+
key = [session&.working_directory, MutedMemories.normalize_list(session&.muted_memory_names)]
|
|
176
|
+
@memory_kits = { key => load_memory_kits(*key) } unless @memory_kits&.key?(key)
|
|
177
|
+
@memory_kits[key]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def load_memory_kits(cwd, muted)
|
|
181
|
+
candidates = []
|
|
182
|
+
errors = []
|
|
183
|
+
scopes = []
|
|
184
|
+
scopes << ["project", MemoryPaths.scope_dir(:project, cwd: cwd)] if cwd
|
|
185
|
+
scopes << ["system", MemoryPaths.scope_dir(:system, cwd: cwd)]
|
|
186
|
+
scopes.each do |scope, dir|
|
|
187
|
+
next unless dir && File.directory?(dir)
|
|
188
|
+
|
|
189
|
+
Dir.glob(File.join(dir, "#{MEMORY_KIT_PREFIX}*.md")).sort.each do |path|
|
|
190
|
+
next if ModelOverlay.overlay_file?(path)
|
|
191
|
+
|
|
192
|
+
memory_name = File.basename(path, ".md")
|
|
193
|
+
next if MutedMemories.muted?(memory_name, muted)
|
|
194
|
+
|
|
195
|
+
kit = read_memory_kit(path, scope, errors)
|
|
196
|
+
candidates << kit if kit
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
[candidates, errors]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def read_memory_kit(path, scope, errors)
|
|
203
|
+
memory_name = File.basename(path, ".md")
|
|
204
|
+
name = memory_name.delete_prefix(MEMORY_KIT_PREFIX)
|
|
205
|
+
return memory_kit_error(errors, memory_name, scope, "toolkit name is empty") if name.empty?
|
|
206
|
+
|
|
207
|
+
body = File.read(path, encoding: "UTF-8")
|
|
208
|
+
match = body.match(MEMORY_KIT_BLOCK)
|
|
209
|
+
return memory_kit_error(errors, memory_name, scope, "expected one fenced yaml block") unless match
|
|
210
|
+
|
|
211
|
+
spec = YAML.safe_load(match[1], permitted_classes: [], aliases: false)
|
|
212
|
+
return memory_kit_error(errors, memory_name, scope, "yaml block must be a map") unless spec.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
spec = spec.transform_keys(&:to_s)
|
|
215
|
+
if spec.key?("prompt")
|
|
216
|
+
return memory_kit_error(errors, memory_name, scope, "prompt: is not supported in toolkit memories")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
unknown = spec.keys - MEMORY_KIT_KEYS
|
|
220
|
+
if unknown.any?
|
|
221
|
+
return memory_kit_error(errors, memory_name, scope, "unknown key #{unknown.first}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
description = spec["description"]
|
|
225
|
+
unless spec.key?("description") && !description.nil?
|
|
226
|
+
return memory_kit_error(errors, memory_name, scope, "description is required")
|
|
227
|
+
end
|
|
228
|
+
if (problem = Toolkit.description_error(description))
|
|
229
|
+
return memory_kit_error(errors, memory_name, scope, problem)
|
|
230
|
+
end
|
|
231
|
+
return memory_kit_error(errors, memory_name, scope, "description is required") if description.strip.empty?
|
|
232
|
+
if !spec["base"].nil? && (!spec["base"].is_a?(String) || spec["base"].strip.empty?)
|
|
233
|
+
return memory_kit_error(errors, memory_name, scope, "base must be a toolkit name")
|
|
234
|
+
end
|
|
235
|
+
if %w[add remove].any? { |key| spec.key?(key) && !(spec[key].is_a?(Array) && spec[key].all?(String)) }
|
|
236
|
+
return memory_kit_error(errors, memory_name, scope, "add and remove must be lists of tool names")
|
|
237
|
+
end
|
|
238
|
+
if (problem = Toolkit.groups_error(spec["groups"]))
|
|
239
|
+
return memory_kit_error(errors, memory_name, scope, problem)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
Toolkit::Kit.new(name: name, base: spec["base"] || Toolkit::FULL, add: spec.fetch("add", []),
|
|
243
|
+
remove: spec.fetch("remove", []), groups: spec["groups"], description: description,
|
|
244
|
+
source: "memory:#{scope}")
|
|
245
|
+
rescue Psych::Exception, SystemCallError, EncodingError => e
|
|
246
|
+
memory_kit_error(errors, memory_name, scope, e.message.lines.first.to_s.strip)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def memory_kit_error(errors, memory_name, scope, message)
|
|
250
|
+
errors << "#{memory_name} (memory:#{scope}): #{message}"
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
146
254
|
# The toolkit's load warnings, each once per process: a name that
|
|
147
255
|
# didn't resolve, config kits left out.
|
|
148
256
|
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
|
|