samagotchi 0.121.0 → 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 +110 -1
- data/bin/chi +7 -3
- data/docs/architecture.md +37 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +11 -4
- data/docs/configuration.md +36 -7
- data/docs/guardrails.md +63 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +16 -8
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/memory.md +33 -0
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +21 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +12 -11
- data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
- data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- 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 +24 -6
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +14 -8
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/review_start.rb +6 -2
- data/lib/samagotchi/run_options.rb +2 -0
- data/lib/samagotchi/session.rb +18 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +37 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +133 -29
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +12 -6
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +44 -9
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +95 -10
- 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/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +127 -69
- data/lib/samagotchi/web/participant_access.rb +30 -8
- data/lib/samagotchi/web/participant_review.rb +8 -5
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +25 -10
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +28 -20
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +17 -3
- data/lib/samagotchi/worker_link.rb +214 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +6 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../paths"
|
|
4
3
|
require_relative "../config"
|
|
5
4
|
require_relative "../memory_paths"
|
|
6
|
-
require_relative "../hooks/loader"
|
|
7
5
|
require_relative "../guardrails/protected_paths"
|
|
6
|
+
require_relative "../guardrails/parent_approvals"
|
|
8
7
|
|
|
9
8
|
module Samagotchi
|
|
10
9
|
module SystemOne
|
|
@@ -23,13 +22,17 @@ module Samagotchi
|
|
|
23
22
|
# there is one agent approving another's, so the user decides them even
|
|
24
23
|
# when they are listed in `guardrails.system_one.rules`.
|
|
25
24
|
NEVER_AUTO = %w[chi-answer-piped chi-answer-http chi-broadcast].freeze
|
|
26
|
-
# chi's
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
# elsewhere: chi's default config dir and default state dir. A path
|
|
30
|
-
# into the memories dir is not a control file.
|
|
31
|
-
DEFAULT_CONFIG_TEXT = %r{\B~/\.config/samagotchi(?!/memories)}
|
|
25
|
+
# chi's default state dir as a shell command may spell it when the
|
|
26
|
+
# settings point elsewhere (the sessions and the guardrails store live
|
|
27
|
+
# there).
|
|
32
28
|
DEFAULT_STATE_TEXT = %r{\B~/\.local/state/samagotchi}
|
|
29
|
+
# The characters that end a shell word: a memories path in a command
|
|
30
|
+
# ends at the first one.
|
|
31
|
+
WORD_END = %q{[\s'"`;&|<>(){}:,]}
|
|
32
|
+
# A `..` segment anywhere in a command: with one, a memories path may
|
|
33
|
+
# climb back out (`memories/../config.yml`, `cd memories && … ../x`),
|
|
34
|
+
# so the command's memories text is not taken out.
|
|
35
|
+
CLIMB = %r{(?:\A|[\s/'"=])\.\.(?:/|[\s'"]|\z)}
|
|
33
36
|
|
|
34
37
|
module_function
|
|
35
38
|
|
|
@@ -39,47 +42,40 @@ module Samagotchi
|
|
|
39
42
|
def refuse?(approval)
|
|
40
43
|
return false unless approval.is_a?(Hash)
|
|
41
44
|
return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
|
|
42
|
-
|
|
43
|
-
dirs
|
|
44
|
-
|
|
45
|
-
return true if paths.any? { |path| control_path?(path, dirs) }
|
|
45
|
+
# The test that makes an ask user_only (ParentApprovals#chi_files?:
|
|
46
|
+
# chi's dirs, however a command spells them), minus the memories.
|
|
47
|
+
return true if Guardrails::ParentApprovals.chi_files?(beyond_memories(approval))
|
|
46
48
|
|
|
47
49
|
command = fetch(approval, :command).to_s
|
|
48
|
-
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# The dirs whose files are chi's own: its hooks, its installed bundles,
|
|
52
|
-
# the guardrails store (approvals.json and judgments.jsonl).
|
|
53
|
-
# @return [Array<String>]
|
|
54
|
-
def control_dirs
|
|
55
|
-
[Hooks::Loader.hooks_dir, MemoryPaths.bundles_dir, File.join(Paths.state_dir, "guardrails")]
|
|
56
|
-
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?)
|
|
50
|
+
command.match?(DEFAULT_STATE_TEXT)
|
|
57
51
|
end
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def control_text?(text, dirs)
|
|
75
|
-
dirs.any? { |dir| text.include?(dir) || text.include?(File.expand_path(dir)) } ||
|
|
76
|
-
text.match?(config_text) || text.match?(DEFAULT_CONFIG_TEXT) || text.match?(DEFAULT_STATE_TEXT)
|
|
53
|
+
# The approval's facts without what is in the memories dir (not its
|
|
54
|
+
# installed bundles): the paths under it, and its spellings in the
|
|
55
|
+
# command unless the command climbs with `..`.
|
|
56
|
+
def beyond_memories(approval)
|
|
57
|
+
memories = Guardrails::ProtectedPaths.real(MemoryPaths.system_dir)
|
|
58
|
+
bundles = Guardrails::ProtectedPaths.real(MemoryPaths.bundles_dir)
|
|
59
|
+
paths = Array(fetch(approval, :paths)).map(&:to_s).reject do |path|
|
|
60
|
+
real = Guardrails::ProtectedPaths.real(path)
|
|
61
|
+
Guardrails::ProtectedPaths.within?(real, memories) && !Guardrails::ProtectedPaths.within?(real, bundles)
|
|
62
|
+
end
|
|
63
|
+
command = fetch(approval, :command).to_s
|
|
64
|
+
unless command.match?(CLIMB)
|
|
65
|
+
command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" }
|
|
66
|
+
end
|
|
67
|
+
{ paths: paths, command: command }
|
|
77
68
|
end
|
|
78
69
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
70
|
+
# The memories dir as a command spells it: the default
|
|
71
|
+
# `.config/samagotchi/memories`, or this process's own path, with the
|
|
72
|
+
# rest of that shell word.
|
|
73
|
+
def memories_text
|
|
74
|
+
dir = ConfigFile.config_dir
|
|
75
|
+
prefixes = [".config/samagotchi", dir, Guardrails::ProtectedPaths.real(dir)]
|
|
76
|
+
.map { |prefix| Regexp.escape(prefix.to_s.chomp("/")) }.uniq
|
|
77
|
+
word = WORD_END.sub("[", "[^")
|
|
78
|
+
Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
|
|
83
79
|
end
|
|
84
80
|
|
|
85
81
|
def fetch(hash, key)
|
|
@@ -6,6 +6,7 @@ require_relative "../model_profile"
|
|
|
6
6
|
require_relative "../session_manager"
|
|
7
7
|
require_relative "../thinking_command"
|
|
8
8
|
require_relative "../bridge_client"
|
|
9
|
+
require_relative "../worker_link"
|
|
9
10
|
require_relative "../parent_report"
|
|
10
11
|
require_relative "../cli/exit"
|
|
11
12
|
require_relative "../launch_mode"
|
|
@@ -207,8 +208,8 @@ module Samagotchi
|
|
|
207
208
|
toolkit: toolkit, settings: settings,
|
|
208
209
|
parent_answers: parent_answers))
|
|
209
210
|
end
|
|
210
|
-
|
|
211
|
-
client
|
|
211
|
+
link = WorkerLink.await(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
212
|
+
link.live? ? link.client(session.id) : raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
|
|
212
213
|
rescue SessionManager::OwnedByTUI => e
|
|
213
214
|
raise Error, e.message
|
|
214
215
|
rescue ArgumentError => e
|
|
@@ -248,9 +249,13 @@ module Samagotchi
|
|
|
248
249
|
end
|
|
249
250
|
|
|
250
251
|
# @return [BridgeClient, nil] the running worker's, or nil when none runs
|
|
252
|
+
# @raise [Error] its worker is newer than this chi (Skew)
|
|
251
253
|
def connect_existing(session_id, state_dir)
|
|
252
254
|
Session.load(session_id, state_dir: state_dir)
|
|
253
|
-
|
|
255
|
+
link = WorkerLink.resolve(Session.session_dir(session_id, state_dir: state_dir), unlink: true)
|
|
256
|
+
raise Error, link.message(session_id) if link.is_a?(WorkerLink::Skew)
|
|
257
|
+
|
|
258
|
+
link.live? ? link.client(session_id) : nil
|
|
254
259
|
end
|
|
255
260
|
end
|
|
256
261
|
end
|
|
@@ -439,7 +439,7 @@ module Samagotchi
|
|
|
439
439
|
|
|
440
440
|
# The stream moved to a new worker (a restart): requests go there too.
|
|
441
441
|
def switch_worker(event)
|
|
442
|
-
@client = @link.switched(event)
|
|
442
|
+
@client = @link.switched(event) || @client
|
|
443
443
|
@worker_changed = true
|
|
444
444
|
# The new worker's machine may name another default model.
|
|
445
445
|
@default_model_name = nil
|
|
@@ -452,9 +452,11 @@ module Samagotchi
|
|
|
452
452
|
def closed_line(reason)
|
|
453
453
|
id = @client.session_id
|
|
454
454
|
# The worker asked for a secret this terminal's chi doesn't send.
|
|
455
|
-
return "Lost the session's worker: #{BridgeSecret
|
|
455
|
+
return "Lost the session's worker: #{BridgeSecret.refused(id, via: via)}" if reason == BridgeSecret::ERROR
|
|
456
456
|
return remote_closed_line(reason, id) if via.remote?
|
|
457
457
|
|
|
458
|
+
return "Lost the session's worker: #{@link.failure}" if reason == "worker_newer" && @link.failure
|
|
459
|
+
|
|
458
460
|
# Here a stopped session has always read as unreachable.
|
|
459
461
|
reason = "unreachable" if reason == "stopped"
|
|
460
462
|
"Lost the session's worker (#{reason}). Resume it with: #{via.command("--shared --resume #{id}")}"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative "attach_link"
|
|
4
4
|
require_relative "../session"
|
|
5
5
|
require_relative "../bridge_client"
|
|
6
|
-
require_relative "../
|
|
6
|
+
require_relative "../worker_link"
|
|
7
7
|
require_relative "../delivery"
|
|
8
8
|
require_relative "../session_inbox"
|
|
9
9
|
require_relative "../image_store"
|
|
@@ -42,6 +42,8 @@ module Samagotchi
|
|
|
42
42
|
installed_version: -> { InstalledVersions.new.newest },
|
|
43
43
|
children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
|
|
44
44
|
@client = client
|
|
45
|
+
@session_id = client.session_id
|
|
46
|
+
@host = BridgeClient::HOST
|
|
45
47
|
@state_dir = state_dir
|
|
46
48
|
@delete_session = delete_session
|
|
47
49
|
@archive_session = archive_session
|
|
@@ -52,30 +54,36 @@ module Samagotchi
|
|
|
52
54
|
# The sidecar's worker, nil while none runs; gone once the session was
|
|
53
55
|
# stopped (no worker is coming): its stop marker in +state_dir+.
|
|
54
56
|
def live_worker
|
|
55
|
-
id = @
|
|
57
|
+
id = @session_id
|
|
56
58
|
return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
|
|
57
59
|
|
|
60
|
+
link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
|
|
61
|
+
if link.is_a?(WorkerLink::Skew)
|
|
62
|
+
@failure = link.message(id)
|
|
63
|
+
return [:gone, "worker_newer"]
|
|
64
|
+
end
|
|
65
|
+
|
|
58
66
|
# Kept: the stream moves to this very client (EventStream), and
|
|
59
67
|
# #switched takes it, port and secret, rather than reading again.
|
|
60
|
-
@found =
|
|
68
|
+
@found = link.live? ? link.client(id, host: @host) : nil
|
|
61
69
|
[@found, nil]
|
|
62
70
|
end
|
|
63
71
|
|
|
64
72
|
# A new worker's Bridge: the client #live_worker found for the stream
|
|
65
|
-
# (the event names its port), with that worker's secret.
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
# gone too, and the stream finds the next.
|
|
73
|
+
# (the event names its port), with that worker's secret. An event that
|
|
74
|
+
# names another worker is resolved again; a mismatched sidecar never
|
|
75
|
+
# becomes a secretless client.
|
|
69
76
|
def switched(event)
|
|
70
77
|
port = AttachLink.event_port(event)
|
|
71
78
|
found = @found
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
candidate = if found&.port == port
|
|
80
|
+
found
|
|
81
|
+
else
|
|
82
|
+
link = WorkerLink.resolve(session_dir, unlink: true, host: @host)
|
|
83
|
+
link.live? && link.port == port ? link.client(@session_id, host: @host) : nil
|
|
84
|
+
end
|
|
85
|
+
@client = candidate if candidate
|
|
86
|
+
candidate
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
# No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
|
|
@@ -139,18 +147,19 @@ module Samagotchi
|
|
|
139
147
|
|
|
140
148
|
def via = ChiInvocation::HERE
|
|
141
149
|
|
|
142
|
-
# The closing line says it all (unreachable, unknown_session)
|
|
143
|
-
|
|
150
|
+
# The closing line says it all (unreachable, unknown_session); a worker
|
|
151
|
+
# newer than this chi names itself.
|
|
152
|
+
attr_reader :failure
|
|
144
153
|
|
|
145
154
|
def close = nil
|
|
146
155
|
|
|
147
156
|
private
|
|
148
157
|
|
|
149
|
-
def session_dir = Session.session_dir(@
|
|
158
|
+
def session_dir = Session.session_dir(@session_id, state_dir: @state_dir)
|
|
150
159
|
|
|
151
160
|
def feature?(name)
|
|
152
|
-
|
|
153
|
-
|
|
161
|
+
link = WorkerLink.resolve(session_dir, unlink: false)
|
|
162
|
+
!link.live? || link.features.include?(name)
|
|
154
163
|
end
|
|
155
164
|
end
|
|
156
165
|
end
|
|
@@ -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "model_match"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# A session's toolkit: which of the registered tools its prompts declare
|
|
@@ -118,8 +119,66 @@ module Samagotchi
|
|
|
118
119
|
Array(groups).map { |group| group.to_s.strip }.reject(&:empty?).uniq.sort.freeze
|
|
119
120
|
end
|
|
120
121
|
|
|
122
|
+
# Whether a source group +name+ is one of +selectors+: fnmatch globs in
|
|
123
|
+
# the guardrails rules' grammar (ModelMatch::GLOB_FLAGS: any case,
|
|
124
|
+
# extglob), so `groups: [Chrome]` reaches chrome.
|
|
121
125
|
def self.group_matches?(selectors, name)
|
|
122
|
-
selectors == :all || selectors.any? { |selector| File.fnmatch?(selector, name.to_s) }
|
|
126
|
+
selectors == :all || selectors.any? { |selector| File.fnmatch?(selector, name.to_s, ModelMatch::GLOB_FLAGS) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Why +value+ can't be a kit's groups (a group name or glob, or a list
|
|
130
|
+
# of them), nil when it can (nil too: its base's groups).
|
|
131
|
+
def self.groups_error(value)
|
|
132
|
+
return nil if value.nil? || value.is_a?(String)
|
|
133
|
+
return nil if value.is_a?(Array) && value.all? { |group| group.is_a?(String) || group.is_a?(Symbol) || group.is_a?(Numeric) }
|
|
134
|
+
|
|
135
|
+
"groups must be a list of group names or globs"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# config.yml's toolkit.kits.NAME line for a +value+ that can't be its
|
|
139
|
+
# groups (groups_error), nil when it can: the one wording a worker's
|
|
140
|
+
# warning and a creation's refusal both start with.
|
|
141
|
+
def self.config_groups_error(name, value)
|
|
142
|
+
problem = groups_error(value)
|
|
143
|
+
problem && "toolkit.kits.#{name}: #{problem}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A new session refused: its toolkit, or a base it builds on, is a
|
|
147
|
+
# config.yml kit (toolkit.kits) whose groups: isn't a name or a list of
|
|
148
|
+
# them. A session never starts on a reach other than the one written
|
|
149
|
+
# (its base's groups: wider or narrower); the message is the one line
|
|
150
|
+
# every creation surface shows.
|
|
151
|
+
class Refused < StandardError
|
|
152
|
+
# @param toolkit [String] the new session's toolkit name
|
|
153
|
+
# @param problem [String] config_groups_error's line
|
|
154
|
+
def self.for(toolkit, problem) = new("#{problem}; fix config.yml to start a session on toolkit #{toolkit}")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Raises Refused when +toolkit+ (a new session's: Session.new_session)
|
|
158
|
+
# names a config kit, or builds on one through its bases, whose
|
|
159
|
+
# groups: is invalid (config_groups_error). Only config.yml's kits are
|
|
160
|
+
# followed: a bundle's (chi.toolkit) refuses at its load, and a name
|
|
161
|
+
# that doesn't resolve stays the worker's warning.
|
|
162
|
+
# @param config [Hash, nil] toolkit.kits (config.yml's by default)
|
|
163
|
+
# @return [Toolkit] +toolkit+
|
|
164
|
+
# @raise [Refused]
|
|
165
|
+
def self.check_new!(toolkit, config: config_kits)
|
|
166
|
+
return toolkit unless toolkit && config.is_a?(Hash)
|
|
167
|
+
|
|
168
|
+
name = toolkit.name
|
|
169
|
+
seen = []
|
|
170
|
+
until seen.include?(name) || name == REVIEW || BUILTIN_KITS.key?(name)
|
|
171
|
+
seen << name
|
|
172
|
+
spec = config[name] || config[name.to_sym]
|
|
173
|
+
break unless spec.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
spec = spec.transform_keys(&:to_s)
|
|
176
|
+
problem = config_groups_error(name, spec["groups"])
|
|
177
|
+
raise Refused.for(toolkit.name, problem) if problem
|
|
178
|
+
|
|
179
|
+
name = (spec["base"] || FULL).to_s
|
|
180
|
+
end
|
|
181
|
+
toolkit
|
|
123
182
|
end
|
|
124
183
|
|
|
125
184
|
# A named list of declared tools: +tools+ (:all, every built-in; or the
|
|
@@ -129,12 +188,15 @@ module Samagotchi
|
|
|
129
188
|
# system prompt, verbatim (stripped); nil inherits the base's, "" clears
|
|
130
189
|
# it. +groups+: :all or a sorted list of source group names/globs; nil
|
|
131
190
|
# inherits the base's.
|
|
132
|
-
|
|
133
|
-
|
|
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)
|
|
134
195
|
super(name: name.to_s, base: base&.to_s, tools: tools == :all ? :all : Toolkit.tool_names(tools),
|
|
135
196
|
add: Toolkit.tool_names(add), remove: Toolkit.tool_names(remove), source: source.to_s,
|
|
136
197
|
prompt: prompt.is_a?(String) ? prompt.strip.freeze : nil,
|
|
137
|
-
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)
|
|
138
200
|
end
|
|
139
201
|
end
|
|
140
202
|
|
|
@@ -151,6 +213,17 @@ module Samagotchi
|
|
|
151
213
|
"prompt is over #{PROMPT_MAX} characters" if value.strip.length > PROMPT_MAX
|
|
152
214
|
end
|
|
153
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
|
+
|
|
154
227
|
# The built-ins `lean` declares: the file and shell tools, memories,
|
|
155
228
|
# the task tools (execute's hand-over, turn notes and task_wait's tail
|
|
156
229
|
# name the trio), questions, web_fetch, and context_read (core notes
|
|
@@ -295,16 +368,19 @@ module Samagotchi
|
|
|
295
368
|
"name your kit something else"
|
|
296
369
|
|
|
297
370
|
# The keys a config kit entry takes.
|
|
298
|
-
KIT_KEYS = %w[base add remove prompt groups].freeze
|
|
371
|
+
KIT_KEYS = %w[base add remove prompt groups description].freeze
|
|
299
372
|
|
|
300
373
|
# The kits known: the built-ins, then config's toolkit.kits, then the
|
|
301
374
|
# plugins' (chi.toolkit). A config kit with a built-in's name is left
|
|
302
375
|
# out (one it can't redefine; review, the participants' boundary, is
|
|
303
376
|
# reserved) with a line in +errors+, and so is one
|
|
304
|
-
# 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
|
|
305
378
|
# (a typo, a tools: list) still loads, with a line naming the key, and
|
|
306
379
|
# so does one whose prompt isn't text or is over PROMPT_MAX characters:
|
|
307
|
-
# without the prompt, with a line saying why.
|
|
380
|
+
# without the prompt, with a line saying why. One whose groups: isn't
|
|
381
|
+
# a name or a list (config_groups_error) loads reaching no group, with
|
|
382
|
+
# a line: a session saved on it before the typo (no new one starts on
|
|
383
|
+
# it: check_new!) never reaches wider than written.
|
|
308
384
|
# @param config [Hash, nil] toolkit.kits
|
|
309
385
|
# @param plugins [Array<Kit>]
|
|
310
386
|
# @param errors [Array<String>] gets a line per kit left out or unknown key
|
|
@@ -318,19 +394,28 @@ module Samagotchi
|
|
|
318
394
|
elsif kits.key?(name)
|
|
319
395
|
errors << "toolkit.kits.#{name}: #{name} is a built-in toolkit"
|
|
320
396
|
elsif !spec.is_a?(Hash)
|
|
321
|
-
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:, groups:}"
|
|
397
|
+
errors << "toolkit.kits.#{name}: expected {base:, add:, remove:, prompt:, groups:, description:}"
|
|
322
398
|
else
|
|
323
399
|
spec = spec.transform_keys(&:to_s)
|
|
324
400
|
(spec.keys - KIT_KEYS).each do |key|
|
|
325
401
|
errors << "toolkit.kits.#{name}: unknown key #{key} " \
|
|
326
|
-
"(a kit takes base:, add:, remove:, prompt:, groups:)"
|
|
402
|
+
"(a kit takes base:, add:, remove:, prompt:, groups:, description:)"
|
|
327
403
|
end
|
|
328
404
|
if (problem = prompt_error(spec["prompt"]))
|
|
329
405
|
errors << "toolkit.kits.#{name}: #{problem}; the kit loads without it"
|
|
330
406
|
spec.delete("prompt")
|
|
331
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
|
|
412
|
+
if (problem = config_groups_error(name, spec["groups"]))
|
|
413
|
+
errors << "#{problem}; the kit reaches no tool-index group until it is fixed"
|
|
414
|
+
spec["groups"] = []
|
|
415
|
+
end
|
|
332
416
|
kits[name] = Kit.new(name: name, base: spec["base"] || FULL, add: spec["add"], remove: spec["remove"],
|
|
333
|
-
prompt: spec["prompt"], groups: spec["groups"], source: "config"
|
|
417
|
+
prompt: spec["prompt"], groups: spec["groups"], source: "config",
|
|
418
|
+
description: spec["description"])
|
|
334
419
|
end
|
|
335
420
|
end
|
|
336
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
|
|