samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require_relative "api"
4
5
  require_relative "context"
5
6
  require_relative "../hooks"
@@ -10,6 +11,11 @@ require_relative "../memory_bundle/manifest"
10
11
  require_relative "../memory_bundle/provenance"
11
12
 
12
13
  module Samagotchi
14
+ # Where installed bundles' plugin code is evaluated: one module per
15
+ # bundle (Plugin::Loader.namespace_for), so two bundles defining the same
16
+ # class name can't collide on a top-level constant.
17
+ module Bundles; end
18
+
13
19
  module Plugin
14
20
  # What a plugin registers into: an Engine's own registries.
15
21
  # +context_for+ is (bundle_name, settings, label) → the Plugin::Context
@@ -25,7 +31,8 @@ module Samagotchi
25
31
  # +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!),
26
32
  # +judgments+ the Engine's SystemOne::Judgments (chi.judgment),
27
33
  # +hook_failed+ is (label, "Class: message") when a chi.on handler
28
- # raises (HookRoutes#plugin_hook_failed).
34
+ # raises, with effect: :deny for a fail-closed one
35
+ # (HookRoutes#plugin_hook_failed).
29
36
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
30
37
  :toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed, :judgments,
31
38
  :hook_failed, keyword_init: true)
@@ -35,14 +42,19 @@ module Samagotchi
35
42
  #
36
43
  # For each bundle, by name: the file must match the sha256 recorded at
37
44
  # install and chi must meet the bundle's requires_chi. The file is
38
- # module_eval'd into the bundle's namespace (as its hooks are), the
45
+ # module_eval'd into the bundle's namespace (#namespace_for), the
39
46
  # class named like the file (plugin.rb → Plugin) is built with the
40
47
  # bundle's settings, and its #register gets a Plugin::Api. What it
41
48
  # registered takes effect only when #register returns: a plugin that
42
49
  # fails adds nothing.
43
50
  #
44
51
  # A failure is logged and added to +failures+ (not required: tool calls
45
- # still run), which the Engine announces once. Not fail-closed.
52
+ # still run), which the Engine announces once. A guardrail plugin's
53
+ # (the manifest's plugin: required: true, read from the kept manifest:
54
+ # InstalledManifest#plugin_required) goes to +guardrail_failures+ as
55
+ # required instead: every tool call is denied, as for a fail-closed
56
+ # config hook. Its chi.on(..., on_error: :fail_closed) hooks deny the
57
+ # call they raise on (Api#on).
46
58
  module Loader
47
59
  TAG = :plugins
48
60
 
@@ -50,12 +62,16 @@ module Samagotchi
50
62
 
51
63
  # @param registries [Registries]
52
64
  # @param failures [Guardrails::LoadFailures, nil]
65
+ # @param guardrail_failures [Guardrails::LoadFailures, nil] where a
66
+ # required plugin's failure goes (the Engine's guardrail failures);
67
+ # nil: +failures+, still required
53
68
  # @param settings [Hash{String => Hash}] config.yml `bundles:`
54
69
  # @return [Array<String>] the bundles whose plugin loaded
55
- def load_installed(registries, failures: nil, settings: {})
70
+ def load_installed(registries, failures: nil, guardrail_failures: nil, settings: {})
56
71
  loaded = []
57
72
  MemoryBundle::Provenance.each_installed(holding: :plugin) do |bundle_name, bundle|
58
- ok = load_bundle(bundle_name, bundle, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
73
+ ok = load_bundle(bundle_name, bundle, registries, failures: failures, guardrail_failures: guardrail_failures,
74
+ settings: settings[bundle_name.to_s] || {})
59
75
  loaded << bundle_name if ok
60
76
  end
61
77
  loaded
@@ -66,22 +82,35 @@ module Samagotchi
66
82
  end
67
83
 
68
84
  # @return [Boolean] whether the plugin loaded
69
- def load_bundle(bundle_name, bundle, registries, failures: nil, settings: {})
85
+ def load_bundle(bundle_name, bundle, registries, failures: nil, guardrail_failures: nil, settings: {})
70
86
  file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(bundle) unless bundle.error?
71
87
  basename = file ? File.basename(file) : "plugin"
88
+ # Known before it registers anything: a guardrail that fails to load
89
+ # (its sha, requires_chi, a raise) denies every tool call.
90
+ required = !bundle.error? && bundle.manifest.plugin_required
91
+ into = required ? guardrail_failures || failures : failures
72
92
  reason = bundle.error || unloadable_reason(file, bundle)
73
- return failed(bundle_name, basename, reason, failures) if reason
93
+ return failed(bundle_name, basename, reason, into, required: required) if reason
74
94
 
75
95
  api = register_file(bundle_name, file, registries, settings: settings)
96
+ warn_unrequired(bundle_name, basename) if api.fail_closed? && !required
76
97
  Log.info(TAG, "plugin_loaded", bundle: bundle_name, file: basename, **api.counts)
77
98
  true
78
99
  rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
79
100
  raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
80
101
 
81
- failed(bundle_name, basename, "#{e.class}: #{e.message}", failures,
102
+ failed(bundle_name, basename, "#{e.class}: #{e.message}", into || failures, required: required == true,
82
103
  location: file && Guardrails::LoadFailures.location(e, File.dirname(file)))
83
104
  end
84
105
 
106
+ # A fail-closed hook from a plugin its manifest doesn't call required:
107
+ # it denies the call it raises on, but a load failure would not deny.
108
+ def warn_unrequired(bundle_name, basename)
109
+ text = "#{label(bundle_name, basename)} has a fail-closed hook but its manifest's plugin: has no required: true, " \
110
+ "so a failure to load it would not deny tool calls"
111
+ Log.warn(TAG, "plugin_fail_closed_unrequired", bundle: bundle_name, file: basename, msg: text)
112
+ end
113
+
85
114
  # Build +file+'s plugin with +settings+ and commit what its #register
86
115
  # staged into +registries+ (no sha or requires_chi check: #load_bundle
87
116
  # makes them; the test kit loads a bundle's source this way).
@@ -117,14 +146,45 @@ module Samagotchi
117
146
  return failure if failure
118
147
  return "the file is missing" unless file && File.file?(file)
119
148
 
120
- Hooks::BundleLoader.sha_mismatch(file, bundle.plugin.sha256, required: true)
149
+ sha_mismatch(file, bundle.plugin.sha256)
150
+ end
151
+
152
+ # Why +file+ doesn't match the sha256 recorded at install (+recorded+,
153
+ # "sha256:<hex>" or bare hex), or nil. None recorded is a mismatch;
154
+ # Provenance.unchecked? (a dev install) is none.
155
+ def sha_mismatch(file, recorded)
156
+ return nil if MemoryBundle::Provenance.unchecked?(recorded)
157
+
158
+ expected = MemoryBundle::Provenance.recorded_sha(recorded)
159
+ return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
160
+
161
+ actual = MemoryBundle::Provenance.file_sha(file)
162
+ return nil if actual == expected
163
+
164
+ "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
165
+ end
166
+
167
+ # The (lazily created) namespace module for a bundle, under
168
+ # Samagotchi::Bundles. The name is sanitized and made injective by a
169
+ # short digest, so `my-bundle` and `my_bundle` don't collide.
170
+ def namespace_for(bundle_name)
171
+ raw = bundle_name.to_s
172
+ base = raw.gsub(/[^A-Za-z0-9_]/, "_")
173
+ base = "B_#{base}" unless base.match?(/\A[A-Z]/)
174
+ ns_name = :"#{base}_#{Digest::MD5.hexdigest(raw)[0, 4]}"
175
+ if Samagotchi::Bundles.const_defined?(ns_name, false)
176
+ Samagotchi::Bundles.const_get(ns_name)
177
+ else
178
+ Samagotchi::Bundles.const_set(ns_name, Module.new)
179
+ end
121
180
  end
122
181
 
123
182
  # module_eval the file into a fresh module in the bundle's namespace
124
183
  # (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
125
184
  # classes, not the last load's reopened) and build its class
126
- # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
127
- # an initialize that takes an argument gets the settings.
185
+ # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as config hooks are
186
+ # built (Hooks.build_plugin): an initialize that takes an argument
187
+ # gets the settings.
128
188
  def instantiate(bundle_name, file, settings, namespace: nil)
129
189
  namespace ||= fresh_namespace(bundle_name)
130
190
  namespace.module_eval(File.read(file), file, 1)
@@ -140,14 +200,14 @@ module Samagotchi
140
200
  def fresh_namespace(bundle_name)
141
201
  LOADS.synchronize do
142
202
  @loads = (@loads || 0) + 1
143
- Hooks::BundleLoader.namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
203
+ namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
144
204
  end
145
205
  end
146
206
 
147
- def failed(bundle_name, basename, reason, failures, location: nil)
207
+ def failed(bundle_name, basename, reason, failures, required: false, location: nil)
148
208
  Log.warn(TAG, "plugin_not_loaded", echo: "[samagotchi:plugins] bundle '#{bundle_name}' plugin '#{basename}' not loaded: #{reason}",
149
- bundle: bundle_name, file: basename)
150
- failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false, location: location)
209
+ bundle: bundle_name, file: basename, required: required)
210
+ failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: required, location: location)
151
211
  false
152
212
  end
153
213
  end
@@ -223,7 +223,7 @@ module Samagotchi
223
223
  return if running.size < max
224
224
 
225
225
  raise Error, "#{running.size} child sessions of this session are running (the most is #{max}, " \
226
- "#{Tools::Delegate::MAX_CHILDREN_KEY}): #{running.map { |s| s[:short_id] }.join(", ")}"
226
+ "#{Tools::Delegate::MAX_CHILDREN_KEY}): #{running.map(&:short_id).join(", ")}"
227
227
  end
228
228
 
229
229
  def live_snapshot(sid, state_dir)
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/command"
4
+ require_relative "cli/exit"
5
+ require_relative "cli/flags"
6
+ require_relative "guardrails/path_resolve"
7
+ require_relative "memory_paths"
8
+ require_relative "paths"
9
+ require_relative "project_init"
10
+
11
+ module Samagotchi
12
+ # `chi project`: which project this folder is in (MemoryPaths.lookup: a
13
+ # git repository, a folder marked with `.chi/project.yml`, or none) and
14
+ # its project memories folder; `chi project init [DIR]` marks a folder in
15
+ # no git repository as a project. Git wins: a repository is already the
16
+ # project, so init writes nothing there. Machine-local
17
+ # (SessionAccess::Contract::MACHINE).
18
+ class ProjectCommand
19
+ include CLI::Command
20
+
21
+ USAGE = <<~TEXT
22
+ Usage: chi project [init [DIR]]
23
+ (none) this folder's project: how it was found (git, marker or none), its root, its
24
+ top and its project memories folder
25
+ init [DIR] make DIR (default: this folder; created when missing) a chi project: writes
26
+ DIR/#{MemoryPaths::MARKER}, so DIR and every subfolder share one project memories
27
+ folder and group their sessions. Not in a git repository (it already is the
28
+ project), nor $HOME or a folder above it
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[init].freeze
32
+ FLAGS = {
33
+ nil => CLI::Flags.new(help: HELP_WORDS, args: false),
34
+ "init" => CLI::Flags.new(help: HELP_WORDS)
35
+ }.freeze
36
+
37
+ # @param cwd [String] the folder asked about (Dir.pwd)
38
+ # @param home [String, nil] $HOME (markers at or above it are ignored)
39
+ # @param sessions [#call, nil] () → this machine's SessionRows (for
40
+ # init's restart note; SessionManager.session_rows by default)
41
+ def initialize(argv, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd, env: ENV, home: MemoryPaths.default_home,
42
+ sessions: nil)
43
+ @argv = argv.dup
44
+ @stdout = stdout
45
+ @stderr = stderr
46
+ @cwd = cwd
47
+ @env = env
48
+ @home = home
49
+ @sessions = sessions || lambda {
50
+ require_relative "session_manager"
51
+ SessionManager.session_rows
52
+ }
53
+ end
54
+
55
+ # @return [Integer] 0, 1 (a refusal), 2 usage
56
+ def run
57
+ @sub = SUBCOMMANDS.include?(@argv.first) ? @argv.first : nil
58
+ parsed = parse_flags(FLAGS.fetch(@sub), @sub ? @argv[1..] : @argv)
59
+ return parsed if parsed.is_a?(Integer)
60
+
61
+ return run_show unless @sub
62
+ return usage_error("init takes one folder") if parsed.args.size > 1
63
+
64
+ run_init(parsed.args.first || @cwd)
65
+ end
66
+
67
+ private
68
+
69
+ def command_name = @sub ? "chi project #{@sub}" : "chi project"
70
+ def usage_on_error = nil
71
+
72
+ def lookup(dir) = MemoryPaths.lookup(dir, home: @home)
73
+
74
+ def run_show
75
+ found = lookup(@cwd)
76
+ row("kind", found.kind.to_s)
77
+ if found.project?
78
+ row("root", found.root)
79
+ row("top", found.top)
80
+ end
81
+ row("memories", memories_line(found.root))
82
+ notes(found).each { |note| row("note", note) }
83
+ CLI::Exit::OK
84
+ end
85
+
86
+ def row(label, value) = @stdout.puts("#{label.ljust(9)}#{value}")
87
+
88
+ def memories_line(root)
89
+ dir = File.join(MemoryPaths.projects_dir(env: @env), MemoryPaths.key_for_root(root))
90
+ count = memory_count(dir)
91
+ "#{Paths.tilde(dir)}#{" (#{count} #{count == 1 ? "memory" : "memories"})" if count.positive?}"
92
+ end
93
+
94
+ def memory_count(dir)
95
+ return 0 unless File.directory?(dir)
96
+
97
+ Dir.children(dir).count { |name| name.end_with?(".md") && name != "index.md" }
98
+ end
99
+
100
+ def notes(found)
101
+ case found.kind
102
+ when :git
103
+ list = []
104
+ list << "#{Paths.tilde(found.top)} is $HOME, a git repository: every folder under it is in that repository" if home_repo?(found)
105
+ marker = MemoryPaths.nearest_marker(@cwd, home: @home)
106
+ list << "#{Paths.tilde(File.join(marker, MemoryPaths::MARKER))} is ignored: the git repository is the project" if marker
107
+ list
108
+ when :none then ["no git repository or chi project marker here: chi project init makes this folder a project"]
109
+ else []
110
+ end
111
+ end
112
+
113
+ def home_repo?(found)
114
+ !@home.to_s.empty? && Guardrails::PathResolve.real(found.top) == Guardrails::PathResolve.real(@home)
115
+ end
116
+
117
+ def run_init(dir)
118
+ dir = File.expand_path(dir, @cwd)
119
+ outcome = ProjectInit.call(dir, home: @home)
120
+ case outcome.kind
121
+ when :git
122
+ found = MemoryPaths::ProjectLookup.new(kind: :git, root: outcome.root, top: outcome.top)
123
+ note = home_repo?(found) ? " ($HOME is a git repository: every folder under it is in it)" : ""
124
+ @stdout.puts("#{outcome.message}#{note}")
125
+ CLI::Exit::OK
126
+ when :refused
127
+ error_line("#{command_name}: #{outcome.message}")
128
+ CLI::Exit::FAILED
129
+ when :already_marked
130
+ @stdout.puts(outcome.message)
131
+ CLI::Exit::OK
132
+ when :created
133
+ report_init(outcome.root, nested_in: outcome.nested_in)
134
+ CLI::Exit::OK
135
+ end
136
+ rescue SystemCallError => e
137
+ error_line("#{command_name}: couldn't write #{Paths.tilde(File.join(dir, MemoryPaths::MARKER))}: #{e.message}")
138
+ CLI::Exit::FAILED
139
+ end
140
+
141
+ def report_init(root, nested_in:)
142
+ nested = nested_in ? ", its own project nested in #{Paths.tilde(nested_in)}" : ""
143
+ @stdout.puts("#{Paths.tilde(root)} is now a chi project#{nested} (#{Paths.tilde(File.join(root, MemoryPaths::MARKER))})")
144
+ row("memories", memories_line(root))
145
+ sessions_note(root)
146
+ end
147
+
148
+ # Sessions in the new project (not one under DIR that a nested
149
+ # repository or marked folder keeps): a running one keeps its old project in its
150
+ # prompt (the memories folder line, the AGENTS.md chain) until it
151
+ # restarts; a running chi web groups the older ones after its restart
152
+ # (SessionHub looked their project up once).
153
+ def sessions_note(root)
154
+ rows = @sessions.call.select { |r| !r.cwd.to_s.empty? && lookup(r.cwd.to_s).root == root }
155
+ return if rows.empty?
156
+
157
+ live = rows.select(&:live)
158
+ unless live.empty?
159
+ @stdout.puts("running sessions in it keep the old project in their prompt until restarted " \
160
+ "(chi sessions restart ID): #{live.map { |r| r.id[0, 8] }.join(", ")}")
161
+ end
162
+ @stdout.puts("#{rows.size} saved session(s) in it join the project in a running chi web after its restart")
163
+ rescue StandardError => e
164
+ error_line("#{command_name}: couldn't list this machine's sessions (#{e.message})")
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "guardrails/path_resolve"
5
+ require_relative "memory_paths"
6
+ require_relative "paths"
7
+
8
+ module Samagotchi
9
+ # The shared decisions for `chi project init` and chi web's project picker.
10
+ module ProjectInit
11
+ Outcome = Data.define(:kind, :reason, :root, :top, :nested_in, :message)
12
+
13
+ MARKER_TEXT = "# chi project marker: this folder is a chi project (docs/memory.md)\n"
14
+
15
+ module_function
16
+
17
+ def call(dir, home:)
18
+ dir = File.expand_path(dir.to_s)
19
+ found = MemoryPaths.lookup(dir, home: home)
20
+ if found.git?
21
+ message = "#{Paths.tilde(dir)} is in the git repository #{Paths.tilde(found.top)}: the repository already is " \
22
+ "the project, nothing written"
23
+ return Outcome.new(kind: :git, reason: nil, root: found.root, top: found.top, nested_in: nil, message: message)
24
+ end
25
+ if MemoryPaths.above_home?(dir, home: home)
26
+ message = "#{Paths.tilde(dir)} is $HOME, a folder above it or /: it can't be a chi project " \
27
+ "(every folder under it would be one); mark a folder inside it"
28
+ return Outcome.new(kind: :refused, reason: :above_home, root: found.root, top: found.top, nested_in: nil,
29
+ message: message)
30
+ end
31
+
32
+ # The marked project this folder sits in, if any: the folder itself (already marked) or its parent project.
33
+ enclosing = found.marker? ? found.root : nil
34
+ if enclosing == Guardrails::PathResolve.real(dir)
35
+ return Outcome.new(kind: :already_marked, reason: nil, root: enclosing, top: found.top, nested_in: nil,
36
+ message: "#{Paths.tilde(enclosing)} is already a chi project")
37
+ end
38
+
39
+ write_marker(dir)
40
+ root = File.realpath(dir)
41
+ # No message: each surface words its own success (the CLI's report, the web's navigation).
42
+ Outcome.new(kind: :created, reason: nil, root: root, top: root, nested_in: enclosing, message: nil)
43
+ rescue SystemCallError => e
44
+ marker = File.join(dir, MemoryPaths::MARKER)
45
+ write_failed("couldn't write #{Paths.tilde(marker)}: #{e.message}")
46
+ end
47
+
48
+ # chi web opens a folder in a git repository without marking it, so the folder has to exist.
49
+ # @return [Outcome, nil] a refused (:write_failed) outcome when it can't be made, else nil
50
+ def ensure_folder(dir)
51
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
52
+ nil
53
+ rescue SystemCallError => e
54
+ write_failed("couldn't create #{Paths.tilde(dir)}: #{e.message}")
55
+ end
56
+
57
+ def write_failed(message)
58
+ Outcome.new(kind: :refused, reason: :write_failed, root: nil, top: nil, nested_in: nil, message: message)
59
+ end
60
+ private_class_method :write_failed
61
+
62
+ def write_marker(dir)
63
+ marker = File.join(dir, MemoryPaths::MARKER)
64
+ FileUtils.mkdir_p(File.dirname(marker))
65
+ File.write(marker, MARKER_TEXT)
66
+ end
67
+ private_class_method :write_marker
68
+ end
69
+ end
@@ -4,9 +4,10 @@ require_relative "memory_paths"
4
4
 
5
5
  module Samagotchi
6
6
  # Which project a folder belongs to, for scoping session lists (web and
7
- # terminal alike): the project root memories use (MemoryPaths.project_root),
8
- # so every worktree and subfolder of one repository is one project. A
9
- # folder in no git repository has no project (nil): its scope is "all".
7
+ # terminal alike): the project root memories use (MemoryPaths.lookup), so
8
+ # every worktree and subfolder of one repository, or every subfolder of a
9
+ # folder marked by `chi project init`, is one project. A folder in neither
10
+ # has no project (nil): its scope is "all".
10
11
  module ProjectScope
11
12
  module_function
12
13
 
@@ -17,7 +18,8 @@ module Samagotchi
17
18
  return nil if dir.empty?
18
19
  return cache[dir] if cache&.key?(dir)
19
20
 
20
- root = MemoryPaths.in_repo?(dir) ? MemoryPaths.project_root(File.expand_path(dir)) : nil
21
+ found = MemoryPaths.lookup(File.expand_path(dir))
22
+ root = found.project? ? found.root : nil
21
23
  cache[dir] = root if cache
22
24
  root
23
25
  end
@@ -19,9 +19,10 @@ module Samagotchi
19
19
  module_function
20
20
 
21
21
  # The AGENTS.md files a session in +cwd+ loads, as absolute paths,
22
- # outermost first: inside a git work tree every one from its top (a
23
- # linked worktree's own checkout) down to the cwd; outside a repository
24
- # only the cwd's ([] with none).
22
+ # outermost first: inside a project every one from its top
23
+ # (MemoryPaths.project_top: a git work tree's, a linked worktree's own
24
+ # checkout, or a folder marked by `chi project init`) down to the cwd;
25
+ # outside a project only the cwd's ([] with none).
25
26
  # @param cwd [String] where the prompt is built (Dir.pwd, not the
26
27
  # session's working directory: /tmp vs /private/tmp would change the
27
28
  # prompt bytes)
@@ -33,10 +34,10 @@ module Samagotchi
33
34
  # The sections of the AGENTS.md chain (#agents_paths) with text: the
34
35
  # first keeps the plain header (a one-file session's prompt is as it
35
36
  # always was), each further one names its directory relative to the
36
- # work tree top ("Project specific description for pkg/:").
37
+ # project's top ("Project specific description for pkg/:").
37
38
  # @return [Array<Section>]
38
39
  def agents_sections(cwd)
39
- top = MemoryPaths.work_tree_root(cwd)
40
+ top = MemoryPaths.project_top(cwd)
40
41
  sections = []
41
42
  agents_paths(cwd).each do |path|
42
43
  dir = File.dirname(path)
@@ -47,10 +48,12 @@ module Samagotchi
47
48
  sections
48
49
  end
49
50
 
50
- # The directories whose AGENTS.md a session in +cwd+ reads, outermost first.
51
+ # The directories whose AGENTS.md a session in +cwd+ reads, outermost
52
+ # first: from its project's top (the work tree's, or the marked root)
53
+ # down to +cwd+.
51
54
  def chain_dirs(cwd)
52
55
  cwd = File.expand_path(cwd)
53
- top = MemoryPaths.work_tree_root(cwd)
56
+ top = MemoryPaths.project_top(cwd)
54
57
  return [cwd] unless top
55
58
 
56
59
  rel = relative_dir(cwd, top)
@@ -63,38 +63,31 @@ module Samagotchi
63
63
  nil
64
64
  end
65
65
 
66
- # The level for +target+ and where it came from ("session",
66
+ # The level for +lookup+'s model and where it came from ("session",
67
67
  # "SAMAGOTCHI_REASONING_HISTORY", "models: qwen", "hosts.work"; nil for
68
68
  # the default).
69
- # @param target [HostRegistry::ModelTarget]
70
- # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
71
- # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
69
+ # @param lookup [ModelLookup] the model's layers (EffectiveModel#lookup)
72
70
  # @param session [Symbol, nil] the session's own level (Session#reasoning_history)
73
71
  # @return [Array(Symbol, String|nil)]
74
- def resolve(target, names:, models: nil, session: nil)
72
+ def resolve(lookup, session: nil)
75
73
  return [session, SESSION_SOURCE] if session
76
74
 
77
- env = env_level
75
+ env = env_level(lookup)
78
76
  return [env, ENV_NAME] if env
79
77
 
80
- models ||= begin
81
- ModelSettings.all
82
- rescue StandardError
83
- {}
84
- end
85
- setting = ModelSettings.find(names, :reasoning_history, models: models)
78
+ setting = lookup.model(:reasoning_history)
86
79
  return [setting.value, "models: #{setting.key}"] if setting
87
80
 
88
- host = target.entry&.reasoning_history
89
- return [host, "hosts.#{target.entry.name}"] if host
81
+ host = lookup.host(:reasoning_history)
82
+ return [host, "hosts.#{lookup.host_name}"] if host
90
83
 
91
- [API_DEFAULTS.fetch(target.entry&.api&.to_sym, DEFAULT), nil]
84
+ [API_DEFAULTS.fetch(lookup.entry&.api&.to_sym, DEFAULT), nil]
92
85
  end
93
86
 
94
87
  # #resolve, as a SettingExplained with the session's own level beside it.
95
88
  # @return [SettingExplained]
96
- def explain(target, names:, models: nil, session: nil)
97
- level, source = resolve(target, names: names, models: models, session: session)
89
+ def explain(lookup, session: nil)
90
+ level, source = resolve(lookup, session: session)
98
91
  SettingExplained.new(level: level, source: source, own: session)
99
92
  end
100
93
 
@@ -150,9 +143,10 @@ module Samagotchi
150
143
  # "drop (models: qwen)", "turn (default)".
151
144
  def label(level, source) = "#{level} (#{source || "default"})"
152
145
 
153
- # SAMAGOTCHI_REASONING_HISTORY as a level, nil when unset or unknown.
154
- def env_level
155
- raw, origin = Config.get_with_origin(KEY)
146
+ # SAMAGOTCHI_REASONING_HISTORY (read with +lookup+'s env) as a level,
147
+ # nil when unset or unknown.
148
+ def env_level(lookup)
149
+ raw, origin = lookup.global(KEY)
156
150
  origin == :env ? level(raw, ENV_NAME) : nil
157
151
  rescue StandardError
158
152
  nil
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "steer"
4
+
5
+ module Samagotchi
6
+ # Where a saved recap sits among the turns a page draws: after the last
7
+ # turn opening it covers (Steer.turn_opening?, the rule its turns_since
8
+ # count goes by). Its stamp is that opening's id, as a card's is
9
+ # (Steer.opening_stamp: a prompt's turn_id, a wake note's, a /continue no
10
+ # reason line's opening_id), so the page finds the bubble by
11
+ # id, not by counting back (a rollback or a check-in could make the count
12
+ # land a turn off). Read at read time from recap.json's `covered`: the file
13
+ # is unchanged, an old one gets a stamp too. Pure.
14
+ module RecapPlace
15
+ module_function
16
+
17
+ # @param messages [Array<Hash>] the session's messages (symbol or string keys)
18
+ # @param covered [Integer] how many of them the recap covers
19
+ # @return [String, nil] the id of the last opening in the covered
20
+ # messages; nil for none, or one with no id (a line an older chi wrote)
21
+ def opening_id(messages, covered)
22
+ opening = Array(messages).first([covered.to_i, 0].max).reverse_each.find { |message| Steer.turn_opening?(message) }
23
+ opening && Steer.opening_stamp(opening)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../worker_link"
5
+
6
+ module Samagotchi
7
+ module ReplyWait
8
+ # Who runs the session, as one look of a wait reads it: one owner lock
9
+ # read and one WorkerLink resolve on it. Two readers on purpose:
10
+ # #owner? (any kind, a one-turn chi run's too: its turn is alive) for
11
+ # whether the worker is gone, #worker_owned? (a worker's lock) for
12
+ # whether a question waits for an answer (a one-turn run's is answered
13
+ # there; a dead worker's waits for no one).
14
+ class Liveness
15
+ def initialize(id, state_dir:)
16
+ @id = id
17
+ @state_dir = state_dir
18
+ end
19
+
20
+ # The lock's holder (SessionManager.session_owner).
21
+ # @return [OwnerLock::Owner, nil]
22
+ def owner
23
+ return @owner if defined?(@owner)
24
+
25
+ @owner = SessionManager.session_owner(@id, state_dir: @state_dir)
26
+ end
27
+
28
+ def owner? = !owner.nil?
29
+
30
+ def worker_owned?
31
+ return @worker_owned if defined?(@worker_owned)
32
+
33
+ @worker_owned = WorkerLink.resolve(Session.session_dir(@id, state_dir: @state_dir), unlink: false,
34
+ owner: owner).worker_owned?
35
+ end
36
+ end
37
+ end
38
+ end