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
@@ -99,28 +99,38 @@ module Samagotchi
99
99
  dest
100
100
  end
101
101
 
102
- # Copies the file at +path+ as +as+ before it is replaced or removed, when
103
- # that would lose something: its bytes are neither what the record says
104
- # was installed (no sha recorded: unknown, so it counts as edited) nor
105
- # +incoming+'s. A link (a dev install's) points at its source, which
106
- # nothing here touches.
107
- def copy_edit(path, as:, recorded: nil, incoming: nil)
108
- return if File.symlink?(path) || !File.file?(path)
109
- return if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
110
- return if !Provenance.recorded_sha(recorded).empty? && Provenance.sha_matches?(path, recorded)
111
-
112
- copy(path, as: as)
113
- end
114
-
115
- # copy_edit for every file under +dir+, which is about to go, at any depth
116
- # (kept as <label>/<its path in dir>). A linked dir isn't followed.
102
+ # Copies every file under +dir+, which is about to be replaced or
103
+ # removed, when that would lose something: its bytes are neither what
104
+ # the record says was installed (no sha recorded: unknown, so it counts
105
+ # as edited) nor the incoming source's, at any depth (kept as
106
+ # <label>/<its path in dir>). A link (a dev install's) points at its
107
+ # source, which nothing here touches; a linked dir isn't followed.
117
108
  # @param recorded [Hash, nil] path in dir => its recorded sha256
118
109
  # @param incoming [Hash] path in dir => the source replacing it
119
110
  def copy_edits(dir, label, recorded = nil, incoming = {})
120
- return if File.symlink?(dir) || !File.directory?(dir)
111
+ self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
112
+ end
113
+
114
+ # Whether #copy_edits would copy the file at +path+ (read-only): the rule
115
+ # ShippedUpdate's plan names its replaced files by.
116
+ def self.edit?(path, recorded: nil, incoming: nil)
117
+ return false if File.symlink?(path) || !File.file?(path)
118
+ return false if incoming && File.file?(incoming) && FileUtils.identical?(incoming, path)
119
+
120
+ Provenance.recorded_sha(recorded).empty? || !Provenance.sha_matches?(path, recorded)
121
+ end
122
+
123
+ # What #copy_edits would copy (read-only), as <label>/<its path in dir>.
124
+ def self.edits(dir, label, recorded = nil, incoming = {})
125
+ edited_in(dir, recorded, incoming).map { |file| "#{label}/#{file}" }
126
+ end
127
+
128
+ # The files under +dir+ (paths in it) #copy_edits copies.
129
+ def self.edited_in(dir, recorded, incoming)
130
+ return [] if File.symlink?(dir) || !File.directory?(dir)
121
131
 
122
- Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.each do |file|
123
- copy_edit(File.join(dir, file), as: "#{label}/#{file}", recorded: recorded&.[](file), incoming: incoming[file])
132
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.select do |file|
133
+ edit?(File.join(dir, file), recorded: recorded&.[](file), incoming: incoming[file])
124
134
  end
125
135
  end
126
136
 
@@ -4,8 +4,8 @@ require "fileutils"
4
4
  require "digest"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "provenance"
7
+ require_relative "file_actions"
7
8
  require_relative "index_updater"
8
- require_relative "merger"
9
9
  require_relative "trash"
10
10
  require_relative "../model_overlay"
11
11
 
@@ -64,17 +64,22 @@ module Samagotchi
64
64
  [key, Provenance.claimants(key, scope: target_scope, except: @name)]
65
65
  end.reject { |_, others| others.empty? }
66
66
 
67
+ # Each file's UNINSTALL row (its source-less FileState): an edit
68
+ # refuses, unless --force (which moves it to the trash with the rest).
67
69
  blocked = []
68
- files.each do |file_key, _meta|
69
- file_key_str = file_key.to_s
70
- target_path = File.join(target_dir, file_key_str)
71
- base_path = provenance.base_path(file_key_str)
72
- next unless File.exist?(target_path)
73
- next if shared.key?(file_key_str)
74
-
75
- if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
76
- blocked << file_key_str
77
- @warnings << "Skipped #{file_key_str}: local edits detected (#{FileStanding::FORCE_MOVES})"
70
+ unless @force
71
+ files.each_key do |file_key|
72
+ key = file_key.to_s
73
+ state = FileState.of(key, record: bundle, provenance: provenance, target_dir: target_dir, source_dir: nil,
74
+ claimants: ->(k) { shared.fetch(k, []) })
75
+ next unless FileActions.for(:uninstall, state)&.do == :refuse
76
+ # Kept for another bundle, read or not.
77
+ next if state.shared_with.any?
78
+
79
+ # Whether it was edited can't be told: the read raises (nothing lost).
80
+ FileDigest.hex(state.paths[:current]) if state.kind == :unreadable
81
+ blocked << key
82
+ @warnings << "Skipped #{key}: local edits detected (#{FileStanding::FORCE_MOVES})"
78
83
  end
79
84
  end
80
85
 
@@ -19,14 +19,14 @@ module Samagotchi
19
19
 
20
20
  module_function
21
21
 
22
- # The orphan candidates for +cwd+'s project, or nil: only inside a git
23
- # repository (outside one the root is cwd, and every src/ or tmp/ would
24
- # match), and only while the computed folder holds no *.md (a
22
+ # The orphan candidates for +cwd+'s project, or nil: only inside a
23
+ # project, a git repository or a marked folder (outside both the root is
24
+ # cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
25
25
  # memory_write's mkdir_p or a project-scope bundle install alone must
26
26
  # not silence it).
27
27
  # @return [Found, nil]
28
28
  def find(env: ENV, cwd: Dir.pwd)
29
- return nil unless MemoryPaths.in_repo?(cwd)
29
+ return nil unless MemoryPaths.in_project?(cwd)
30
30
 
31
31
  projects = MemoryPaths.projects_dir(env: env)
32
32
  computed = MemoryPaths.project_dir(env: env, cwd: cwd)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "config"
5
+ require_relative "guardrails/path_resolve"
5
6
 
6
7
  module Samagotchi
7
8
  # Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
@@ -9,11 +10,61 @@ module Samagotchi
9
10
  # ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
10
11
  # a fixture chi) moves memories together with config.yml.
11
12
  #
12
- # Project memories are keyed by the project root (see project_root), so every
13
- # worktree and subdirectory of one git repository shares one folder.
13
+ # Project memories are keyed by the project root (see #lookup): one folder
14
+ # per project root, a git repository (every worktree and subdirectory of it
15
+ # shares one folder) or a folder marked by `chi project init` (and its
16
+ # subfolders).
14
17
  module MemoryPaths
18
+ # The file that makes a folder in no git repository a chi project
19
+ # (`chi project init`). The file, not the bare .chi/ dir, is the marker.
20
+ MARKER = File.join(".chi", "project.yml").freeze
21
+
22
+ # How a folder's project was found (#lookup): +kind+ is :git, :marker or
23
+ # :none; +root+ is what memories are keyed on (#project_root: git's
24
+ # common-dir checkout, the marked folder's realpath, or the folder
25
+ # itself); +top+ is the folder a session reads its AGENTS.md chain from
26
+ # (the work tree's top, the marked root; nil for :none).
27
+ ProjectLookup = Data.define(:kind, :root, :top) do
28
+ def project? = kind != :none
29
+ def git? = kind == :git
30
+ def marker? = kind == :marker
31
+ end
32
+
15
33
  module_function
16
34
 
35
+ # The project +cwd+ belongs to, looked up once: a .git above it wins
36
+ # (git is the identity wherever it exists); only when there is none, the
37
+ # nearest MARKER above it, ignoring one in $HOME or any folder above it
38
+ # (and /), so a marker in ~ never makes every folder under home one
39
+ # project. The marker walk starts from +cwd+'s real path and the marked
40
+ # root is a realpath, as git's root is, so every spelling of a folder
41
+ # (/tmp, /var on macOS, a symlink into or out of a marked tree) gets the
42
+ # answer the worker's physical Dir.pwd gets.
43
+ # @param home [String, nil] $HOME by default; nil or blank ignores only /
44
+ # @return [ProjectLookup]
45
+ def lookup(cwd = Dir.pwd, home: default_home)
46
+ dot_git = find_dot_git(cwd)
47
+ return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
48
+
49
+ marked = nearest_marker(cwd, home: home)
50
+ return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
51
+
52
+ ProjectLookup.new(kind: :none, root: cwd, top: nil)
53
+ end
54
+
55
+ # Whether a marker at +dir+ is ignored: +dir+ is / or holds $HOME (both
56
+ # resolved; Guardrails::PathResolve.home_root?).
57
+ def above_home?(dir, home: default_home)
58
+ Guardrails::PathResolve.real(dir) == "/" || Guardrails::PathResolve.home_root?(dir, home)
59
+ end
60
+
61
+ # $HOME, or nil when it can't be found (no HOME, no passwd entry).
62
+ def default_home
63
+ Dir.home
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
17
68
  def system_dir(env: ENV)
18
69
  File.join(ConfigFile.config_dir(env: env), "memories")
19
70
  end
@@ -28,16 +79,31 @@ module Samagotchi
28
79
  # #project_key for a root already resolved (a session's project_root).
29
80
  def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
30
81
 
31
- # The repository's common git directory identifies the project; every linked
32
- # worktree shares it. Returns the checkout holding it when it is named .git,
33
- # otherwise the git dir itself (a bare repo, a proj/.bare layout,
34
- # --separate-git-dir, a submodule's .git/modules/<name>), so for those layouts
35
- # the "project root" is a git dir, not a checkout. Outside any repository the
36
- # root is cwd. No git subprocess: at most two small files are read.
37
- def project_root(cwd = Dir.pwd)
38
- dot_git = find_dot_git(cwd)
39
- return cwd unless dot_git
82
+ # The project root (#lookup): in a repository, its common git directory
83
+ # identifies the project; every linked worktree shares it. Returns the
84
+ # checkout holding it when it is named .git, otherwise the git dir itself
85
+ # (a bare repo, a proj/.bare layout, --separate-git-dir, a submodule's
86
+ # .git/modules/<name>), so for those layouts the "project root" is a git
87
+ # dir, not a checkout. Outside any repository, the marked folder holding
88
+ # +cwd+, else cwd. No git subprocess: at most two small files are read.
89
+ def project_root(cwd = Dir.pwd) = lookup(cwd).root
90
+
91
+ # Is +cwd+ in a project: a git repository or a marked folder?
92
+ def in_project?(cwd = Dir.pwd) = lookup(cwd).project?
93
+
94
+ # The marked folder (realpath) +cwd+'s project is, or nil (in a git
95
+ # repository, or in no project).
96
+ def marked_root(cwd = Dir.pwd)
97
+ found = lookup(cwd)
98
+ found.marker? ? found.root : nil
99
+ end
100
+
101
+ # The top of +cwd+'s project: the git work tree's top (#work_tree_root) or
102
+ # the marked root; nil in no project.
103
+ def project_top(cwd = Dir.pwd) = lookup(cwd).top
40
104
 
105
+ # The project root of the checkout whose .git is +dot_git+ (#project_root).
106
+ def git_root(dot_git)
41
107
  holder = File.dirname(dot_git)
42
108
  gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
43
109
  return holder unless gitdir
@@ -78,7 +144,8 @@ module Samagotchi
78
144
  File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
79
145
  end
80
146
 
81
- # Is +cwd+ inside a git repository (a .git somewhere above it)?
147
+ # Is +cwd+ inside a git repository (a .git somewhere above it)? Git only:
148
+ # a marked folder is not a repository (#in_project?).
82
149
  def in_repo?(cwd = Dir.pwd)
83
150
  !find_dot_git(cwd).nil?
84
151
  end
@@ -96,6 +163,28 @@ module Samagotchi
96
163
  end
97
164
  end
98
165
 
166
+ # The realpath of the nearest folder at or above +start+ holding MARKER,
167
+ # git or not (#lookup asks only outside a repository; `chi project` tells
168
+ # of one a repository makes ignored), or nil. A marker at or above +home+
169
+ # (#above_home?) ends the walk with nil: every folder above it is above
170
+ # home too.
171
+ def nearest_marker(start, home: default_home)
172
+ dir = Guardrails::PathResolve.real(start)
173
+ loop do
174
+ if File.file?(File.join(dir, MARKER))
175
+ return nil if above_home?(dir, home: home)
176
+
177
+ return File.realpath(dir)
178
+ end
179
+ parent = File.dirname(dir)
180
+ return nil if parent == dir
181
+
182
+ dir = parent
183
+ end
184
+ rescue SystemCallError
185
+ nil
186
+ end
187
+
99
188
  # A ".git" file holds "gitdir: <path>", relative to the file's directory.
100
189
  def gitdir_from_file(path)
101
190
  line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
@@ -115,7 +204,7 @@ module Samagotchi
115
204
  rescue SystemCallError
116
205
  gitdir
117
206
  end
118
- private_class_method :find_dot_git, :gitdir_from_file, :common_dir
207
+ private_class_method :find_dot_git, :git_root, :gitdir_from_file, :common_dir
119
208
 
120
209
  def project_dir(env: ENV, cwd: Dir.pwd)
121
210
  File.join(projects_dir(env: env), project_key(cwd))
@@ -89,7 +89,7 @@ module Samagotchi
89
89
  def self.load_sessions(state_dir: Session.default_state_dir)
90
90
  sessions = []
91
91
  unreadable = 0
92
- Dir.glob(File.join(state_dir, "*#{Session::FILE_EXT}")).sort.each do |path|
92
+ Dir.glob(File.join(state_dir, "*#{SessionPaths::FILE_EXT}")).sort.each do |path|
93
93
  data = JSON.parse(File.read(path, encoding: "UTF-8"))
94
94
  # A project_root that is not a path (a hand-edited file) makes every
95
95
  # later step raise, so the file counts as unreadable rather than
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # The layered inputs a model's settings resolve from, in one value: the
7
+ # target (its host entry: the hosts: layer), the names its models: entry
8
+ # may be under (HostRegistry#lookup_names, most specific first), the
9
+ # models: map, and the env the CLI/env/file layers are read with.
10
+ # EffectiveModel builds one per read (with its registry's env); each
11
+ # setting module (Thinking, ReasoningHistory, SteerCutAfter,
12
+ # SamplingSettings, VisionSupport, ContextWindow, LLMContextStrategy) keeps
13
+ # its own ordered resolve over it, and ModelProfile takes its #models.
14
+ #
15
+ # The setting modules call it duck-typed and never name it, and it names
16
+ # none of them (only ModelSettings, ModelSetting, Config, ConfigFile): the
17
+ # modules' parsers are what ModelSettings calls, so either edge would close
18
+ # a cycle (spec/model_lookup_spec.rb checks this file's side).
19
+ #
20
+ # +given_models+ is a caller's models: map (one for a whole picker, or a
21
+ # spec's); without one, #models reads config.yml at each call, as every
22
+ # resolve always did.
23
+ ModelLookup = Data.define(:target, :names, :given_models, :env) do
24
+ # @param target [HostRegistry::ModelTarget, nil] nil: the global layers only
25
+ # @param names [Array<String>] HostRegistry#lookup_names
26
+ # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all by default
27
+ # @param env [Hash] ENV, or another env whose config file and variables
28
+ # every layer reads (chi self's)
29
+ def self.for(target, names:, models: nil, env: ENV)
30
+ new(target: target, names: Array(names), given_models: models, env: env)
31
+ end
32
+
33
+ # The models: map: the one given, else ModelSettings.all for #env; {}
34
+ # when it can't be read.
35
+ # @return [Hash{String => ModelSettings}]
36
+ def models
37
+ return given_models if given_models
38
+
39
+ env.equal?(ENV) ? ModelSettings.all : ModelSettings.all(env: env)
40
+ rescue StandardError # degrade-ok: the setting resolves from its other layers, as it always did
41
+ {}
42
+ end
43
+
44
+ def entry = target&.entry
45
+
46
+ # The host's name, as a "hosts.NAME" label names it.
47
+ def host_name = entry&.name
48
+
49
+ # The first lookup name whose models: entry sets +field+ (a
50
+ # ModelSettings member): a ModelSetting (key, value), or nil.
51
+ # @return [ModelSetting, nil]
52
+ def model(field) = ModelSettings.find(names, field, models: models)
53
+
54
+ # The host entry's +field+ (a HostEntry member), nil when unset or
55
+ # without a host.
56
+ def host(field) = entry&.public_send(field)
57
+
58
+ # [value, origin] of the global config key +key+ (:cli, :env, :file or
59
+ # :default), as Config.get_with_origin gives it; for another env its
60
+ # variables and config file, with this process's CLI settings. Raises as
61
+ # that does.
62
+ # @return [Array(Object, Symbol)]
63
+ def global(key)
64
+ return Config.get_with_origin(key) if env.equal?(ENV)
65
+
66
+ file_data = ConfigFile.read_yaml(env: env, path: ConfigFile.global_path(env: env))
67
+ Config.resolve_with_origin(key, file_data: file_data, env: env, cli_overrides: Config.cli_overrides)
68
+ end
69
+
70
+ # The model and the names only: the default would print the env (it
71
+ # holds API keys) and the target's client.
72
+ def inspect = "#<Samagotchi::ModelLookup #{target&.model.inspect} host=#{host_name.inspect} names=#{names.inspect}>"
73
+ alias_method :to_s, :inspect
74
+ end
75
+ end
@@ -286,6 +286,7 @@ module Samagotchi
286
286
  def self.default_relist(hosts)
287
287
  lambda do |host_name, relist_env|
288
288
  require_relative "host_registry"
289
+ # Not .current: the caller's hosts, and a fresh listing is the point.
289
290
  registry = Samagotchi::HostRegistry.new(hosts_config: hosts, env: relist_env)
290
291
  answer = nil
291
292
  thread = Thread.new { answer = registry.list_models(host_name) }
@@ -73,7 +73,7 @@ module Samagotchi
73
73
 
74
74
  # @return [Array(Hash, Boolean)] the payload and whether any host listed
75
75
  def catalog(timeout)
76
- registry = @registry || HostRegistry.new
76
+ registry = @registry || HostRegistry.current
77
77
  results = registry.list_all_models(force: true, wait: timeout)
78
78
  listed = results.values.any? { |data| data.error.nil? }
79
79
  [ModelCatalog.payload(results, registry: registry, default_name: default_name), listed]
@@ -97,7 +97,7 @@ module Samagotchi
97
97
 
98
98
  # --all: every live session. @return [Integer] exit status
99
99
  def deliver_to_live(text, source)
100
- ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map { |s| s[:id] }
100
+ ids = @access.summaries(live: true, include_tests: Session.test_session_env?).map(&:id)
101
101
  if ids.empty?
102
102
  error_line("chi note: no live sessions (chi sessions list --live --scope=all)")
103
103
  return 1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "delivery"
4
+ require_relative "session_paths"
4
5
 
5
6
  module Samagotchi
6
7
  # The rules for a participant's turn: the one place that decides them.
@@ -75,7 +76,7 @@ module Samagotchi
75
76
 
76
77
  require_relative "session"
77
78
  require_relative "worker_link"
78
- dir = Session.session_dir(session.id, state_dir: state_dir || Session.default_state_dir)
79
+ dir = SessionPaths.session_dir(session.id, state_dir: state_dir || SessionPaths.default_state_dir)
79
80
  link = WorkerLink.resolve(dir, unlink: false)
80
81
  link.live? ? link.features : nil
81
82
  rescue StandardError
@@ -32,10 +32,7 @@ module Samagotchi
32
32
  # plugin writes it (ApiCalls#keywords: `ctx.sessions.send(…,
33
33
  # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
34
34
  # Events aren't listed: chi.on takes any symbol and an older chi never
35
- # fires one it doesn't know (harmless; chi bundle check warns on it),
36
- # and a manifest hook's events: list (BundleHook) needs the bundle's
37
- # requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
38
- # scan covers manifest.yml.
35
+ # fires one it doesn't know (harmless; chi bundle check warns on it).
39
36
  SINCE = {
40
37
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
41
38
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -57,9 +54,14 @@ module Samagotchi
57
54
  "ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0",
58
55
  "ctx.session_dir" => "0.143.0", "ctx.session_dir(create:)" => "0.144.0",
59
56
  "ctx.objectives" => "0.149.0", "ctx.turn_running?" => "0.149.0",
60
- "ctx.last_answer" => "0.154.0"
57
+ "ctx.last_answer" => "0.154.0",
58
+ "chi.on(on_error:)" => "0.157.0"
61
59
  }.freeze
62
60
 
61
+ # chi.on's on_error: values. :log (the default) logs a raise and goes
62
+ # on; :fail_closed (before_tool_call only) denies the call it raised on.
63
+ ON_ERROR = %i[log fail_closed].freeze
64
+
63
65
  # @param bundle [String]
64
66
  # @param label [String] "<file> (bundle <name>)": what hooks and
65
67
  # failures are named by
@@ -222,14 +224,31 @@ module Samagotchi
222
224
  # logged and skipped; its first failure in this worker also tells the
223
225
  # user, once (Registries#hook_failed).
224
226
  # @param priority [Integer] lower runs first among bundle hooks
225
- def on(event, priority: 100, &block)
227
+ # @param on_error [Symbol] :log, or :fail_closed for a before_tool_call
228
+ # guard: a raise denies that call. A plugin that registers one is a
229
+ # guardrail (#fail_closed?): if it then fails to load, every tool call
230
+ # is denied (Loader; the manifest's plugin: required: true says so
231
+ # before it registers anything).
232
+ def on(event, priority: 100, on_error: :log, &block)
226
233
  raise ArgumentError, "on(#{event.inspect}) needs a block" unless block
227
234
  raise ArgumentError, "on: the event must be a Symbol or String" unless event.is_a?(Symbol) || event.is_a?(String)
228
235
 
229
- @hooks << { event: event.to_sym, priority: Integer(priority), block: block }
236
+ event = event.to_sym
237
+ policy = on_error.is_a?(String) ? on_error.to_sym : on_error
238
+ raise ArgumentError, "on(#{event.inspect}): on_error: must be :log or :fail_closed, not #{on_error.inspect}" unless ON_ERROR.include?(policy)
239
+ if policy == :fail_closed && event != :before_tool_call
240
+ raise ArgumentError, "on(#{event.inspect}): on_error: :fail_closed is for :before_tool_call only"
241
+ end
242
+
243
+ @hooks << { event: event, priority: Integer(priority), on_error: policy, block: block }
230
244
  nil
231
245
  end
232
246
 
247
+ # Whether #register staged a fail-closed hook (chi.on(on_error:
248
+ # :fail_closed)): the plugin is a guardrail, so its failing to load
249
+ # denies every tool call (Loader).
250
+ def fail_closed? = @hooks.any? { |hook| hook[:on_error] == :fail_closed }
251
+
233
252
  # A long-lived thing the plugin keeps (a server process): the block
234
253
  # starts it and returns what #value gives; in it, svc.on_stop { }
235
254
  # says how to stop it. It starts on first svc.value, or now with
@@ -451,10 +470,13 @@ module Samagotchi
451
470
  @tools.each { |tool| @registries.tools.register(tool.name, source: @bundle, **tool.entry_fields(context)) }
452
471
  @hooks.each do |hook|
453
472
  block = hook[:block]
454
- # Never stderr (echo: false): a turn's live region is on screen.
455
- handler = Hooks.wrap(label: "#{@label} #{hook[:event]} hook", event: hook[:event], policy: :log,
473
+ # Never stderr (echo: false): a turn's live region is on screen. A
474
+ # fail-closed one denies the call it raised on (Hooks.wrap's :deny).
475
+ policy = hook[:on_error] == :fail_closed ? :deny : :log
476
+ label = "#{"guardrail " if policy == :deny}#{@label} #{hook[:event]} hook"
477
+ handler = Hooks.wrap(label: label, event: hook[:event], policy: policy,
456
478
  log: [:plugins, "plugin_hook_failed"], echo: false, fields: { bundle: @bundle },
457
- on_failure: @registries.hook_failed) do |event|
479
+ on_failure: hook_failed(policy)) do |event|
458
480
  # ctx's helpers act as this event's while the block runs.
459
481
  context ? context.with_event(event) { block.call(event, context) } : block.call(event, context)
460
482
  end
@@ -478,6 +500,16 @@ module Samagotchi
478
500
  @committed = true
479
501
  end
480
502
 
503
+ # Registries#hook_failed for a hook under +policy+: a fail-closed one's
504
+ # notice says it denies (effect: :deny).
505
+ def hook_failed(policy)
506
+ told = @registries.hook_failed
507
+ return told unless told && policy == :deny
508
+
509
+ ->(label, failed) { told.call(label, failed, effect: :deny) }
510
+ end
511
+ private :hook_failed
512
+
481
513
  # @return [Hash] how many of each it registered (for the log)
482
514
  def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
483
515
  services: @services.size, inits: @inits.size, toolkits: @toolkits.size,