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
@@ -10,13 +10,18 @@ require_relative "interrupted_turn"
10
10
  require_relative "last_turn"
11
11
  require_relative "pending_question"
12
12
  require_relative "llm_context_override"
13
+ require_relative "reasoning_history"
14
+ require_relative "thinking"
15
+ require_relative "toolkit"
16
+ require_relative "session_config"
13
17
  require_relative "session_setup"
14
18
  require_relative "prompt_note"
15
19
  require_relative "working_set_pin"
16
20
  require_relative "session_review"
21
+ require_relative "session_field"
22
+ require_relative "session_paths"
17
23
  require_relative "version"
18
24
 
19
- require_relative "paths"
20
25
  require_relative "project_scope"
21
26
  require_relative "guardrails/parent_levels"
22
27
 
@@ -29,9 +34,11 @@ module Samagotchi
29
34
  autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
30
35
 
31
36
  class Session
37
+ # The paths and the id rule (SessionPaths): Session.session_dir,
38
+ # .session_file, .default_state_dir, .valid_id?, .check_id!.
39
+ extend SessionPaths
40
+
32
41
  METADATA_VERSION = 3
33
- STATE_SUBDIR = File.join("samagotchi", "sessions")
34
- FILE_EXT = ".json"
35
42
 
36
43
  STATUS_IDLE = "idle"
37
44
  STATUS_RUNNING = "running"
@@ -65,195 +72,309 @@ module Samagotchi
65
72
  SORT_KEYS = %w[created_at updated_at].freeze
66
73
  SORT_ORDERS = %w[asc desc].freeze
67
74
 
68
- attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
69
- :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
70
- :used_memory_names
71
- # The id the prompt a session is created with gets (SessionManager
72
- # .spawn_session), so its first turn is a turn with an id like one sent
73
- # later: the worker's first turn takes it as its origin, its reply file
74
- # and last_turn carry it, and a wait for it (`chi send --new --wait`,
75
- # chi web's create) can name it (`--wait --turn T ID`). nil for a
76
- # session created idle, and for one saved before the field existed.
77
- attr_accessor :first_turn_id
78
- # Memories the session was started with (--memory) and memories hidden
79
- # from it (--mute): the worker rebuilds the same prompt on a respawn.
80
- # Names as given; the engine normalizes them.
81
- attr_accessor :preloaded_memory_names, :muted_memory_names
82
- # The name the user gave the session (/title, TitleCommand), else nil:
83
- # every list, card and tab shows it in place of #first_preview (see
84
- # #display_title). A continue's new link inherits it.
85
- attr_accessor :title
86
- # The files chi --append-system-prompt put into the session's system
87
- # prompt (absolute paths; config.yml's append_system_prompt: entries
88
- # aren't stored): kept on respawns, copied by a fork and a continue
89
- # (SessionSetup).
90
- attr_reader :appended_prompt_files
91
-
92
- def appended_prompt_files=(paths)
93
- @appended_prompt_files = Array(paths).map(&:to_s).reject(&:empty?).uniq
75
+ # A field a session file must have (KeyError without it).
76
+ REQUIRED = SessionField::REQUIRED
77
+
78
+ # How the plain rows read and write their values.
79
+ BOOLEAN = ->(value) { !!value }
80
+ NAMES = ->(names) { name_list(names) }
81
+ LIST = ->(value) { Array(value) }
82
+ ID = ->(value) { value&.to_s }
83
+ MESSAGES = ->(messages) { SessionField.scrub_utf8(messages.map { |msg| SessionField.stringify_keys(msg) }) }
84
+ VERDICTS = lambda do |records|
85
+ records&.map do |record|
86
+ SessionField.scrub_utf8(record.to_h do |key, entry|
87
+ [key.to_s, entry.is_a?(Hash) ? SessionField.stringify_keys(entry) : entry]
88
+ end)
89
+ end
94
90
  end
95
- # The model notes the session's system prompt carried at its last build
96
- # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
97
- # Engine at each build (a start, a resume's or a woken worker's first
98
- # turn, /model) and saved, so what a session ran with can be read later.
99
- attr_reader :prompt_notes
100
-
101
- # @param notes [Array<PromptNote, Hash>] a file's hashes are read
102
- # (PromptNote.list)
103
- def prompt_notes=(notes)
104
- @prompt_notes = PromptNote.list(notes)
91
+ TOOLKIT_ORIGIN = lambda do |value|
92
+ origin = value.to_s
93
+ TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
105
94
  end
106
- # The name model_name was typed as when that differs from the resolved
107
- # ref stored in model_name (an alias: `small` for `gemma-small`), else
108
- # nil. The models: lookup takes it (HostRegistry#lookup_names).
109
- attr_accessor :model_typed
110
- # The project the session was started in (ProjectScope.root_for its
111
- # folder), stored because worktrees are deleted after a merge and a
112
- # deleted folder no longer leads to its repository. nil outside a repo,
113
- # and in files written before the field existed (see #project_root).
114
- attr_writer :project_root
115
- # The session that delegated this one (the `delegate` tool) or that a
116
- # plugin forked it from (ctx.sessions.fork), else nil.
117
- # Set before the spawn and kept on respawns, like preloaded_memory_names.
118
- attr_accessor :parent_id
119
- # Started by the `delegate` tool (a fork has a parent_id too, but isn't
120
- # one): see #delegate?.
121
- attr_writer :delegate
122
- # The session this one continues (SessionManager.continue_session): the
123
- # previous link of a chain, "the next run of the same routine", else
124
- # nil. Not a parent: archiving one link leaves the next alone. The chain
125
- # itself is derived (SessionChain), never stored.
126
- attr_accessor :continues
127
- # A `chi scratch` session: deleted when its terminal leaves (its worker
128
- # deletes it too once its last client is gone), and by the next sweep
129
- # (or `chi sessions clean`) when both died first.
130
- attr_accessor :scratch
131
- # The question this session waits on (PendingQuestion), saved as
132
- # session.json's "pending_question"; nil with none. A Hash (a saved
133
- # record, string or symbol keys) is read with PendingQuestion.from_file.
134
- attr_reader :pending_question
95
+ PARENT_ANSWERS = lambda do |value|
96
+ case (level = Guardrails::ParentPolicy.parse_level(value))
97
+ when :unset then nil
98
+ when :invalid then value.to_s.strip
99
+ else level
100
+ end
101
+ end
102
+ private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :VERDICTS, :TOOLKIT_ORIGIN, :PARENT_ANSWERS
135
103
 
136
- def pending_question=(value)
137
- @pending_question = PendingQuestion.from_file(value)
104
+ # The fields of <id>.json, in the order #save writes them: key => its
105
+ # SessionField (the value for a file without it, REQUIRED for the ones
106
+ # it must have; how it is read and written). Session.new, .from_h and
107
+ # #to_h all go by this table: a new field is one row (plus a
108
+ # SessionSetup member when a fork and a continue carry it). Messages
109
+ # change key type on the way (symbols in memory, strings on disk).
110
+ #
111
+ # A new field: a chi older than it (from the release after 0.130.0)
112
+ # keeps the key as it was (#extra_fields) but doesn't honour it, and the
113
+ # next newer chi reads it again. So it must be safe to come back
114
+ # unchanged after an older chi ran turns on the session: static data, or
115
+ # append-only data the older chi never appends to (review, verify_fresh,
116
+ # first_turn_id, verdicts); a cursor into messages, which an older chi
117
+ # would have moved, needs a check against what it points at. Only
118
+ # top-level keys are kept: the value objects (SessionReview,
119
+ # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
120
+ # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
121
+ # only, so an older chi still drops a new sub-key of one of them; add a
122
+ # top-level field when an older chi must keep it. A removed key would
123
+ # stay an extra forever: leave it out of the extras in .from_h then (no
124
+ # key was ever removed).
125
+ FIELDS = [
126
+ SessionField.plain("metadata_version", 1, fresh: METADATA_VERSION),
127
+ # The chi version that last saved the loaded file (#to_h writes this
128
+ # chi's VERSION); nil for a file from 0.130.0 or older.
129
+ SessionField.plain("written_by", nil, write: ->(_) { VERSION }),
130
+ SessionField.plain("id", REQUIRED),
131
+ SessionField.plain("mode", REQUIRED),
132
+ SessionField.plain("model_name", REQUIRED),
133
+ # The name model_name was typed as when that differs from the resolved
134
+ # ref stored in model_name (an alias: `small` for `gemma-small`), else
135
+ # nil. The models: lookup takes it (HostRegistry#lookup_names).
136
+ SessionField.plain("model_typed", nil),
137
+ SessionField.plain("working_directory", REQUIRED),
138
+ # The conversation (symbol keys in memory; .from_h symbolizes a
139
+ # file's); a running turn's checkpoint is saved in its place (#to_h).
140
+ SessionField.plain("messages", [], fresh: REQUIRED, write: MESSAGES),
141
+ SessionField.plain("created_at", REQUIRED),
142
+ SessionField.plain("updated_at", REQUIRED),
143
+ SessionField.plain("status", STATUS_IDLE),
144
+ SessionField.plain("last_prompt", ""),
145
+ SessionField.plain("first_preview", ""),
146
+ # The name the user gave the session (/title, TitleCommand), else nil:
147
+ # every list, card and tab shows it in place of #first_preview (see
148
+ # #display_title). A continue's new link inherits it.
149
+ SessionField.plain("title", nil),
150
+ SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
151
+ # The question this session waits on (PendingQuestion), saved as
152
+ # session.json's "pending_question"; nil with none. A Hash (a saved
153
+ # record, string or symbol keys) is read with PendingQuestion.from_file.
154
+ SessionField.value("pending_question", :from_file, scrub: true) { PendingQuestion },
155
+ SessionField.plain("used_memory_names", [], cast: NAMES, write: LIST),
156
+ # The project the session was started in (ProjectScope.root_for its
157
+ # folder), stored because worktrees are deleted after a merge and a
158
+ # deleted folder no longer leads to its repository. nil outside a repo,
159
+ # and in files written before the field existed (see #project_root).
160
+ SessionField.plain("project_root", nil),
161
+ # Memories the session was started with (--memory) and memories hidden
162
+ # from it (--mute): the worker rebuilds the same prompt on a respawn.
163
+ # Names as given; the engine normalizes them.
164
+ SessionField.plain("preloaded_memory_names", [], cast: NAMES, write: LIST),
165
+ SessionField.plain("muted_memory_names", [], cast: NAMES, write: LIST),
166
+ # The model notes the session's system prompt carried at its last build
167
+ # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
168
+ # Engine at each build (a start, a resume's or a woken worker's first
169
+ # turn, /model) and saved, so what a session ran with can be read later.
170
+ SessionField.list("prompt_notes") { PromptNote },
171
+ # The session that delegated this one (the `delegate` tool) or that a
172
+ # plugin forked it from (ctx.sessions.fork), else nil.
173
+ # Set before the spawn and kept on respawns, like preloaded_memory_names.
174
+ SessionField.plain("parent_id", nil, cast: ID),
175
+ # A `chi scratch` session: deleted when its terminal leaves (its worker
176
+ # deletes it too once its last client is gone), and by the next sweep
177
+ # (or `chi sessions clean`) when both died first.
178
+ SessionField.plain("scratch", false, cast: BOOLEAN),
179
+ # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
180
+ # and the web's notifications (the hub sends it in the summary); nil
181
+ # before the first turn.
182
+ SessionField.value("last_turn", :from_file) { LastTurn },
183
+ # Verdict cards recorded by completed Verify turns, in append order.
184
+ # A missing field stays missing until the first verdict is recorded
185
+ # (#verdicts reads it as []). Cast on every set too.
186
+ SessionField.plain("verdicts", nil, cast: ->(values) { values.nil? ? nil : Array(values) }, write: VERDICTS,
187
+ optional: true),
188
+ # Started by the `delegate` tool (a fork has a parent_id too, but isn't
189
+ # one): see #delegate?.
190
+ SessionField.plain("delegate", false, cast: BOOLEAN),
191
+ # The session's own llm_context values (LLMContextOverride: strategy,
192
+ # apply rule, budget), before the model's; nil when it has none (an
193
+ # empty one is none). Saved, so a --resume and a respawned worker keep
194
+ # them; a plugin's fork copies them, a delegate child starts without.
195
+ # A file's parts it can't read are kept (LLMContextOverride.unread).
196
+ SessionField.level("llm_context", merge_unread: true) { LLMContextOverride },
197
+ # The session this one continues (SessionContinue.run): the
198
+ # previous link of a chain, "the next run of the same routine", else
199
+ # nil. Not a parent: archiving one link leaves the next alone. The chain
200
+ # itself is derived (SessionChain), never stored.
201
+ SessionField.plain("continues", nil, cast: ID),
202
+ # The session's own thinking level (:off, :low, :medium, :high), before
203
+ # the process default and the model's (Thinking.resolve); nil when it
204
+ # has none. Saved, so a --resume, a respawned worker and a continue keep
205
+ # it; a plugin's fork copies it, a delegate child starts without.
206
+ SessionField.level("thinking", unset: Thinking::DEFAULT.to_s) { Thinking },
207
+ # The session's own reasoning_history level (:drop, :turn, :all), before
208
+ # SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
209
+ # nil when it has none. Saved and carried like thinking.
210
+ SessionField.level("reasoning_history", unset: ReasoningHistory::UNSET) { ReasoningHistory },
211
+ # The files chi --append-system-prompt put into the session's system
212
+ # prompt (absolute paths; config.yml's append_system_prompt: entries
213
+ # aren't stored): kept on respawns, copied by a fork and a continue
214
+ # (SessionSetup). Cast on every set too.
215
+ SessionField.plain("appended_prompt_files", [], cast: ->(paths) { Array(paths).map(&:to_s).reject(&:empty?).uniq },
216
+ write: LIST),
217
+ # The session's toolkit (Toolkit: the tools its prompts declare), as
218
+ # given when it was created; nil for a session from before toolkits,
219
+ # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
220
+ # again from config, so a respawned worker declares the same tools; a
221
+ # continue and a fork copy it (SessionSetup.of), a delegate child gets
222
+ # the one its delegate call named, else toolkit.child
223
+ # (SessionManager.spawn_session). A Hash or a String reads too.
224
+ SessionField.value("toolkit", :from_file) { Toolkit },
225
+ # Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
226
+ # reads as chosen). Files from before the field read as chosen, so an
227
+ # old default session is never newly suggested. Cast on every set too.
228
+ SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN),
229
+ # The two parts of #settings (Session.new's settings:, .from_h composes
230
+ # them): its bundles part, then its core part.
231
+ SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
232
+ SessionField.file_only("config_settings", {}, from: :settings, write: ->(settings) { settings.core }),
233
+ # The mark of a turn a worker checkpointed and never finished
234
+ # (InterruptedTurn), as loaded; the next worker settles it and sets it
235
+ # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
236
+ SessionField.value("interrupted_turn", :from_file) { InterruptedTurn },
237
+ # The session's own guardrails.parent_answers (hard|standard|soft), nil
238
+ # for none (config.yml's). It may only tighten config.yml's
239
+ # (Guardrails::ParentPolicy.effective); only the user loosens it
240
+ # (/parent-answers in a UI). Given at creation (chi --parent-answers,
241
+ # chi send --new --parent-answers); a respawn, a continue and a fork keep
242
+ # it (SessionSetup.of), a delegate child takes its parent's. Read as
243
+ # Guardrails::ParentPolicy.parse_level on every set: a level, or nil for
244
+ # none ("", "default"); a value that isn't a level is kept as written,
245
+ # stripped (read as hard: ParentPolicy.session_level).
246
+ SessionField.plain("parent_answers", nil, cast: PARENT_ANSWERS),
247
+ # The working set's items the user pinned (/working-set pin;
248
+ # WorkingSetPin), [] with none: the model's forget_outputs refuses
249
+ # their outputs. A respawn, a continue and a fork keep them
250
+ # (SessionSetup.of); a delegate child starts without.
251
+ SessionField.list("working_set_pins") { WorkingSetPin },
252
+ # A review session's subjects and source (SessionReview), nil for any
253
+ # other session.
254
+ SessionField.value("review", :of, optional: true) { SessionReview },
255
+ # The id the prompt a session is created with gets (SessionManager
256
+ # .spawn_session), so its first turn is a turn with an id like one sent
257
+ # later: the worker's first turn takes it as its origin, its reply file
258
+ # and last_turn carry it, and a wait for it (`chi send --new --wait`,
259
+ # chi web's create) can name it (`--wait --turn T ID`). nil for a
260
+ # session created idle, and for one saved before the field existed.
261
+ SessionField.plain("first_turn_id", nil, optional: true),
262
+ # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
263
+ # nil for any other session. Set once, at spawn (VerifyFresh.start).
264
+ # Autoloaded on the first one (the bundle test kit's load budget).
265
+ SessionField.value("verify_fresh", :of, optional: true) { VerifyFreshOrigin }
266
+ ].to_h { |field| [field.key, field] }.freeze
267
+
268
+ # FIELDS a session file has only when set: a session without them saves
269
+ # as it did before they existed.
270
+ OPTIONAL_FIELDS = FIELDS.values.select(&:optional).map(&:key).freeze
271
+
272
+ # Session.new's keywords: every FIELDS row's attr.
273
+ ATTRS = FIELDS.values.filter_map(&:attr).freeze
274
+
275
+ # @raise [ArgumentError] a keyword Session.new doesn't take, or a
276
+ # required one missing (Ruby's own wording)
277
+ def self.check_attrs!(attrs)
278
+ unknown = attrs.keys - ATTRS
279
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
280
+
281
+ missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
282
+ raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
138
283
  end
139
284
 
140
- # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
141
- # and the web's notifications (the hub sends it in the summary); nil
142
- # before the first turn.
143
- attr_reader :last_turn
285
+ # The plain fields' readers and writers (each one's meaning is at its
286
+ # FIELDS row): a writer takes the value as given (Session.new and
287
+ # .from_h cast it), except the ones defined below.
288
+ attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
289
+ :created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
290
+ :used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
291
+ :continues, :first_turn_id
292
+ attr_writer :project_root, :delegate
293
+ attr_reader :appended_prompt_files, :written_by
144
294
 
145
- # @param value [LastTurn, Hash, nil] a file's Hash is read
146
- # (LastTurn.from_file)
147
- def last_turn=(value)
148
- @last_turn = LastTurn.from_file(value)
295
+ def appended_prompt_files=(value)
296
+ @appended_prompt_files = load_field("appended_prompt_files", value)
149
297
  end
150
298
 
151
- # Verdict cards recorded by completed Verify turns, in append order.
152
- # A missing field stays missing until the first verdict is recorded.
153
- def verdicts = @verdicts || []
299
+ # The value objects' readers and writers: a writer reads its value as
300
+ # its FIELDS row does (a file's Hash, the object itself; nil is none).
301
+ attr_reader :prompt_notes, :pending_question, :last_turn, :interrupted_turn, :toolkit, :review, :verify_fresh,
302
+ :working_set_pins
154
303
 
155
- def verdicts=(values)
156
- @verdicts = values.nil? ? nil : Array(values)
304
+ def prompt_notes=(value)
305
+ @prompt_notes = load_field("prompt_notes", value)
157
306
  end
158
307
 
159
- # The mark of a turn a worker checkpointed and never finished
160
- # (InterruptedTurn), as loaded; the next worker settles it and sets it
161
- # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
162
- attr_reader :interrupted_turn
308
+ def pending_question=(value)
309
+ @pending_question = load_field("pending_question", value)
310
+ end
163
311
 
164
- # @param value [InterruptedTurn, Hash, nil]
165
- def interrupted_turn=(value)
166
- @interrupted_turn = InterruptedTurn.from_file(value)
312
+ def last_turn=(value)
313
+ @last_turn = load_field("last_turn", value)
167
314
  end
168
315
 
169
- # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
170
- # (marker an InterruptedTurn). While +status+ is running, every save
171
- # writes these messages and the marker instead of #messages and
172
- # #interrupted_turn, so a save in the middle of the turn (a question
173
- # asked, the checkpoint's own) keeps the turn so far on disk; #messages
174
- # itself is never touched. The turn's end sets status idle before it
175
- # saves, so that save writes the real messages, without a marker. Not
176
- # saved; the worker clears it after each turn.
177
- attr_accessor :turn_checkpoint
178
- # The session's own llm_context values (LLMContextOverride: strategy,
179
- # apply rule, budget), before the model's; nil when it has none.
180
- # Saved, so a --resume and a respawned worker keep them; a plugin's
181
- # fork copies them, a delegate child starts without.
182
- attr_reader :llm_context
183
-
184
- # @param value [LLMContextOverride, Hash, nil] an empty one is nil; a
185
- # file's Hash keeps the fields it can't read (LLMContextOverride.unread),
186
- # saved back as they were until the session sets its own
187
- def llm_context=(value)
188
- @llm_context_unread = LLMContextOverride.unread(value)
189
- @llm_context = LLMContextOverride.from_file(value)
316
+ def interrupted_turn=(value)
317
+ @interrupted_turn = load_field("interrupted_turn", value)
190
318
  end
191
319
 
192
- # The session file's "llm_context": the override's fields over the ones
193
- # it couldn't read; nil when there are none.
194
- def llm_context_file
195
- saved = @llm_context_unread.merge(@llm_context&.to_file || {})
196
- saved.empty? ? nil : saved
320
+ def toolkit=(value)
321
+ @toolkit = load_field("toolkit", value)
197
322
  end
198
323
 
199
- # The session's own thinking level (:off, :low, :medium, :high), before
200
- # the process default and the model's (Thinking.resolve); nil when it
201
- # has none. Saved, so a --resume, a respawned worker and a continue keep
202
- # it; a plugin's fork copies it, a delegate child starts without.
203
- attr_reader :thinking
324
+ def review=(value)
325
+ @review = load_field("review", value)
326
+ end
204
327
 
205
- # @param value [Symbol, String, nil] a value that isn't a level reads
206
- # as nil and is saved back as written, until the session sets its own
207
- def thinking=(value)
208
- @thinking = Thinking.session_level(value)
209
- unread = @thinking.nil? && value.is_a?(String) && !value.strip.casecmp?(Thinking::DEFAULT.to_s)
210
- @thinking_unread = unread ? value : nil
328
+ def verify_fresh=(value)
329
+ @verify_fresh = load_field("verify_fresh", value)
211
330
  end
212
331
 
213
- # The session file's "thinking": the level, else a value it couldn't
214
- # read; nil when there is neither.
215
- def thinking_file = @thinking&.to_s || @thinking_unread
332
+ def working_set_pins=(value)
333
+ @working_set_pins = load_field("working_set_pins", value)
334
+ end
216
335
 
217
- # The session's own reasoning_history level (:drop, :turn, :all), before
218
- # SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
219
- # nil when it has none. Saved and carried like #thinking.
220
- attr_reader :reasoning_history
336
+ # Verdict cards recorded by completed Verify turns ([] before the
337
+ # first; the file has none then).
338
+ def verdicts = @verdicts || []
221
339
 
222
- # @param value [Symbol, String, nil] a value that isn't a level reads
223
- # as nil and is saved back as written, until the session sets its own
224
- def reasoning_history=(value)
225
- @reasoning_history = ReasoningHistory.session_level(value)
226
- unread = @reasoning_history.nil? && value.is_a?(String) && !value.strip.casecmp?(ReasoningHistory::UNSET)
227
- @reasoning_history_unread = unread ? value : nil
340
+ def verdicts=(value)
341
+ @verdicts = load_field("verdicts", value)
228
342
  end
229
343
 
230
- # The session file's "reasoning_history": the level, else a value it
231
- # couldn't read; nil when there is neither.
232
- def reasoning_history_file = @reasoning_history&.to_s || @reasoning_history_unread
344
+ # The session's own levels' and setup's readers and writers: a writer
345
+ # reads its value as its FIELDS row does; thinking, reasoning_history
346
+ # and llm_context keep what of it they couldn't read (SessionField.level).
347
+ attr_reader :llm_context, :thinking, :reasoning_history, :toolkit_origin, :parent_answers
233
348
 
234
- # The session's toolkit (Toolkit: the tools its prompts declare), as
235
- # given when it was created; nil for a session from before toolkits,
236
- # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
237
- # again from config, so a respawned worker declares the same tools; a
238
- # continue and a fork copy it (SessionSetup.of), a delegate child gets
239
- # the one its delegate call named, else toolkit.child
240
- # (SessionManager.spawn_session).
241
- attr_reader :toolkit
349
+ def llm_context=(value)
350
+ @llm_context = load_field("llm_context", value)
351
+ end
242
352
 
243
- # @param value [Toolkit, Hash, String, nil] the session file's form too
244
- def toolkit=(value)
245
- @toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
353
+ def thinking=(value)
354
+ @thinking = load_field("thinking", value)
246
355
  end
247
356
 
248
- # Why this session is on its toolkit. Files from before the field read
249
- # as chosen, so an old default session is never newly suggested.
250
- attr_reader :toolkit_origin
357
+ def reasoning_history=(value)
358
+ @reasoning_history = load_field("reasoning_history", value)
359
+ end
251
360
 
252
361
  def toolkit_origin=(value)
253
- origin = value.to_s
254
- @toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
362
+ @toolkit_origin = load_field("toolkit_origin", value)
255
363
  end
256
364
 
365
+ def parent_answers=(value)
366
+ @parent_answers = load_field("parent_answers", value)
367
+ end
368
+
369
+ # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
370
+ # (marker an InterruptedTurn). While +status+ is running, every save
371
+ # writes these messages and the marker instead of #messages and
372
+ # #interrupted_turn, so a save in the middle of the turn (a question
373
+ # asked, the checkpoint's own) keeps the turn so far on disk; #messages
374
+ # itself is never touched. The turn's end sets status idle before it
375
+ # saves, so that save writes the real messages, without a marker. Not
376
+ # saved; the worker clears it after each turn.
377
+ attr_accessor :turn_checkpoint
257
378
  # The session's own settings (chi --set), a SessionConfig::Settings:
258
379
  # empty for none and for a session from before them. Its core part
259
380
  # ({key => value}: recap.enabled) is the file's config_settings, its
@@ -269,48 +390,9 @@ module Samagotchi
269
390
  @settings = SessionConfig::Settings.of(value)
270
391
  end
271
392
 
272
- # The session's own guardrails.parent_answers (hard|standard|soft), nil
273
- # for none (config.yml's). It may only tighten config.yml's
274
- # (Guardrails::ParentPolicy.effective); only the user loosens it
275
- # (/parent-answers in a UI). Given at creation (chi --parent-answers,
276
- # chi send --new --parent-answers); a respawn, a continue and a fork keep
277
- # it (SessionSetup.of), a delegate child takes its parent's.
278
- attr_reader :parent_answers
279
-
280
- # @param value [String, Symbol, nil] read as
281
- # Guardrails::ParentPolicy.parse_level: a level, or nil for none ("",
282
- # "default"); a value that isn't a level is kept as written, stripped
283
- # (read as hard: ParentPolicy.session_level)
284
- def parent_answers=(value)
285
- level = Guardrails::ParentPolicy.parse_level(value)
286
- @parent_answers = case level
287
- when :unset then nil
288
- when :invalid then value.to_s.strip
289
- else level
290
- end
291
- end
292
-
293
- # A review session's subjects and source (SessionReview), nil for any
294
- # other session.
295
- attr_reader :review
296
-
297
- # @param value [SessionReview, Hash, nil] the session file's form too
298
- def review=(value)
299
- @review = SessionReview.of(value)
300
- end
301
-
302
393
  # Whether this is a review session (chi review, the web's Start review).
303
394
  def review? = !@review.nil?
304
395
 
305
- # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
306
- # nil for any other session. Set once, at spawn (VerifyFresh.start).
307
- attr_reader :verify_fresh
308
-
309
- # @param value [VerifyFreshOrigin, Hash, nil] the session file's form too
310
- def verify_fresh=(value)
311
- @verify_fresh = value.nil? ? nil : VerifyFreshOrigin.of(value)
312
- end
313
-
314
396
  # The session whose participants.json names this session's
315
397
  # participants: a Verify fresh child's parent (its requester's grant is
316
398
  # the parent's), else the session itself.
@@ -323,17 +405,6 @@ module Samagotchi
323
405
 
324
406
  TOOL_ROLES = %w[tool_response tool].freeze
325
407
 
326
- # The working set's items the user pinned (/working-set pin;
327
- # WorkingSetPin), [] with none: the model's forget_outputs refuses
328
- # their outputs. A respawn, a continue and a fork keep them
329
- # (SessionSetup.of); a delegate child starts without.
330
- attr_reader :working_set_pins
331
-
332
- # @param value [Array<WorkingSetPin, Hash>] the session file's form too
333
- def working_set_pins=(value)
334
- @working_set_pins = WorkingSetPin.list(value)
335
- end
336
-
337
408
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
338
409
  # left), e.g. "loop-guard"; nil when it ended otherwise.
339
410
  def stopped_by
@@ -353,7 +424,7 @@ module Samagotchi
353
424
  attr_accessor :model_warning
354
425
 
355
426
  # The delegates a continue moved to this new link (ChildMove;
356
- # SessionManager.continue_session sets it); not saved.
427
+ # SessionContinue.run sets it); not saved.
357
428
  attr_accessor :moved_children
358
429
 
359
430
  # The attached context a continue took to this new link
@@ -361,7 +432,7 @@ module Samagotchi
361
432
  attr_accessor :moved_context
362
433
 
363
434
  # The text web `continue →` prefills in this new link
364
- # (SessionChain.opener_for; SessionManager.continue_session sets it); not
435
+ # (SessionChain.opener_for; SessionContinue.run sets it); not
365
436
  # saved.
366
437
  attr_accessor :opener
367
438
 
@@ -370,59 +441,18 @@ module Samagotchi
370
441
  # FIELDS as they were. Never read by this chi.
371
442
  attr_reader :extra_fields
372
443
 
373
- # The chi version that last saved the loaded file (#to_h writes this
374
- # chi's VERSION); nil for a file from 0.130.0 or older.
375
- attr_reader :written_by
376
-
377
- def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
378
- metadata_version: METADATA_VERSION, written_by: nil, status: STATUS_IDLE, last_prompt: "",
379
- first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
380
- used_memory_names: [], project_root: nil,
381
- preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
382
- last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
383
- continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
384
- toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
385
- working_set_pins: [], review: nil, verdicts: nil, verify_fresh: nil, extra_fields: {})
386
- @id = id
387
- @metadata_version = metadata_version
388
- @written_by = written_by
389
- @mode = mode
390
- @model_name = model_name
391
- @model_typed = model_typed
392
- @working_directory = working_directory
393
- @messages = messages
394
- @created_at = created_at
395
- @updated_at = updated_at
396
- @status = status
397
- @last_prompt = last_prompt
398
- @first_preview = first_preview
399
- @first_turn_id = first_turn_id
400
- @title = title
401
- @test_run = !!test_run
402
- self.pending_question = pending_question
403
- @used_memory_names = self.class.name_list(used_memory_names)
404
- @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
405
- @muted_memory_names = self.class.name_list(muted_memory_names)
406
- self.prompt_notes = prompt_notes
407
- @project_root = project_root
408
- @parent_id = parent_id&.to_s
409
- @scratch = !!scratch
410
- self.last_turn = last_turn
411
- @delegate = !!delegate
412
- @continues = continues&.to_s
413
- self.llm_context = llm_context
414
- self.thinking = thinking
415
- self.reasoning_history = reasoning_history
416
- self.appended_prompt_files = appended_prompt_files
417
- self.toolkit = toolkit
418
- self.toolkit_origin = toolkit_origin
444
+ # Every FIELDS row's attr is a keyword (a missing required one or an
445
+ # unknown one raises ArgumentError, as Ruby's keywords do), given as is
446
+ # (cast by its row, as the writers cast); +settings+ is the
447
+ # settings pair's one value, +extra_fields+ the file's unknown keys.
448
+ def initialize(extra_fields: {}, settings: nil, **attrs)
449
+ self.class.check_attrs!(attrs)
450
+ # What the level fields couldn't read, by key (SessionField.level).
451
+ @unread = {}
452
+ FIELDS.each_value do |field|
453
+ instance_variable_set(field.ivar, load_field(field.key, attrs.fetch(field.attr) { field.fresh_value })) if field.attr
454
+ end
419
455
  self.settings = settings
420
- self.parent_answers = parent_answers
421
- self.working_set_pins = working_set_pins
422
- self.review = review
423
- self.verdicts = verdicts
424
- self.verify_fresh = verify_fresh
425
- self.interrupted_turn = interrupted_turn
426
456
  @extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
427
457
  @turn_checkpoint = nil
428
458
  @archived = false
@@ -452,6 +482,20 @@ module Samagotchi
452
482
  @preloaded_memory_names.include?(DELEGATE_MEMORY)
453
483
  end
454
484
 
485
+ # The turn state (idle/running/…) the lists show: the saved status,
486
+ # except a "running" with no live +owner+ (an OwnerLock::Owner, or nil):
487
+ # the worker that saved it died mid-turn, so nothing runs. The one rule
488
+ # behind chi web's rows and pages (Web::SessionSummary.displayed_status,
489
+ # which prefers a live worker's snapshot) and `chi sessions list`
490
+ # (SessionManager.session_summaries and the plain table).
491
+ # @param owner [OwnerLock::Owner, nil] the session's owner now
492
+ # @return [String]
493
+ def displayed_status(owner:)
494
+ return status unless status == STATUS_RUNNING
495
+
496
+ owner ? status : STATUS_IDLE
497
+ end
498
+
455
499
  # The question this session waits on, as the lists show it: {id:, kind:}
456
500
  # with kind "question" (the model's), "approval", "hook" or "continue"
457
501
  # (the step-limit question, asked between turns), +relayed_to+ and
@@ -501,16 +545,20 @@ module Samagotchi
501
545
  # Build a new, unsaved session.
502
546
  # @param messages [Array<Hash>] a conversation to start from (a fork's
503
547
  # seed); [] by default
504
- # @param setup [SessionSetup] the session's own settings (none by default)
548
+ # @param setup [SessionSetup, nil] the session's own settings (none by
549
+ # default): each member is the field of the same name (a member that
550
+ # isn't one is an ArgumentError), a toolkit_origin of none is chosen
505
551
  # @param now [Time] its created_at and updated_at (a spec's clock)
506
552
  # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
507
553
  # one SessionManager.spawn_session gives it) builds on a config kit
508
554
  # with an invalid groups: (Toolkit.check_new!)
509
555
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
510
556
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
511
- scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
557
+ scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
512
558
  review: nil, now: Time.now)
513
- Toolkit.check_new!(setup.toolkit || Toolkit.configured(delegate: delegate))
559
+ own = setup.to_h
560
+ own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
561
+ Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
514
562
  now = now.iso8601(3)
515
563
  resolved_test = if test_run.nil?
516
564
  test_session_env?
@@ -535,17 +583,9 @@ module Samagotchi
535
583
  parent_id: parent_id,
536
584
  scratch: scratch,
537
585
  delegate: delegate,
538
- llm_context: setup.llm_context,
539
- thinking: setup.thinking,
540
- reasoning_history: setup.reasoning_history,
541
- appended_prompt_files: setup.appended_prompt_files,
542
- toolkit: setup.toolkit,
543
- toolkit_origin: setup.toolkit_origin || "chosen",
544
- settings: setup.settings,
545
- parent_answers: setup.parent_answers,
546
- working_set_pins: setup.working_set_pins,
547
586
  continues: continues,
548
- review: review
587
+ review: review,
588
+ **own
549
589
  )
550
590
  end
551
591
 
@@ -559,9 +599,12 @@ module Samagotchi
559
599
  valid_id?(session_id) && File.exist?(session_path(session_id, state_dir: state_dir))
560
600
  end
561
601
 
602
+ # @raise [NotFound] no such session (an id that isn't one too)
562
603
  def self.load(session_id, state_dir: default_state_dir)
604
+ raise NotFound, session_id unless valid_id?(session_id)
605
+
563
606
  path = session_path(session_id, state_dir: state_dir)
564
- raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(path)
607
+ raise NotFound, session_id unless File.exist?(path)
565
608
 
566
609
  session = from_h(JSON.parse(File.read(path)))
567
610
  session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
@@ -571,84 +614,12 @@ module Samagotchi
571
614
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
572
615
  end
573
616
 
574
- # A field a session file must have (KeyError without it).
575
- REQUIRED = Object.new.freeze
576
-
577
- # The fields of <id>.json, in the order #save writes them: key => the
578
- # value for a file without it (REQUIRED for the ones it must have).
579
- # .from_h and #to_h both go by this table; messages change key type on
580
- # the way (symbols in memory, strings on disk); last_turn, prompt_notes
581
- # and pending_question are value objects in memory (LastTurn,
582
- # PromptNote, PendingQuestion).
583
- #
584
- # A new field: a chi older than it (from the release after 0.130.0)
585
- # keeps the key as it was (#extra_fields) but doesn't honour it, and the
586
- # next newer chi reads it again. So it must be safe to come back
587
- # unchanged after an older chi ran turns on the session: static data, or
588
- # append-only data the older chi never appends to (review, verify_fresh,
589
- # first_turn_id, verdicts); a cursor into messages, which an older chi
590
- # would have moved, needs a check against what it points at. Only
591
- # top-level keys are kept: the value objects (SessionReview,
592
- # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
593
- # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
594
- # only, so an older chi still drops a new sub-key of one of them; add a
595
- # top-level field when an older chi must keep it. A removed key would
596
- # stay an extra forever: leave it out of the extras in .from_h then (no
597
- # key was ever removed).
598
- FIELDS = {
599
- "metadata_version" => 1,
600
- "written_by" => nil,
601
- "id" => REQUIRED,
602
- "mode" => REQUIRED,
603
- "model_name" => REQUIRED,
604
- "model_typed" => nil,
605
- "working_directory" => REQUIRED,
606
- "messages" => [],
607
- "created_at" => REQUIRED,
608
- "updated_at" => REQUIRED,
609
- "status" => STATUS_IDLE,
610
- "last_prompt" => "",
611
- "first_preview" => "",
612
- "title" => nil,
613
- "test_run" => false,
614
- "pending_question" => nil,
615
- "used_memory_names" => [],
616
- "project_root" => nil,
617
- "preloaded_memory_names" => [],
618
- "muted_memory_names" => [],
619
- "prompt_notes" => [],
620
- "parent_id" => nil,
621
- "scratch" => false,
622
- "last_turn" => nil,
623
- "verdicts" => nil,
624
- "delegate" => false,
625
- "llm_context" => nil,
626
- "continues" => nil,
627
- "thinking" => nil,
628
- "reasoning_history" => nil,
629
- "appended_prompt_files" => [],
630
- "toolkit" => nil,
631
- "toolkit_origin" => "chosen",
632
- "bundle_settings" => {},
633
- "config_settings" => {},
634
- "interrupted_turn" => nil,
635
- "parent_answers" => nil,
636
- "working_set_pins" => [],
637
- "review" => nil,
638
- "first_turn_id" => nil,
639
- "verify_fresh" => nil
640
- }.freeze
641
-
642
- # FIELDS a session file has only when set: a session without them saves
643
- # as it did before they existed.
644
- OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
645
-
646
617
  # A session from a parsed session file. +messages+ false leaves the
647
618
  # conversation out (the lists' lightweight summaries).
648
619
  # @raise [KeyError] for a missing REQUIRED field
649
620
  def self.from_h(data, messages: true)
650
- attrs = FIELDS.to_h do |key, default|
651
- [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
621
+ attrs = FIELDS.to_h do |key, field|
622
+ [key.to_sym, field.required? ? data.fetch(key) : data.fetch(key, field.default)]
652
623
  end
653
624
  attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
654
625
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
@@ -667,14 +638,41 @@ module Samagotchi
667
638
  end
668
639
  end
669
640
 
641
+ # What every surface says when an id (or a prefix) names no session:
642
+ # this class owns the words, callers add their context around
643
+ # #message. +given+ is what was asked for, as given.
644
+ class NotFound < ArgumentError
645
+ attr_reader :given
646
+
647
+ def initialize(given)
648
+ @given = given.to_s
649
+ super("no session #{@given[0, 80]}")
650
+ end
651
+ end
652
+
653
+ # The full id of an existing session that +given+ (an id or a unique
654
+ # prefix) names: the one lookup the commands, SessionAccess::Local, chi
655
+ # web's routes and the tools share.
656
+ # @raise [NotFound] none (an id that isn't one too)
657
+ # @raise [AmbiguousId] a prefix of several
658
+ def self.resolve!(given, state_dir: default_state_dir)
659
+ raise NotFound, given unless valid_id?(given.to_s)
660
+
661
+ id = resolve_id(given, state_dir: state_dir)
662
+ raise NotFound, given unless exist?(id, state_dir: state_dir)
663
+
664
+ id
665
+ end
666
+
670
667
  # A session id or a unique prefix of one (like git's): the full id. An
671
- # unknown one comes back as is, for the caller's own "not found"; one that
668
+ # unknown one comes back as is (resolve! raises NotFound for it); one that
672
669
  # names several sessions raises AmbiguousId, which lists them.
673
670
  def self.resolve_id(id_or_prefix, state_dir: default_state_dir)
674
671
  id = id_or_prefix.to_s
675
672
  return id unless valid_id?(id) && !File.exist?(session_path(id, state_dir: state_dir))
676
673
 
677
- matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}")).map { |path| File.basename(path, FILE_EXT) }.sort
674
+ matches = Dir.glob(File.join(state_dir, "#{id}*#{SessionPaths::FILE_EXT}"))
675
+ .map { |path| File.basename(path, SessionPaths::FILE_EXT) }.sort
678
676
  return matches.fetch(0, id) if matches.size <= 1
679
677
 
680
678
  lines = matches.map do |match|
@@ -700,7 +698,7 @@ module Samagotchi
700
698
  sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
701
699
  sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
702
700
 
703
- sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
701
+ sessions = Dir.glob(File.join(state_dir, "*#{SessionPaths::FILE_EXT}")).filter_map { |path| summary_from_file(path) }
704
702
  sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
705
703
  sessions.reject!(&:archived) unless include_archived
706
704
  if project_root
@@ -780,40 +778,24 @@ module Samagotchi
780
778
  # messages.
781
779
  def to_h
782
780
  checkpoint = checkpointed
783
- FIELDS.keys.to_h do |key|
784
- value = case key
785
- when "messages"
786
- (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
787
- when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
788
- when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
789
- when "written_by" then VERSION
790
- when "test_run" then !!@test_run
791
- when "delegate" then @delegate
792
- when "llm_context" then llm_context_file
793
- when "thinking" then thinking_file
794
- when "reasoning_history" then reasoning_history_file
795
- when "toolkit" then @toolkit&.to_file
796
- when "bundle_settings" then @settings.bundles
797
- when "config_settings" then @settings.core
798
- when "prompt_notes" then @prompt_notes.map(&:to_file)
799
- when "last_turn" then @last_turn&.to_file
800
- when "working_set_pins" then @working_set_pins.map(&:to_file)
801
- when "review" then @review&.to_file
802
- when "verify_fresh" then @verify_fresh&.to_file
803
- when "verdicts"
804
- @verdicts&.map do |record|
805
- scrub_utf8(record.to_h do |key, entry|
806
- [key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
807
- end)
808
- end
809
- when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
810
- Array(instance_variable_get(:"@#{key}"))
811
- else instance_variable_get(:"@#{key}")
781
+ FIELDS.each_value.with_object({}) do |field, file|
782
+ value = case field.key
783
+ when "messages" then field.dump(checkpoint ? checkpoint[:messages] : @messages)
784
+ when "interrupted_turn" then field.dump(checkpoint ? checkpoint[:marker] : @interrupted_turn)
785
+ else field.dump(instance_variable_get(field.ivar), @unread[field.key])
812
786
  end
813
- [key, value]
814
- end.reject { |key, value| value.nil? && OPTIONAL_FIELDS.include?(key) }
815
- .merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
787
+ file[field.key] = value unless value.nil? && field.optional
788
+ end.merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
789
+ end
790
+
791
+ # +value+ as +key+'s FIELDS row reads it (its writer's value); what of
792
+ # it a level field couldn't read is kept for #to_h.
793
+ def load_field(key, value)
794
+ field = FIELDS.fetch(key)
795
+ @unread[key] = field.unread.call(value) if field.unread
796
+ field.load(value)
816
797
  end
798
+ private :load_field
817
799
 
818
800
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
819
801
  def checkpointed
@@ -832,9 +814,9 @@ module Samagotchi
832
814
 
833
815
  # Mark a session as stopped (STOPPED_FILE); its session file stays as
834
816
  # it is.
835
- # @raise [ArgumentError] when there is no such session
817
+ # @raise [NotFound] when there is no such session
836
818
  def self.mark_stopped(session_id, state_dir: default_state_dir)
837
- raise ArgumentError, "Session not found: #{session_id}" unless exist?(session_id, state_dir: state_dir)
819
+ raise NotFound, session_id unless exist?(session_id, state_dir: state_dir)
838
820
 
839
821
  dir = session_dir(session_id, state_dir: state_dir)
840
822
  FileUtils.mkdir_p(dir)
@@ -873,45 +855,6 @@ module Samagotchi
873
855
  nil
874
856
  end
875
857
 
876
- # An id that is not a session id: it could name a path outside the
877
- # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
878
- class InvalidId < ArgumentError; end
879
-
880
- # Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
881
- # "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
882
- # CLI resolves passes too. This is the one check every id from outside
883
- # (the bridge, the web routes, chi send/note, delegate) meets before it
884
- # becomes a path: session_dir and the session file path refuse others.
885
- VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
886
-
887
- def self.valid_id?(session_id)
888
- session_id.is_a?(String) && VALID_ID.match?(session_id)
889
- end
890
-
891
- # @raise [InvalidId] unless valid_id?(session_id)
892
- def self.check_id!(session_id)
893
- return session_id if valid_id?(session_id)
894
-
895
- raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
896
- end
897
-
898
- # Directory for a specific session (holds IPC files alongside session.json).
899
- # @raise [InvalidId] for an id that isn't one (valid_id?)
900
- def self.session_dir(session_id, state_dir: default_state_dir)
901
- File.join(state_dir, check_id!(session_id))
902
- end
903
-
904
- # A session's <id>.json.
905
- # @raise [InvalidId] for an id that isn't one (valid_id?)
906
- def self.session_file(session_id, state_dir: default_state_dir)
907
- File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
908
- end
909
-
910
- # XDG-aware sessions directory.
911
- def self.default_state_dir(env: ENV)
912
- File.join(Paths.state_home(env: env), STATE_SUBDIR)
913
- end
914
-
915
858
  # What names the session: its #title when it has one, else its
916
859
  # #first_preview.
917
860
  def display_title
@@ -970,24 +913,6 @@ module Samagotchi
970
913
  text.empty? ? nil : self.class.preview_of("note: #{text}")
971
914
  end
972
915
 
973
- def stringify_message_keys(hash)
974
- hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
975
- end
976
-
977
- # The last guard before JSON.generate, which raises on bytes that
978
- # aren't UTF-8: a save that raised would lose the whole conversation.
979
- # ToolRunner already scrubs tool output; this covers any other source.
980
- def scrub_utf8(obj)
981
- case obj
982
- when String
983
- str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
984
- str.valid_encoding? ? str : str.scrub("?")
985
- when Array then obj.map { |element| scrub_utf8(element) }
986
- when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
987
- else obj
988
- end
989
- end
990
-
991
916
  class << self
992
917
  private
993
918