samagotchi 0.155.0 → 0.157.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 (234) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +2 -1
  4. data/docs/architecture.md +22 -13
  5. data/docs/bundles.md +3 -1
  6. data/docs/cli.md +9 -8
  7. data/docs/configuration.md +36 -4
  8. data/docs/guardrails.md +9 -5
  9. data/docs/hooks.md +11 -5
  10. data/docs/internals/anthropic.md +1 -1
  11. data/docs/internals/attached-context.md +3 -2
  12. data/docs/internals/background-tasks.md +1 -1
  13. data/docs/internals/bridge.md +83 -6
  14. data/docs/internals/bundles.md +28 -18
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +641 -937
  17. data/docs/internals/llm-context-forget.md +8 -3
  18. data/docs/internals/objectives.md +2 -2
  19. data/docs/internals/participants.md +3 -2
  20. data/docs/internals/prompt-caching.md +4 -1
  21. data/docs/internals/remote-sessions.md +8 -3
  22. data/docs/internals/web-frontend.md +3 -2
  23. data/docs/internals/working-set.md +2 -2
  24. data/docs/memory.md +13 -2
  25. data/docs/plugins.md +35 -2
  26. data/docs/sessions.md +11 -11
  27. data/docs/sub-agent.md +17 -2
  28. data/docs/system-one.md +3 -2
  29. data/docs/testing.md +53 -2
  30. data/lib/samagotchi/answer_command.rb +2 -1
  31. data/lib/samagotchi/archive_store.rb +4 -3
  32. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  33. data/lib/samagotchi/bootstrap_command.rb +3 -2
  34. data/lib/samagotchi/bridge/card_places.rb +10 -4
  35. data/lib/samagotchi/bridge/card_store.rb +3 -0
  36. data/lib/samagotchi/bridge/turn_accumulator.rb +109 -9
  37. data/lib/samagotchi/bridge/turn_drawings.rb +265 -0
  38. data/lib/samagotchi/bridge.rb +31 -6
  39. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  40. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  41. data/lib/samagotchi/broadcast_command.rb +3 -3
  42. data/lib/samagotchi/bundle_command/check.rb +18 -0
  43. data/lib/samagotchi/bundle_command/try/sandbox.rb +2 -2
  44. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  45. data/lib/samagotchi/bundle_command.rb +5 -2
  46. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  47. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  48. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  49. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  50. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  51. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  52. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  53. data/lib/samagotchi/child_move.rb +13 -57
  54. data/lib/samagotchi/child_reports.rb +2 -2
  55. data/lib/samagotchi/children_status.rb +15 -21
  56. data/lib/samagotchi/cli/parent_wait.rb +9 -23
  57. data/lib/samagotchi/config.rb +20 -112
  58. data/lib/samagotchi/context_command.rb +1 -2
  59. data/lib/samagotchi/context_sources.rb +5 -4
  60. data/lib/samagotchi/context_window.rb +9 -12
  61. data/lib/samagotchi/desktop/macos.rb +1 -1
  62. data/lib/samagotchi/effective_model.rb +57 -22
  63. data/lib/samagotchi/engine/context_judge.rb +19 -11
  64. data/lib/samagotchi/engine/messages.rb +1 -1
  65. data/lib/samagotchi/engine/models.rb +5 -2
  66. data/lib/samagotchi/engine/objectives.rb +29 -11
  67. data/lib/samagotchi/engine/plugins.rb +1 -1
  68. data/lib/samagotchi/engine/recap.rb +8 -4
  69. data/lib/samagotchi/engine/side_requests.rb +21 -11
  70. data/lib/samagotchi/engine/toolkit.rb +10 -9
  71. data/lib/samagotchi/engine/turn_end.rb +1 -1
  72. data/lib/samagotchi/engine/turn_helpers.rb +7 -7
  73. data/lib/samagotchi/engine/verify.rb +1 -1
  74. data/lib/samagotchi/engine.rb +43 -12
  75. data/lib/samagotchi/events.rb +3 -2
  76. data/lib/samagotchi/extension_load.rb +6 -3
  77. data/lib/samagotchi/guardrails/targets.rb +24 -9
  78. data/lib/samagotchi/guardrails_command.rb +1 -1
  79. data/lib/samagotchi/host_address.rb +52 -0
  80. data/lib/samagotchi/host_config.rb +72 -1
  81. data/lib/samagotchi/host_model.rb +2 -6
  82. data/lib/samagotchi/host_registry.rb +58 -46
  83. data/lib/samagotchi/kernel_loop.rb +53 -124
  84. data/lib/samagotchi/llm/anthropic_wire.rb +2 -2
  85. data/lib/samagotchi/llm/chat_loop.rb +40 -21
  86. data/lib/samagotchi/llm/native_backend.rb +1 -1
  87. data/lib/samagotchi/llm_context_apply.rb +54 -1
  88. data/lib/samagotchi/llm_context_notice.rb +1 -1
  89. data/lib/samagotchi/llm_context_strategy.rb +29 -67
  90. data/lib/samagotchi/llm_context_view.rb +9 -0
  91. data/lib/samagotchi/memory_bundle/asset_installer.rb +71 -76
  92. data/lib/samagotchi/memory_bundle/builder.rb +2 -1
  93. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  94. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  95. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  96. data/lib/samagotchi/memory_bundle/installed_copy.rb +2 -1
  97. data/lib/samagotchi/memory_bundle/installed_manifest.rb +8 -1
  98. data/lib/samagotchi/memory_bundle/installer.rb +185 -169
  99. data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
  100. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  101. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  102. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  103. data/lib/samagotchi/memory_bundle/provenance.rb +15 -1
  104. data/lib/samagotchi/memory_bundle/remerge.rb +25 -12
  105. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -6
  106. data/lib/samagotchi/memory_bundle/status.rb +14 -20
  107. data/lib/samagotchi/memory_bundle/system_bundle.rb +24 -6
  108. data/lib/samagotchi/memory_bundle/trash.rb +23 -8
  109. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  110. data/lib/samagotchi/memory_usage.rb +1 -1
  111. data/lib/samagotchi/model_lookup.rb +75 -0
  112. data/lib/samagotchi/model_price.rb +4 -10
  113. data/lib/samagotchi/model_profile.rb +7 -6
  114. data/lib/samagotchi/model_settings.rb +37 -2
  115. data/lib/samagotchi/models_command.rb +1 -1
  116. data/lib/samagotchi/note_command.rb +2 -3
  117. data/lib/samagotchi/objectives_audit.rb +4 -3
  118. data/lib/samagotchi/participant_mode.rb +2 -1
  119. data/lib/samagotchi/participants.rb +2 -2
  120. data/lib/samagotchi/plugin/api.rb +41 -6
  121. data/lib/samagotchi/plugin/context.rb +1 -1
  122. data/lib/samagotchi/plugin/loader.rb +33 -10
  123. data/lib/samagotchi/plugin/sessions.rb +3 -6
  124. data/lib/samagotchi/plugin/tool_spec.rb +5 -3
  125. data/lib/samagotchi/project_env.rb +1 -1
  126. data/lib/samagotchi/reasoning_history.rb +14 -20
  127. data/lib/samagotchi/recap_place.rb +27 -0
  128. data/lib/samagotchi/reminder_carry.rb +1 -1
  129. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  130. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  131. data/lib/samagotchi/reply_wait/target.rb +113 -0
  132. data/lib/samagotchi/reply_wait.rb +22 -2
  133. data/lib/samagotchi/reprefill_meter.rb +45 -0
  134. data/lib/samagotchi/review_command.rb +1 -1
  135. data/lib/samagotchi/sampling_settings.rb +9 -16
  136. data/lib/samagotchi/self_report.rb +36 -37
  137. data/lib/samagotchi/send_command.rb +17 -23
  138. data/lib/samagotchi/session.rb +370 -445
  139. data/lib/samagotchi/session_access/local.rb +12 -11
  140. data/lib/samagotchi/session_access/remote.rb +22 -6
  141. data/lib/samagotchi/session_archive_command.rb +2 -2
  142. data/lib/samagotchi/session_chain.rb +7 -6
  143. data/lib/samagotchi/session_continue.rb +6 -5
  144. data/lib/samagotchi/session_delete_command.rb +1 -1
  145. data/lib/samagotchi/session_field.rb +133 -0
  146. data/lib/samagotchi/session_inbox.rb +49 -1
  147. data/lib/samagotchi/session_manager/delivery.rb +254 -0
  148. data/lib/samagotchi/session_manager/emptiness.rb +139 -0
  149. data/lib/samagotchi/session_manager/lifecycle.rb +274 -0
  150. data/lib/samagotchi/session_manager/listing.rb +118 -0
  151. data/lib/samagotchi/session_manager/removal.rb +190 -0
  152. data/lib/samagotchi/session_manager/spawn.rb +242 -0
  153. data/lib/samagotchi/session_manager/worker_command.rb +157 -0
  154. data/lib/samagotchi/session_manager.rb +28 -1541
  155. data/lib/samagotchi/session_metrics.rb +8 -5
  156. data/lib/samagotchi/session_paths.rb +57 -0
  157. data/lib/samagotchi/session_retention.rb +2 -2
  158. data/lib/samagotchi/session_row.rb +200 -0
  159. data/lib/samagotchi/session_setup.rb +15 -12
  160. data/lib/samagotchi/sessions_command.rb +11 -129
  161. data/lib/samagotchi/share_command.rb +1 -1
  162. data/lib/samagotchi/starting_marker.rb +65 -0
  163. data/lib/samagotchi/steer_cut_after.rb +7 -14
  164. data/lib/samagotchi/system_one/recorder.rb +49 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  166. data/lib/samagotchi/terminal_ui/exit_lines.rb +1 -1
  167. data/lib/samagotchi/terminal_ui/join_replay.rb +86 -8
  168. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  169. data/lib/samagotchi/terminal_ui.rb +5 -7
  170. data/lib/samagotchi/testing/install.rb +2 -1
  171. data/lib/samagotchi/testing/sessions.rb +1 -1
  172. data/lib/samagotchi/thinking.rb +16 -21
  173. data/lib/samagotchi/token_usage.rb +1 -1
  174. data/lib/samagotchi/tool_activity.rb +10 -4
  175. data/lib/samagotchi/tool_declarations.rb +2 -2
  176. data/lib/samagotchi/tool_output_cut.rb +17 -0
  177. data/lib/samagotchi/tool_response.rb +5 -3
  178. data/lib/samagotchi/tool_runner.rb +25 -5
  179. data/lib/samagotchi/toolkit_command.rb +1 -1
  180. data/lib/samagotchi/tools/delegate.rb +55 -45
  181. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  182. data/lib/samagotchi/tools/delegate_result.rb +5 -5
  183. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  184. data/lib/samagotchi/tools/index_hint.rb +14 -0
  185. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  186. data/lib/samagotchi/tools/registry.rb +28 -11
  187. data/lib/samagotchi/tools/send_note.rb +4 -6
  188. data/lib/samagotchi/tools/tool_index.rb +52 -51
  189. data/lib/samagotchi/tools/tool_surface.rb +22 -8
  190. data/lib/samagotchi/turn_context.rb +2 -1
  191. data/lib/samagotchi/turn_stages/context_judge.rb +38 -36
  192. data/lib/samagotchi/turn_stages/objectives.rb +56 -52
  193. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +13 -17
  194. data/lib/samagotchi/turn_stages/verify_anchors.rb +9 -10
  195. data/lib/samagotchi/turn_stages/warmup.rb +14 -15
  196. data/lib/samagotchi/update_command.rb +11 -7
  197. data/lib/samagotchi/verify_fresh.rb +7 -11
  198. data/lib/samagotchi/version.rb +1 -1
  199. data/lib/samagotchi/vision_support.rb +11 -14
  200. data/lib/samagotchi/web/app.rb +45 -28
  201. data/lib/samagotchi/web/message_display.rb +57 -7
  202. data/lib/samagotchi/web/participant_access.rb +3 -3
  203. data/lib/samagotchi/web/public/activity.js +16 -7
  204. data/lib/samagotchi/web/public/app.js +29 -8
  205. data/lib/samagotchi/web/public/bubbles.js +8 -3
  206. data/lib/samagotchi/web/public/cards_place.js +15 -0
  207. data/lib/samagotchi/web/public/commands.js +5 -1
  208. data/lib/samagotchi/web/public/data.js +4 -2
  209. data/lib/samagotchi/web/public/history_shape.js +220 -0
  210. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  211. data/lib/samagotchi/web/public/reload_check.js +71 -0
  212. data/lib/samagotchi/web/public/stage_model.js +7 -5
  213. data/lib/samagotchi/web/public/stage_view.js +7 -3
  214. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  215. data/lib/samagotchi/web/public/timing.js +43 -2
  216. data/lib/samagotchi/web/public/turn_block.js +215 -30
  217. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  218. data/lib/samagotchi/web/public/turn_events.js +10 -1
  219. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  220. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  221. data/lib/samagotchi/web/server.rb +4 -2
  222. data/lib/samagotchi/web/session_access_routes.rb +6 -6
  223. data/lib/samagotchi/web/session_control.rb +4 -4
  224. data/lib/samagotchi/web/session_create.rb +3 -3
  225. data/lib/samagotchi/web/session_hub.rb +9 -17
  226. data/lib/samagotchi/web/session_show.rb +67 -5
  227. data/lib/samagotchi/web/session_summary.rb +39 -28
  228. data/lib/samagotchi/worker.rb +83 -32
  229. data/lib/samagotchi/worker_boot.rb +184 -0
  230. data/lib/samagotchi/worker_link.rb +3 -0
  231. data/lib/samagotchi/worker_signals.rb +1 -1
  232. data/lib/samagotchi/worker_task_wakes.rb +2 -2
  233. metadata +32 -4
  234. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -33,6 +33,8 @@ module Samagotchi
33
33
  # Loaded on first use: SessionManager and its ~80 files only when a
34
34
  # command reaches it (chi context never does).
35
35
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
36
+ # A continue (#create with continues); it requires session_manager.
37
+ autoload :SessionContinue, File.expand_path("../session_continue", __dir__)
36
38
 
37
39
  module SessionAccess
38
40
  # This machine's sessions: the calls the session commands made before
@@ -63,11 +65,11 @@ module Samagotchi
63
65
  # @raise [Session::AmbiguousId]
64
66
  def resolve_id(given) = Session.resolve_id(given, **dir)
65
67
 
66
- # The full id of +given+, of a session that exists.
67
- # @raise [ArgumentError] no such session (Session::AmbiguousId: a
68
+ # The full id of +given+, of a session that exists (and loads).
69
+ # @raise [Session::NotFound] no such session (Session::AmbiguousId: a
68
70
  # prefix of several)
69
71
  def resolve(given)
70
- id = resolve_id(given)
72
+ id = Session.resolve!(given, **dir)
71
73
  show(id)
72
74
  id
73
75
  end
@@ -79,12 +81,11 @@ module Samagotchi
79
81
  # @return [OwnerLock::Owner, nil] the worker or one-turn chi run that owns it
80
82
  def owner(id) = SessionManager.session_owner(id, **dir)
81
83
 
82
- # SessionManager.list_sessions: Session objects, for chi sessions list.
83
- def list(**filters) = SessionManager.list_sessions(**filters, **dir)
84
-
85
- # SessionManager.session_summaries: the summary hashes (chi sessions
86
- # list --live/--format, chi note --all).
87
- def summaries(**filters) = SessionManager.session_summaries(**filters, **dir)
84
+ # SessionManager.session_rows: the SessionRows chi sessions list
85
+ # prints and chi note --all reaches (chi web's summaries route answers
86
+ # their #to_summary_h).
87
+ # @return [Array<SessionRow>]
88
+ def summaries(**filters) = SessionManager.session_rows(**filters, **dir)
88
89
 
89
90
  # The session's worker for an attach: the live one as is, else
90
91
  # resumed and waited for up to +wait+ seconds (chi --attach on this
@@ -140,7 +141,7 @@ module Samagotchi
140
141
 
141
142
  start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
142
143
  if spec.continues
143
- return SessionManager.continue_session(spec.continues, **start, model: spec.model_name, **dir)
144
+ return SessionContinue.run(spec.continues, **start, model: spec.model_name, **dir)
144
145
  end
145
146
 
146
147
  SessionManager.spawn_session(**start, working_directory: spec.working_directory, model_name: spec.model_name,
@@ -269,7 +270,7 @@ module Samagotchi
269
270
  # @return [Boolean] whether the worker released the session
270
271
  def stop(id, wait:) = SessionManager.stop_session(id, wait: wait, **dir)
271
272
 
272
- # @return [SessionManager::RestartResult]
273
+ # @return [SessionManager::Restarted]
273
274
  def restart(id) = SessionManager.restart_session(id, **dir)
274
275
 
275
276
  # @return [SessionManager::ArchiveResult]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../session"
4
+ require_relative "../session_row"
4
5
  require_relative "../session_access"
5
6
  require_relative "../remotes"
6
7
  require_relative "../reply_wait"
@@ -137,11 +138,11 @@ module Samagotchi
137
138
  end
138
139
  end
139
140
 
140
- # @raise [ArgumentError] no such session (Session::AmbiguousId: a
141
+ # @raise [Session::NotFound] no such session (Session::AmbiguousId: a
141
142
  # prefix of several)
142
143
  def resolve(given)
143
144
  id = resolve_id(given)
144
- raise ArgumentError, "Session not found: #{given}" if id == given.to_s && !exists?(id)
145
+ raise Session::NotFound, given if id == given.to_s && !exists?(id)
145
146
 
146
147
  id
147
148
  end
@@ -158,8 +159,9 @@ module Samagotchi
158
159
  # @return [OwnerLock::Owner, nil]
159
160
  def owner(id) = show(id).owner
160
161
 
161
- # The rows chi sessions list prints, as SessionManager.session_summaries
162
- # gives them (symbol keys).
162
+ # The rows chi sessions list prints: chi web's summaries
163
+ # (SessionRow#to_summary_h) read back as SessionRows.
164
+ # @return [Array<SessionRow>]
163
165
  def summaries(live: false, cwd: nil, limit: nil, include_tests: true, project_root: nil, include_archived: false,
164
166
  sort: nil, order: nil)
165
167
  query = { live: (1 if live), cwd: cwd, limit: limit, tests: (0 unless include_tests), dir: project_root,
@@ -167,7 +169,7 @@ module Samagotchi
167
169
  reply = request(:get, "/api/sessions/summaries", query: query)
168
170
  raise_refusal(reply) unless reply.status == 200
169
171
 
170
- Array(reply.json).map { |row| symbolize(row) }
172
+ Array(reply.json).map { |row| SessionRow.from_summary_h(symbolize(row)) }
171
173
  end
172
174
 
173
175
  # --- create and send
@@ -704,6 +706,20 @@ module Samagotchi
704
706
  reply.json["detail"].to_s.start_with?("no installed bundle resolves")
705
707
  end
706
708
 
709
+ # chi web's 404 not_found detail for a session it doesn't have:
710
+ # "no session X" (Session::NotFound), or "Session not found: X" from
711
+ # chi web 0.155.0 and older (compat N-1: the same line either way).
712
+ SESSION_NOT_FOUND = /\A(?:no session |Session not found: )(.+)\z/m
713
+
714
+ # Session::NotFound for a missing session's 404, else an
715
+ # ArgumentError with chi web's words (another route's 404). chi web
716
+ # never answers Session::NONE_YET ("no session yet") as a 404
717
+ # not_found; were it to, the pattern would read it as NotFound("yet").
718
+ def not_found_error(detail)
719
+ given = detail[SESSION_NOT_FOUND, 1]
720
+ given ? Session::NotFound.new(given) : ArgumentError.new(detail)
721
+ end
722
+
707
723
  # Raises what Local's callee raises for this refusal (chi web sends
708
724
  # the fields each class is built from; see docs/architecture.md, chi
709
725
  # web's routes for the session commands), else RemoteFailed.
@@ -714,7 +730,7 @@ module Samagotchi
714
730
  sid = json["session_id"]
715
731
  reason = json["reason"]&.to_sym
716
732
  case error
717
- when "not_found" then raise ArgumentError, message.to_s
733
+ when "not_found" then raise not_found_error(message.to_s)
718
734
  when "ambiguous_id" then raise Session::AmbiguousId.new(message, ids: Array(json["ids"]))
719
735
  when "owned_by_tui"
720
736
  raise SessionManager::ScratchSession, sid if json["scratch"] == true
@@ -82,7 +82,7 @@ module Samagotchi
82
82
  @stdout.puts("#{short} refused: #{e.message}")
83
83
  false
84
84
  rescue ArgumentError => e
85
- error_line("chi sessions archive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
85
+ error_line("chi sessions archive: #{e.message}")
86
86
  false
87
87
  rescue SystemCallError => e
88
88
  @stdout.puts("#{short} failed: #{e.message}")
@@ -96,7 +96,7 @@ module Samagotchi
96
96
  @stdout.puts(result[:unarchived].empty? ? "#{short} not archived" : "#{short} unarchived")
97
97
  true
98
98
  rescue ArgumentError => e
99
- error_line("chi sessions unarchive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
99
+ error_line("chi sessions unarchive: #{e.message}")
100
100
  false
101
101
  rescue SystemCallError => e
102
102
  @stdout.puts("#{short} failed: #{e.message}")
@@ -15,7 +15,7 @@ module Samagotchi
15
15
  # "the next run of the same routine". The chain is derived, never stored:
16
16
  # follow +continues+ back for the earlier links, forward (the session whose
17
17
  # +continues+ names this one) for the later ones. A link is continued at
18
- # most once (SessionManager.continue_session refuses a second), so the
18
+ # most once (SessionContinue.run refuses a second), so the
19
19
  # chain's latest link is the one nobody continues.
20
20
  module SessionChain
21
21
  # `--continues last:<id>`: the latest link of the chain <id> is in.
@@ -62,15 +62,16 @@ module Samagotchi
62
62
  # A session to continue as the CLI and the web name it: an id, a unique
63
63
  # prefix, or `last:<id or prefix>` for that chain's latest link.
64
64
  # @return [String] the full id of an existing session
65
- # @raise [ArgumentError] none such (Session::AmbiguousId for a prefix of several)
65
+ # @raise [Session::NotFound] none such (Session::AmbiguousId for a prefix of several)
66
66
  def resolve(ref, state_dir:)
67
67
  given = ref.to_s.strip
68
68
  last = given.start_with?(LAST_PREFIX)
69
69
  given = given.delete_prefix(LAST_PREFIX) if last
70
- raise ArgumentError, "no session #{ref}" unless Session.valid_id?(given)
71
-
72
- id = Session.resolve_id(given, state_dir: state_dir)
73
- raise ArgumentError, "no session #{ref}" unless Session.exist?(id, state_dir: state_dir)
70
+ id = begin
71
+ Session.resolve!(given, state_dir: state_dir)
72
+ rescue Session::NotFound
73
+ raise Session::NotFound, ref # as given, last: and all
74
+ end
74
75
 
75
76
  last ? latest(id, state_dir: state_dir) : id
76
77
  end
@@ -7,6 +7,7 @@ require_relative "session_inbox"
7
7
  require_relative "session_chain"
8
8
  require_relative "objectives"
9
9
  require_relative "child_move"
10
+ require_relative "starting_marker"
10
11
  require_relative "context_move"
11
12
  require_relative "archive_store"
12
13
  require_relative "recap_store"
@@ -17,9 +18,9 @@ require_relative "log"
17
18
  require_relative "session_manager"
18
19
 
19
20
  module Samagotchi
20
- # The next link of a session chain (chi send --new --continues, web
21
- # Continue →): SessionManager.continue_session runs .run. The archive,
22
- # spawn, stop and wake it builds on are SessionManager's.
21
+ # The next link of a session chain: chi send --new --continues and web
22
+ # Continue → run .run. The archive, spawn, stop and wake it builds on are
23
+ # SessionManager's.
23
24
  module SessionContinue
24
25
  # A continue that can't happen (#run). reason: :continued
25
26
  # (another session continues it already: +ids+ names it), :open_children
@@ -198,7 +199,7 @@ module Samagotchi
198
199
  Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
199
200
  end
200
201
 
201
- # The new link, holding off any worker but its own (ChildMove.starting?)
202
+ # The new link, holding off any worker but its own (StartingMarker.fresh?)
202
203
  # while +move+ goes to it.
203
204
  private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:)
204
205
  recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
@@ -206,7 +207,7 @@ module Samagotchi
206
207
  preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
207
208
  moved_context = nil
208
209
  before_spawn = lambda do |link|
209
- ChildMove.mark_starting(link.id, state_dir: state_dir)
210
+ StartingMarker.mark(link.id, state_dir: state_dir)
210
211
  ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
211
212
  # Its movable context too, with a note saying what moved and stayed.
212
213
  moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
@@ -76,7 +76,7 @@ module Samagotchi
76
76
  @stdout.puts("#{short} refused: #{line}")
77
77
  false
78
78
  rescue ArgumentError => e
79
- error_line("chi sessions delete: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
79
+ error_line("chi sessions delete: #{e.message}")
80
80
  false
81
81
  rescue SystemCallError => e
82
82
  @stdout.puts("#{short} failed: #{e.message}")
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # One key of a session file (a Session::FIELDS row): +key+ the file's
5
+ # key, +attr+ Session's attribute and keyword (nil for a key only the
6
+ # file has: the settings pair, written from Session's +from+), +default+
7
+ # the value of a file without the key (REQUIRED for one it must have),
8
+ # +fresh+ a new Session's when it isn't given (it differs only for
9
+ # metadata_version and messages), +optional+ a key written only when set,
10
+ # +cast+ what a value is read as (Session.new, .from_h and the writers),
11
+ # +write+ what the file gets from the attribute (the value as is without
12
+ # one). +unread+ and +keep+ are the Unread rule's (.level): what of a
13
+ # value the field couldn't read the session keeps, and how the file gets
14
+ # it back. A leaf: the types a row names are named in session.rb, in a
15
+ # block its lambdas call (`type.call`, not `yield`: never while the
16
+ # constructor runs, so Performance/RedundantBlockCall is off for them).
17
+ SessionField = Data.define(:key, :attr, :from, :default, :fresh, :optional, :cast, :write, :unread, :keep) do
18
+ # A field read as given, or through +cast+.
19
+ def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false)
20
+ new(key: key, attr: key.to_sym, from: nil, default: default, fresh: fresh, optional: optional, cast: cast,
21
+ write: write, unread: nil, keep: nil)
22
+ end
23
+
24
+ # rubocop:disable Performance/RedundantBlockCall
25
+ # A value object's field (nil for none): a value that is already one
26
+ # is taken as it is, nil is nil (the type isn't loaded for it: an
27
+ # autoloaded one stays unloaded), anything else is read with the type's
28
+ # +reader+ (from_file, of); the file gets its #to_file, through
29
+ # .scrub_utf8 with +scrub+. +type+ is a block, so the row names its type
30
+ # without loading it.
31
+ def self.value(key, reader, optional: false, scrub: false, &type)
32
+ cast = lambda do |value|
33
+ next nil if value.nil?
34
+
35
+ klass = type.call
36
+ value.is_a?(klass) ? value : klass.public_send(reader, value)
37
+ end
38
+ write = scrub ? ->(value) { value && scrub_utf8(value.to_file) } : ->(value) { value&.to_file }
39
+ new(key: key, attr: key.to_sym, from: nil, default: nil, fresh: nil, optional: optional, cast: cast,
40
+ write: write, unread: nil, keep: nil)
41
+ end
42
+
43
+ # A list of value objects ([] for none): read with the type's .list, each
44
+ # written with its #to_file.
45
+ def self.list(key, &type)
46
+ new(key: key, attr: key.to_sym, from: nil, default: [], fresh: [], optional: false,
47
+ cast: ->(value) { type.call.list(value) }, write: ->(values) { values.map(&:to_file) }, unread: nil,
48
+ keep: nil)
49
+ end
50
+
51
+ # A session's own level (nil for none), with the Unread rule: a value
52
+ # that isn't one reads as nil, and the session keeps it as written (a
53
+ # newer chi's level, a hand edit) and saves it back so, until it sets
54
+ # its own. A level is the type's .session_level of the value; a String
55
+ # that doesn't read as one, other than the +unset+ word (any case and
56
+ # spaces), is the one kept. With +merge_unread+ the field is a value
57
+ # object of several parts (LLMContextOverride: .from_file, .unread):
58
+ # the parts it couldn't read are kept, and the file gets them under the
59
+ # parts it has (nil when there is none).
60
+ def self.level(key, unset: nil, merge_unread: false, &type)
61
+ if merge_unread
62
+ cast = ->(value) { type.call.from_file(value) }
63
+ unread = ->(value) { type.call.unread(value) }
64
+ write = ->(value) { value&.to_file }
65
+ keep = lambda do |written, kept|
66
+ saved = kept.merge(written || {})
67
+ saved.empty? ? nil : saved
68
+ end
69
+ else
70
+ cast = ->(value) { type.call.session_level(value) }
71
+ unread = lambda do |value|
72
+ value if value.is_a?(String) && type.call.session_level(value).nil? && !value.strip.casecmp?(unset)
73
+ end
74
+ write = ->(value) { value&.to_s }
75
+ keep = ->(written, kept) { written || kept }
76
+ end
77
+ new(key: key, attr: key.to_sym, from: nil, default: nil, fresh: nil, optional: false, cast: cast, write: write,
78
+ unread: unread, keep: keep)
79
+ end
80
+ # rubocop:enable Performance/RedundantBlockCall
81
+
82
+ # A key only the file has: +write+ takes Session's +from+ attribute.
83
+ def self.file_only(key, default, from:, write:)
84
+ new(key: key, attr: nil, from: from, default: default, fresh: nil, optional: false, cast: nil, write: write,
85
+ unread: nil, keep: nil)
86
+ end
87
+
88
+ def required? = default.equal?(SessionField::REQUIRED)
89
+
90
+ def fresh_required? = fresh.equal?(SessionField::REQUIRED)
91
+
92
+ # A new Session's value when it isn't given: a fresh copy of a list or
93
+ # a Hash, never one shared with another session.
94
+ def fresh_value = fresh.is_a?(Array) || fresh.is_a?(Hash) ? fresh.dup : fresh
95
+
96
+ # The instance variable #to_h reads (never the reader: project_root's
97
+ # and verdicts' have logic of their own).
98
+ def ivar = :"@#{attr || from}"
99
+
100
+ # +value+ as the attribute holds it.
101
+ def load(value) = cast ? cast.call(value) : value
102
+
103
+ # The attribute's +value+ as the file holds it, with what the session
104
+ # kept of a value it couldn't read (+kept+, .level's).
105
+ def dump(value, kept = nil)
106
+ written = write ? write.call(value) : value
107
+ keep ? keep.call(written, kept) : written
108
+ end
109
+
110
+ # The last guard before JSON.generate, which raises on bytes that
111
+ # aren't UTF-8: a save that raised would lose the whole conversation.
112
+ # ToolRunner already scrubs tool output; this covers any other source.
113
+ def self.scrub_utf8(obj)
114
+ case obj
115
+ when String
116
+ str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
117
+ str.valid_encoding? ? str : str.scrub("?")
118
+ when Array then obj.map { |element| scrub_utf8(element) }
119
+ when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
120
+ else obj
121
+ end
122
+ end
123
+
124
+ # A Hash with String keys (one level).
125
+ def self.stringify_keys(hash)
126
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
127
+ end
128
+ end
129
+
130
+ # A field a session file must have (KeyError without it, ArgumentError
131
+ # from Session.new).
132
+ SessionField::REQUIRED = Object.new.freeze
133
+ end
@@ -442,7 +442,7 @@ module Samagotchi
442
442
  private_class_method :unmark_removed
443
443
 
444
444
  # How old a per-input tombstone (input/removed/, TurnCheckpoint's
445
- # input/settled/) gets before a worker's start prunes it (#prune_old).
445
+ # input/settled/, a merge record in input/merged/) gets before a worker's start prunes it (#prune_old).
446
446
  # A wait reads one at its next poll (ReplyWait::POLL_INTERVAL) and a
447
447
  # later `chi send --wait --turn` reads a settled one at its start, so
448
448
  # only a wait whose process slept (a closed laptop) or one started long
@@ -470,6 +470,54 @@ module Samagotchi
470
470
  # The removed tombstones past +max_age+ (#prune_old).
471
471
  def self.prune_removed(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, REMOVED_DIR, max_age: max_age)
472
472
 
473
+ # Where a running turn's drain records an input it merged as a steer:
474
+ # input/merged/<enqueued_id> = {"into": the turn's enqueued_id, "at"}.
475
+ # Its own directory: a file in input/settled/ reads as worker_stopped to
476
+ # a chi before it, and no *.json glob sees a subdirectory.
477
+ MERGED_DIR = "merged"
478
+
479
+ # Records that the input +id+ went into the turn +into+ (both
480
+ # enqueued_ids) as a steer, before the drain deletes its claimed file
481
+ # (Worker#pending_input_drain): a wait for it (ReplyWait's turn:) then
482
+ # waits for that turn (Poll#follow_merge). Nothing for an id that
483
+ # can't name a file, nor for a turn with none; a failed write only
484
+ # logs (the wait infers the merge, as for a worker before this).
485
+ def self.mark_merged(session_dir, id, into:, now: Time.now)
486
+ return unless ENQUEUED_ID_NAME.match?(id.to_s) && ENQUEUED_ID_NAME.match?(into.to_s)
487
+
488
+ dir = File.join(session_dir, INPUT_DIR, MERGED_DIR)
489
+ FileUtils.mkdir_p(dir)
490
+ AtomicFile.write(File.join(dir, id), JSON.generate("into" => into, "at" => now.iso8601(3)))
491
+ rescue SystemCallError, IOError => e
492
+ Log.warn(:worker, "merge_record_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
493
+ end
494
+
495
+ # A merge record (#mark_merged): the enqueued_id of the turn an input
496
+ # went into, and when (a Time, nil when unreadable).
497
+ Merge = Data.define(:into, :at)
498
+
499
+ # @return [Merge, nil] the turn the input +id+ was merged into
500
+ # (#mark_merged), nil without a readable record
501
+ def self.merged_into(session_dir, id)
502
+ return nil unless ENQUEUED_ID_NAME.match?(id.to_s)
503
+
504
+ raw = JSON.parse(File.read(File.join(session_dir, INPUT_DIR, MERGED_DIR, id.to_s)))
505
+ into = raw["into"]
506
+ return nil unless ENQUEUED_ID_NAME.match?(into.to_s)
507
+
508
+ at = begin
509
+ Time.iso8601(raw["at"].to_s)
510
+ rescue ArgumentError
511
+ nil
512
+ end
513
+ Merge.new(into: into, at: at)
514
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
515
+ nil
516
+ end
517
+
518
+ # The merge records past +max_age+ (#prune_old).
519
+ def self.prune_merged(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, MERGED_DIR, max_age: max_age)
520
+
473
521
  # @return [Boolean] whether the queued input +enqueued_id+ was removed
474
522
  # before it ran (#take_queued_input's tombstone), or is being removed:
475
523
  # the tombstone goes first, so while its claimed file is still there