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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -1
  3. data/bin/chi +7 -3
  4. data/docs/architecture.md +37 -11
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +11 -4
  7. data/docs/configuration.md +36 -7
  8. data/docs/guardrails.md +63 -19
  9. data/docs/internals/anthropic.md +1 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/engine.md +2 -0
  12. data/docs/internals/participants.md +16 -8
  13. data/docs/internals/remote-sessions.md +15 -11
  14. data/docs/internals/web-push.md +22 -11
  15. data/docs/memory.md +33 -0
  16. data/docs/plugins.md +8 -3
  17. data/docs/sessions.md +21 -0
  18. data/docs/testing.md +41 -1
  19. data/lib/samagotchi/answer_command.rb +1 -1
  20. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  21. data/lib/samagotchi/bridge.rb +12 -11
  22. data/lib/samagotchi/bridge_client/event_stream.rb +14 -26
  23. data/lib/samagotchi/bridge_client/stream_open.rb +85 -0
  24. data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
  25. data/lib/samagotchi/bridge_client.rb +52 -65
  26. data/lib/samagotchi/bridge_secret.rb +9 -7
  27. data/lib/samagotchi/bundle_command/check.rb +27 -2
  28. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
  30. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
  32. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
  36. data/lib/samagotchi/child_move.rb +5 -7
  37. data/lib/samagotchi/children_status.rb +2 -1
  38. data/lib/samagotchi/config.rb +5 -0
  39. data/lib/samagotchi/engine.rb +107 -1
  40. data/lib/samagotchi/event_fanout.rb +0 -1
  41. data/lib/samagotchi/guardrail_wiring.rb +24 -6
  42. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
  43. data/lib/samagotchi/guardrails_command.rb +41 -6
  44. data/lib/samagotchi/image_store.rb +6 -3
  45. data/lib/samagotchi/interrupted_turn.rb +6 -3
  46. data/lib/samagotchi/live_versions.rb +6 -7
  47. data/lib/samagotchi/log_line.rb +1 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  50. data/lib/samagotchi/memory_orphans.rb +11 -0
  51. data/lib/samagotchi/participant_mode.rb +11 -0
  52. data/lib/samagotchi/plugin/api.rb +14 -8
  53. data/lib/samagotchi/plugin/sessions.rb +13 -4
  54. data/lib/samagotchi/process_group.rb +13 -0
  55. data/lib/samagotchi/push_command.rb +1 -1
  56. data/lib/samagotchi/relay_verifier.rb +2 -3
  57. data/lib/samagotchi/relay_watcher.rb +2 -2
  58. data/lib/samagotchi/remote_attach_command.rb +1 -1
  59. data/lib/samagotchi/reply_wait/poll.rb +8 -2
  60. data/lib/samagotchi/reply_wait.rb +4 -3
  61. data/lib/samagotchi/review_command.rb +1 -1
  62. data/lib/samagotchi/review_start.rb +6 -2
  63. data/lib/samagotchi/run_options.rb +2 -0
  64. data/lib/samagotchi/session.rb +18 -1
  65. data/lib/samagotchi/session_access/contract.rb +1 -1
  66. data/lib/samagotchi/session_access/local.rb +18 -8
  67. data/lib/samagotchi/session_access/remote.rb +37 -12
  68. data/lib/samagotchi/session_access/web_bridge_client.rb +38 -19
  69. data/lib/samagotchi/session_access.rb +21 -9
  70. data/lib/samagotchi/session_continue.rb +2 -3
  71. data/lib/samagotchi/session_manager.rb +133 -29
  72. data/lib/samagotchi/session_setup.rb +8 -4
  73. data/lib/samagotchi/sessions_command.rb +10 -5
  74. data/lib/samagotchi/steer.rb +7 -6
  75. data/lib/samagotchi/system_one/client.rb +12 -6
  76. data/lib/samagotchi/system_one/fake_backend.rb +0 -2
  77. data/lib/samagotchi/system_one/log.rb +44 -9
  78. data/lib/samagotchi/system_one/self_protect.rb +39 -43
  79. data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
  80. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  81. data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
  82. data/lib/samagotchi/terminal_ui.rb +2 -1
  83. data/lib/samagotchi/tool_runner.rb +2 -1
  84. data/lib/samagotchi/toolkit.rb +95 -10
  85. data/lib/samagotchi/toolkit_binding.rb +115 -7
  86. data/lib/samagotchi/toolkit_command.rb +15 -3
  87. data/lib/samagotchi/toolkit_report_command.rb +318 -0
  88. data/lib/samagotchi/toolkit_suggest.rb +211 -0
  89. data/lib/samagotchi/tools/delegate.rb +1 -1
  90. data/lib/samagotchi/tools/delegate_relay.rb +9 -7
  91. data/lib/samagotchi/tools/memory.rb +1 -1
  92. data/lib/samagotchi/turn_checkpoint.rb +15 -9
  93. data/lib/samagotchi/version.rb +1 -1
  94. data/lib/samagotchi/web/app.rb +127 -69
  95. data/lib/samagotchi/web/participant_access.rb +30 -8
  96. data/lib/samagotchi/web/participant_review.rb +8 -5
  97. data/lib/samagotchi/web/participant_view.rb +7 -3
  98. data/lib/samagotchi/web/participants_admin.rb +13 -0
  99. data/lib/samagotchi/web/principal.rb +3 -4
  100. data/lib/samagotchi/web/public/app.js +1 -1
  101. data/lib/samagotchi/web/public/bubbles.js +3 -2
  102. data/lib/samagotchi/web/public/notifications.js +21 -1
  103. data/lib/samagotchi/web/public/participant.html +3 -2
  104. data/lib/samagotchi/web/public/participant.js +25 -10
  105. data/lib/samagotchi/web/public/participants_panel.js +46 -11
  106. data/lib/samagotchi/web/public/push.js +4 -3
  107. data/lib/samagotchi/web/public/push_devices.js +17 -3
  108. data/lib/samagotchi/web/public/review_history.js +14 -2
  109. data/lib/samagotchi/web/public/review_start.js +1 -3
  110. data/lib/samagotchi/web/public/session_actions.js +14 -2
  111. data/lib/samagotchi/web/public/storage.js +16 -0
  112. data/lib/samagotchi/web/public/turn_events.js +2 -1
  113. data/lib/samagotchi/web/public/turn_html.js +1 -1
  114. data/lib/samagotchi/web/push/client.rb +5 -0
  115. data/lib/samagotchi/web/push/store.rb +16 -3
  116. data/lib/samagotchi/web/push_routes.rb +3 -2
  117. data/lib/samagotchi/web/server.rb +2 -2
  118. data/lib/samagotchi/web/session_access_routes.rb +28 -20
  119. data/lib/samagotchi/web/session_control.rb +4 -2
  120. data/lib/samagotchi/web/session_create.rb +4 -4
  121. data/lib/samagotchi/web/session_hub.rb +6 -4
  122. data/lib/samagotchi/web/session_summary.rb +8 -16
  123. data/lib/samagotchi/web/start_dir.rb +10 -5
  124. data/lib/samagotchi/worker.rb +17 -3
  125. data/lib/samagotchi/worker_link.rb +214 -0
  126. data/lib/samagotchi/worker_sidecar.rb +15 -37
  127. 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 config file, however the user names it in config.yml.
27
- CONFIG_FILE = /\Aconfig[\w-]*\.ya?ml\z/i
28
- # The dirs as a shell command may spell them when the settings point
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 = control_dirs
44
- paths = Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?)
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
- !command.empty? && control_text?(command, dirs)
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
- def control_path?(path, dirs)
60
- real = Guardrails::ProtectedPaths.real(path)
61
- return true if dirs.any? { |dir| Guardrails::ProtectedPaths.within?(real, dir) }
62
- return true if config_file?(real)
63
-
64
- control_text?(path, dirs)
65
- end
66
-
67
- # config.yml in chi's config dir — not the memories beside it.
68
- def config_file?(path)
69
- Guardrails::ProtectedPaths.within?(path, ConfigFile.config_dir) && File.basename(path).match?(CONFIG_FILE)
70
- end
71
-
72
- # A command or path naming one of those dirs, in the spelling this
73
- # process uses or the default `~/.config/samagotchi` one.
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
- # chi's config dir as text — everything under it except the memories,
80
- # which the model works with.
81
- def config_text
82
- Regexp.new("#{Regexp.escape(ConfigFile.config_dir)}(?![/]memories)")
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
- client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
211
- client || raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
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
- BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
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::REFUSED}" if reason == BridgeSecret::ERROR
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 "../worker_sidecar"
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 = @client.session_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 = BridgeClient.discover(id, session_dir: session_dir, host: @client.host)
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. One the event
66
- # doesn't name (another worker since) is read from the sidecar for that
67
- # port, none when the sidecar names another one by now: that worker is
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
- @client = if found&.port == port
73
- found
74
- else
75
- sidecar = WorkerSidecar.read(session_dir)
76
- sidecar = WorkerSidecar.new(port: port) unless sidecar&.port == port
77
- BridgeClient.for_sidecar(@client.session_id, sidecar, host: @client.host)
78
- end
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
- def failure = nil
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(@client.session_id, state_dir: @state_dir)
158
+ def session_dir = Session.session_dir(@session_id, state_dir: @state_dir)
150
159
 
151
160
  def feature?(name)
152
- sidecar = WorkerSidecar.read(session_dir)
153
- sidecar.nil? || sidecar.features.include?(name)
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
- settings: @session_settings, parent_answers: @parent_answers)
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)
@@ -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
- Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt, :groups) do
133
- def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil, groups: nil)
191
+ # +description+: optional classifier text; it is not prompt text.
192
+ Kit = Data.define(:name, :base, :tools, :add, :remove, :source, :prompt, :groups, :description) do
193
+ def initialize(name:, base: nil, tools: nil, add: [], remove: [], source: "core", prompt: nil, groups: nil,
194
+ description: nil)
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: the built-ins, config's
43
- # toolkit.kits, the plugins' (Toolkit.kits). Config's are read once per
44
- # process, like the plugins': an edit to toolkit.kits never changes a
45
- # live session's tools mid-session (a new worker reads it).
46
- # @param errors [Array<String>] gets a line per config kit left out
46
+ # The kits a session's toolkit resolves against: built-ins, config,
47
+ # plugins, then project and system memory kits. Config and memory kits
48
+ # are read once per worker, like the plugins': edits take effect when a
49
+ # new worker reads them.
50
+ # @param errors [Array<String>] gets a line per invalid or skipped kit
47
51
  # @return [Hash{String => Toolkit::Kit}]
48
52
  def kits(errors = [])
49
53
  @config_kits = Toolkit.config_kits unless defined?(@config_kits)
50
- Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
54
+ memory_kits, memory_errors = memory_kits_for(@session.call)
55
+ errors.concat(memory_errors)
56
+ kits = Toolkit.kits(config: @config_kits, plugins: @plugin_kits, errors: errors)
57
+ memory_kits.each do |kit|
58
+ if kits.key?(kit.name)
59
+ errors << "#{MEMORY_KIT_PREFIX}#{kit.name}.md (#{kit.source}): " \
60
+ "toolkit name is already defined; skipping this memory kit"
61
+ else
62
+ kits[kit.name] = kit
63
+ end
64
+ end
65
+ kits
51
66
  end
52
67
 
53
68
  # The session's toolkit resolved (Toolkit::Resolved): what its next
@@ -98,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
- return true if session.toolkit == toolkit
116
+ if session.toolkit == toolkit
117
+ if session.toolkit_origin != "chosen"
118
+ session.toolkit_origin = "chosen"
119
+ session.save(state_dir: @state_dir.call)
120
+ end
121
+ return true
122
+ end
102
123
 
103
124
  session.toolkit = toolkit
125
+ session.toolkit_origin = "chosen"
104
126
  session.save(state_dir: @state_dir.call)
105
127
  @notify.call("toolkit: #{name} from the next turn's start", :info, "toolkit", extra: { toolkit: summary })
106
128
  true
@@ -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): not saved.
47
- return [listing(before) + "\n(no change to what the next turn declares)", false, nil] if after.prompt_key == before.prompt_key
46
+ # Nothing the prompt carries changes (full +delegate on full): only
47
+ # the origin is saved, so a no-op choice still counts as user-selected.
48
+ if after.prompt_key == before.prompt_key
49
+ if session.toolkit_origin != "chosen"
50
+ session.toolkit_origin = "chosen"
51
+ @save.call(session)
52
+ end
53
+ return [listing(before) + "\n(no change to what the next turn declares)", false, nil]
54
+ end
48
55
 
49
56
  cost = Toolkit.cache_cost(before.prompt_key, after.prompt_key)
50
57
  if confirm && !yes && CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: !cost.nil?)
@@ -53,6 +60,7 @@ module Samagotchi
53
60
  end
54
61
 
55
62
  session.toolkit = after.toolkit
63
+ session.toolkit_origin = "chosen"
56
64
  @save.call(session)
57
65
  [[listing(after), "From the next turn's start (a running turn keeps its tools)."].join("\n"), true, nil]
58
66
  end
@@ -88,7 +96,11 @@ module Samagotchi
88
96
  lines << " reachable (#{route}): #{explained.reachable.join(", ")}"
89
97
  lines << " removed: #{explained.removed.empty? ? "none" : explained.removed.join(", ")}"
90
98
  end
91
- lines << " toolkits: #{@engine.toolkit_kits.keys.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
99
+ choices = @engine.toolkit_kits.map do |name, kit|
100
+ source = kit.source.start_with?("memory:") ? " (#{kit.source})" : ""
101
+ "#{name}#{source}"
102
+ end
103
+ lines << " toolkits: #{choices.join(", ")}; #{USAGE.delete_prefix("usage: ")} changes it"
92
104
  lines.join("\n")
93
105
  end
94
106