samagotchi 0.156.0 → 0.158.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -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,323 @@ 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
+ # A text the user typed or pasted (a prompt, a title): the file gets it
85
+ # as valid UTF-8 (nil stays nil), the session keeps what it was given.
86
+ SCRUBBED = ->(value) { SessionField.scrub_utf8(value) }
87
+ VERDICTS = lambda do |records|
88
+ records&.map do |record|
89
+ SessionField.scrub_utf8(record.to_h do |key, entry|
90
+ [key.to_s, entry.is_a?(Hash) ? SessionField.stringify_keys(entry) : entry]
91
+ end)
92
+ end
94
93
  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)
94
+ TOOLKIT_ORIGIN = lambda do |value|
95
+ origin = value.to_s
96
+ TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
97
+ end
98
+ TOOLKIT_ORIGIN_UNREAD = ->(value) { value if value.is_a?(String) && !TOOLKIT_ORIGINS.include?(value) }
99
+ # The kept value while there is one: the written one is the cast
100
+ # (never nil), so it can't come first as a level's does.
101
+ KEPT_FIRST = ->(written, kept) { kept || written }
102
+ PARENT_ANSWERS = lambda do |value|
103
+ case (level = Guardrails::ParentPolicy.parse_level(value))
104
+ when :unset then nil
105
+ when :invalid then value.to_s.strip
106
+ else level
107
+ end
105
108
  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 (SessionContinue.run): 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
109
+ private_constant :BOOLEAN, :NAMES, :LIST, :ID, :MESSAGES, :SCRUBBED, :VERDICTS, :TOOLKIT_ORIGIN, :TOOLKIT_ORIGIN_UNREAD, :KEPT_FIRST,
110
+ :PARENT_ANSWERS
135
111
 
136
- def pending_question=(value)
137
- @pending_question = PendingQuestion.from_file(value)
138
- end
112
+ # The fields of <id>.json, in the order #save writes them: key => its
113
+ # SessionField (the value for a file without it, REQUIRED for the ones
114
+ # it must have; how it is read and written). Session.new, .from_h and
115
+ # #to_h all go by this table: a new field is one row (plus a
116
+ # SessionSetup member when a fork and a continue carry it). Messages
117
+ # change key type on the way (symbols in memory, strings on disk).
118
+ #
119
+ # A new field: a chi older than it (from the release after 0.130.0)
120
+ # keeps the key as it was (#extra_fields) but doesn't honour it, and the
121
+ # next newer chi reads it again. So it must be safe to come back
122
+ # unchanged after an older chi ran turns on the session: static data, or
123
+ # append-only data the older chi never appends to (review, verify_fresh,
124
+ # first_turn_id, verdicts); a cursor into messages, which an older chi
125
+ # would have moved, needs a check against what it points at. Only
126
+ # top-level keys are kept: the value objects (SessionReview,
127
+ # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
128
+ # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
129
+ # only, so an older chi still drops a new sub-key of one of them; add a
130
+ # top-level field when an older chi must keep it. A removed key would
131
+ # stay an extra forever: leave it out of the extras in .from_h then (no
132
+ # key was ever removed).
133
+ FIELDS = [
134
+ SessionField.plain("metadata_version", 1, fresh: METADATA_VERSION),
135
+ # The chi version that last saved the loaded file (#to_h writes this
136
+ # chi's VERSION); nil for a file from 0.130.0 or older.
137
+ SessionField.plain("written_by", nil, write: ->(_) { VERSION }),
138
+ SessionField.plain("id", REQUIRED),
139
+ SessionField.plain("mode", REQUIRED),
140
+ SessionField.plain("model_name", REQUIRED),
141
+ # The name model_name was typed as when that differs from the resolved
142
+ # ref stored in model_name (an alias: `small` for `gemma-small`), else
143
+ # nil. The models: lookup takes it (HostRegistry#lookup_names).
144
+ SessionField.plain("model_typed", nil),
145
+ SessionField.plain("working_directory", REQUIRED),
146
+ # The conversation (symbol keys in memory; .from_h symbolizes a
147
+ # file's); a running turn's checkpoint is saved in its place (#to_h).
148
+ SessionField.plain("messages", [], fresh: REQUIRED, write: MESSAGES),
149
+ SessionField.plain("created_at", REQUIRED),
150
+ SessionField.plain("updated_at", REQUIRED),
151
+ SessionField.plain("status", STATUS_IDLE),
152
+ SessionField.plain("last_prompt", "", write: SCRUBBED),
153
+ SessionField.plain("first_preview", "", write: SCRUBBED),
154
+ # The name the user gave the session (/title, TitleCommand), else nil:
155
+ # every list, card and tab shows it in place of #first_preview (see
156
+ # #display_title). A continue's new link inherits it.
157
+ SessionField.plain("title", nil, write: SCRUBBED),
158
+ SessionField.plain("test_run", false, cast: BOOLEAN, write: BOOLEAN),
159
+ # The question this session waits on (PendingQuestion), saved as
160
+ # session.json's "pending_question"; nil with none. A Hash (a saved
161
+ # record, string or symbol keys) is read with PendingQuestion.from_file.
162
+ SessionField.value("pending_question", :from_file, scrub: true) { PendingQuestion },
163
+ SessionField.plain("used_memory_names", [], cast: NAMES, write: LIST),
164
+ # The project the session was started in (ProjectScope.root_for its
165
+ # folder), stored because worktrees are deleted after a merge and a
166
+ # deleted folder no longer leads to its repository. nil outside a repo,
167
+ # and in files written before the field existed (see #project_root).
168
+ SessionField.plain("project_root", nil),
169
+ # Memories the session was started with (--memory) and memories hidden
170
+ # from it (--mute): the worker rebuilds the same prompt on a respawn.
171
+ # Names as given; the engine normalizes them.
172
+ SessionField.plain("preloaded_memory_names", [], cast: NAMES, write: LIST),
173
+ SessionField.plain("muted_memory_names", [], cast: NAMES, write: LIST),
174
+ # The model notes the session's system prompt carried at its last build
175
+ # (Array<PromptNote>: name, scope, chars, digest; ModelNotes), set by the
176
+ # Engine at each build (a start, a resume's or a woken worker's first
177
+ # turn, /model) and saved, so what a session ran with can be read later.
178
+ SessionField.list("prompt_notes") { PromptNote },
179
+ # The session that delegated this one (the `delegate` tool) or that a
180
+ # plugin forked it from (ctx.sessions.fork), else nil.
181
+ # Set before the spawn and kept on respawns, like preloaded_memory_names.
182
+ SessionField.plain("parent_id", nil, cast: ID),
183
+ # A `chi scratch` session: deleted when its terminal leaves (its worker
184
+ # deletes it too once its last client is gone), and by the next sweep
185
+ # (or `chi sessions clean`) when both died first.
186
+ SessionField.plain("scratch", false, cast: BOOLEAN),
187
+ # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
188
+ # and the web's notifications (the hub sends it in the summary); nil
189
+ # before the first turn.
190
+ SessionField.value("last_turn", :from_file) { LastTurn },
191
+ # Verdict cards recorded by completed Verify turns, in append order.
192
+ # A missing field stays missing until the first verdict is recorded
193
+ # (#verdicts reads it as []). Cast on every set too.
194
+ SessionField.plain("verdicts", nil, cast: ->(values) { values.nil? ? nil : Array(values) }, write: VERDICTS,
195
+ optional: true),
196
+ # Started by the `delegate` tool (a fork has a parent_id too, but isn't
197
+ # one): see #delegate?.
198
+ SessionField.plain("delegate", false, cast: BOOLEAN),
199
+ # The session's own llm_context values (LLMContextOverride: strategy,
200
+ # apply rule, budget), before the model's; nil when it has none (an
201
+ # empty one is none). Saved, so a --resume and a respawned worker keep
202
+ # them; a plugin's fork copies them, a delegate child starts without.
203
+ # A file's parts it can't read are kept (LLMContextOverride.unread).
204
+ SessionField.level("llm_context", merge_unread: true) { LLMContextOverride },
205
+ # The session this one continues (SessionContinue.run): the
206
+ # previous link of a chain, "the next run of the same routine", else
207
+ # nil. Not a parent: archiving one link leaves the next alone. The chain
208
+ # itself is derived (SessionChain), never stored.
209
+ SessionField.plain("continues", nil, cast: ID),
210
+ # The session's own thinking level (:off, :low, :medium, :high), before
211
+ # the process default and the model's (Thinking.resolve); nil when it
212
+ # has none. Saved, so a --resume, a respawned worker and a continue keep
213
+ # it; a plugin's fork copies it, a delegate child starts without.
214
+ SessionField.level("thinking", unset: Thinking::DEFAULT.to_s) { Thinking },
215
+ # The session's own reasoning_history level (:drop, :turn, :all), before
216
+ # SAMAGOTCHI_REASONING_HISTORY and the model's (ReasoningHistory.resolve);
217
+ # nil when it has none. Saved and carried like thinking.
218
+ SessionField.level("reasoning_history", unset: ReasoningHistory::UNSET) { ReasoningHistory },
219
+ # The files chi --append-system-prompt put into the session's system
220
+ # prompt (absolute paths; config.yml's append_system_prompt: entries
221
+ # aren't stored): kept on respawns, copied by a fork and a continue
222
+ # (SessionSetup). Cast on every set too.
223
+ SessionField.plain("appended_prompt_files", [], cast: ->(paths) { Array(paths).map(&:to_s).reject(&:empty?).uniq },
224
+ write: LIST),
225
+ # The session's toolkit (Toolkit: the tools its prompts declare), as
226
+ # given when it was created; nil for a session from before toolkits,
227
+ # which is on Toolkit::FULL (Toolkit.of). Saved and never resolved
228
+ # again from config, so a respawned worker declares the same tools; a
229
+ # continue and a fork copy it (SessionSetup.of), a delegate child gets
230
+ # the one its delegate call named, else toolkit.child
231
+ # (SessionManager.spawn_session). A Hash or a String reads too.
232
+ SessionField.value("toolkit", :from_file) { Toolkit },
233
+ # Why this session is on its toolkit (TOOLKIT_ORIGINS; anything else
234
+ # reads as chosen). Files from before the field read as chosen, so an
235
+ # old default session is never newly suggested. Cast on every set too.
236
+ # An unknown String (a newer chi's origin) is written back as it was
237
+ # (the Unread rule), until the session sets its own: the kept value
238
+ # wins over the cast chosen.
239
+ SessionField.plain("toolkit_origin", "chosen", cast: TOOLKIT_ORIGIN, unread: TOOLKIT_ORIGIN_UNREAD,
240
+ keep: KEPT_FIRST),
241
+ # The two parts of #settings (Session.new's settings:, .from_h composes
242
+ # them): its bundles part, then its core part.
243
+ SessionField.file_only("bundle_settings", {}, from: :settings, write: ->(settings) { settings.bundles }),
244
+ SessionField.file_only("config_settings", {}, from: :settings, write: ->(settings) { settings.core }),
245
+ # The mark of a turn a worker checkpointed and never finished
246
+ # (InterruptedTurn), as loaded; the next worker settles it and sets it
247
+ # to nil. While a turn runs, #turn_checkpoint's marker is saved instead.
248
+ SessionField.value("interrupted_turn", :from_file) { InterruptedTurn },
249
+ # The session's own guardrails.parent_answers (hard|standard|soft), nil
250
+ # for none (config.yml's). It may only tighten config.yml's
251
+ # (Guardrails::ParentPolicy.effective); only the user loosens it
252
+ # (/parent-answers in a UI). Given at creation (chi --parent-answers,
253
+ # chi send --new --parent-answers); a respawn, a continue and a fork keep
254
+ # it (SessionSetup.of), a delegate child takes its parent's. Read as
255
+ # Guardrails::ParentPolicy.parse_level on every set: a level, or nil for
256
+ # none ("", "default"); a value that isn't a level is kept as written,
257
+ # stripped (read as hard: ParentPolicy.session_level).
258
+ SessionField.plain("parent_answers", nil, cast: PARENT_ANSWERS),
259
+ # The working set's items the user pinned (/working-set pin;
260
+ # WorkingSetPin), [] with none: the model's forget_outputs refuses
261
+ # their outputs. A respawn, a continue and a fork keep them
262
+ # (SessionSetup.of); a delegate child starts without.
263
+ SessionField.list("working_set_pins") { WorkingSetPin },
264
+ # A review session's subjects and source (SessionReview), nil for any
265
+ # other session.
266
+ SessionField.value("review", :of, optional: true) { SessionReview },
267
+ # The id the prompt a session is created with gets (SessionManager
268
+ # .spawn_session), so its first turn is a turn with an id like one sent
269
+ # later: the worker's first turn takes it as its origin, its reply file
270
+ # and last_turn carry it, and a wait for it (`chi send --new --wait`,
271
+ # chi web's create) can name it (`--wait --turn T ID`). nil for a
272
+ # session created idle, and for one saved before the field existed.
273
+ SessionField.plain("first_turn_id", nil, optional: true),
274
+ # A Verify fresh child's requester, rules and claim (VerifyFreshOrigin),
275
+ # nil for any other session. Set once, at spawn (VerifyFresh.start).
276
+ # Autoloaded on the first one (the bundle test kit's load budget).
277
+ SessionField.value("verify_fresh", :of, optional: true) { VerifyFreshOrigin }
278
+ ].to_h { |field| [field.key, field] }.freeze
139
279
 
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
280
+ # FIELDS a session file has only when set: a session without them saves
281
+ # as it did before they existed.
282
+ OPTIONAL_FIELDS = FIELDS.values.select(&:optional).map(&:key).freeze
144
283
 
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)
284
+ # Session.new's keywords: every FIELDS row's attr.
285
+ ATTRS = FIELDS.values.filter_map(&:attr).freeze
286
+
287
+ # The plain fields' readers and writers (each one's meaning is at its
288
+ # FIELDS row): a writer takes the value as given (Session.new and
289
+ # .from_h cast it), except the ones defined below.
290
+ attr_accessor :id, :metadata_version, :mode, :model_name, :model_typed, :working_directory, :messages,
291
+ :created_at, :updated_at, :status, :last_prompt, :first_preview, :title, :test_run,
292
+ :used_memory_names, :preloaded_memory_names, :muted_memory_names, :parent_id, :scratch,
293
+ :continues, :first_turn_id
294
+ attr_writer :project_root, :delegate
295
+ attr_reader :appended_prompt_files, :written_by
296
+
297
+ def appended_prompt_files=(value)
298
+ @appended_prompt_files = load_field("appended_prompt_files", value)
149
299
  end
150
300
 
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 || []
301
+ # The value objects' readers and writers: a writer reads its value as
302
+ # its FIELDS row does (a file's Hash, the object itself; nil is none).
303
+ attr_reader :prompt_notes, :pending_question, :last_turn, :interrupted_turn, :toolkit, :review, :verify_fresh,
304
+ :working_set_pins
154
305
 
155
- def verdicts=(values)
156
- @verdicts = values.nil? ? nil : Array(values)
306
+ def prompt_notes=(value)
307
+ @prompt_notes = load_field("prompt_notes", value)
157
308
  end
158
309
 
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
310
+ def pending_question=(value)
311
+ @pending_question = load_field("pending_question", value)
312
+ end
163
313
 
164
- # @param value [InterruptedTurn, Hash, nil]
165
- def interrupted_turn=(value)
166
- @interrupted_turn = InterruptedTurn.from_file(value)
314
+ def last_turn=(value)
315
+ @last_turn = load_field("last_turn", value)
167
316
  end
168
317
 
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)
318
+ def interrupted_turn=(value)
319
+ @interrupted_turn = load_field("interrupted_turn", value)
190
320
  end
191
321
 
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
322
+ def toolkit=(value)
323
+ @toolkit = load_field("toolkit", value)
197
324
  end
198
325
 
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
326
+ def review=(value)
327
+ @review = load_field("review", value)
328
+ end
204
329
 
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
330
+ def verify_fresh=(value)
331
+ @verify_fresh = load_field("verify_fresh", value)
211
332
  end
212
333
 
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
334
+ def working_set_pins=(value)
335
+ @working_set_pins = load_field("working_set_pins", value)
336
+ end
216
337
 
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
338
+ # Verdict cards recorded by completed Verify turns ([] before the
339
+ # first; the file has none then).
340
+ def verdicts = @verdicts || []
221
341
 
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
342
+ def verdicts=(value)
343
+ @verdicts = load_field("verdicts", value)
228
344
  end
229
345
 
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
346
+ # The session's own levels' and setup's readers and writers: a writer
347
+ # reads its value as its FIELDS row does; thinking, reasoning_history
348
+ # and llm_context keep what of it they couldn't read (SessionField.level).
349
+ attr_reader :llm_context, :thinking, :reasoning_history, :toolkit_origin, :parent_answers
233
350
 
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
351
+ def llm_context=(value)
352
+ @llm_context = load_field("llm_context", value)
353
+ end
242
354
 
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)
355
+ def thinking=(value)
356
+ @thinking = load_field("thinking", value)
246
357
  end
247
358
 
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
359
+ def reasoning_history=(value)
360
+ @reasoning_history = load_field("reasoning_history", value)
361
+ end
251
362
 
252
363
  def toolkit_origin=(value)
253
- origin = value.to_s
254
- @toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
364
+ @toolkit_origin = load_field("toolkit_origin", value)
365
+ end
366
+
367
+ # The user chose the toolkit (/toolkit, the toolkit binding's use): the
368
+ # origin is chosen. Whether that changes what the file holds, so the
369
+ # caller saves only then: a kept unknown origin (the Unread rule) reads
370
+ # as chosen but is written as it was, so it counts as a change.
371
+ # @return [Boolean]
372
+ def choose_toolkit_origin!
373
+ field = FIELDS.fetch("toolkit_origin")
374
+ was = field.dump(@toolkit_origin, @unread["toolkit_origin"])
375
+ self.toolkit_origin = "chosen"
376
+ was != field.dump(@toolkit_origin, @unread["toolkit_origin"])
377
+ end
378
+
379
+ def parent_answers=(value)
380
+ @parent_answers = load_field("parent_answers", value)
255
381
  end
256
382
 
383
+ # The running turn's checkpoint (TurnCheckpoint): {messages:, marker:}
384
+ # (marker an InterruptedTurn). While +status+ is running, every save
385
+ # writes these messages and the marker instead of #messages and
386
+ # #interrupted_turn, so a save in the middle of the turn (a question
387
+ # asked, the checkpoint's own) keeps the turn so far on disk; #messages
388
+ # itself is never touched. The turn's end sets status idle before it
389
+ # saves, so that save writes the real messages, without a marker. Not
390
+ # saved; the worker clears it after each turn.
391
+ attr_accessor :turn_checkpoint
257
392
  # The session's own settings (chi --set), a SessionConfig::Settings:
258
393
  # empty for none and for a session from before them. Its core part
259
394
  # ({key => value}: recap.enabled) is the file's config_settings, its
@@ -269,48 +404,9 @@ module Samagotchi
269
404
  @settings = SessionConfig::Settings.of(value)
270
405
  end
271
406
 
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
407
  # Whether this is a review session (chi review, the web's Start review).
303
408
  def review? = !@review.nil?
304
409
 
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
410
  # The session whose participants.json names this session's
315
411
  # participants: a Verify fresh child's parent (its requester's grant is
316
412
  # the parent's), else the session itself.
@@ -323,17 +419,6 @@ module Samagotchi
323
419
 
324
420
  TOOL_ROLES = %w[tool_response tool].freeze
325
421
 
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
422
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
338
423
  # left), e.g. "loop-guard"; nil when it ended otherwise.
339
424
  def stopped_by
@@ -370,59 +455,18 @@ module Samagotchi
370
455
  # FIELDS as they were. Never read by this chi.
371
456
  attr_reader :extra_fields
372
457
 
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
458
+ # Every FIELDS row's attr is a keyword (a missing required one or an
459
+ # unknown one raises ArgumentError, as Ruby's keywords do), given as is
460
+ # (cast by its row, as the writers cast); +settings+ is the
461
+ # settings pair's one value, +extra_fields+ the file's unknown keys.
462
+ def initialize(extra_fields: {}, settings: nil, **attrs)
463
+ check_attrs!(attrs)
464
+ # What the level fields couldn't read, by key (SessionField.level).
465
+ @unread = {}
466
+ FIELDS.each_value do |field|
467
+ instance_variable_set(field.ivar, load_field(field.key, attrs.fetch(field.attr) { field.fresh_value })) if field.attr
468
+ end
419
469
  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
470
  @extra_fields = extra_fields.to_h { |key, value| [key.to_s, value] }.freeze
427
471
  @turn_checkpoint = nil
428
472
  @archived = false
@@ -515,16 +559,20 @@ module Samagotchi
515
559
  # Build a new, unsaved session.
516
560
  # @param messages [Array<Hash>] a conversation to start from (a fork's
517
561
  # seed); [] by default
518
- # @param setup [SessionSetup] the session's own settings (none by default)
562
+ # @param setup [SessionSetup, nil] the session's own settings (none by
563
+ # default): each member is the field of the same name (a member that
564
+ # isn't one is an ArgumentError), a toolkit_origin of none is chosen
519
565
  # @param now [Time] its created_at and updated_at (a spec's clock)
520
566
  # @raise [Toolkit::Refused] its toolkit (setup's, else the configured
521
567
  # one SessionManager.spawn_session gives it) builds on a config kit
522
568
  # with an invalid groups: (Toolkit.check_new!)
523
569
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
524
570
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
525
- scratch: false, model_typed: nil, delegate: false, setup: SessionSetup.new, continues: nil,
571
+ scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
526
572
  review: nil, now: Time.now)
527
- Toolkit.check_new!(setup.toolkit || Toolkit.configured(delegate: delegate))
573
+ own = setup.to_h
574
+ own = own.merge(toolkit_origin: own[:toolkit_origin] || "chosen")
575
+ Toolkit.check_new!(own[:toolkit] || Toolkit.configured(delegate: delegate))
528
576
  now = now.iso8601(3)
529
577
  resolved_test = if test_run.nil?
530
578
  test_session_env?
@@ -549,17 +597,9 @@ module Samagotchi
549
597
  parent_id: parent_id,
550
598
  scratch: scratch,
551
599
  delegate: delegate,
552
- llm_context: setup.llm_context,
553
- thinking: setup.thinking,
554
- reasoning_history: setup.reasoning_history,
555
- appended_prompt_files: setup.appended_prompt_files,
556
- toolkit: setup.toolkit,
557
- toolkit_origin: setup.toolkit_origin || "chosen",
558
- settings: setup.settings,
559
- parent_answers: setup.parent_answers,
560
- working_set_pins: setup.working_set_pins,
561
600
  continues: continues,
562
- review: review
601
+ review: review,
602
+ **own
563
603
  )
564
604
  end
565
605
 
@@ -588,84 +628,12 @@ module Samagotchi
588
628
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
589
629
  end
590
630
 
591
- # A field a session file must have (KeyError without it).
592
- REQUIRED = Object.new.freeze
593
-
594
- # The fields of <id>.json, in the order #save writes them: key => the
595
- # value for a file without it (REQUIRED for the ones it must have).
596
- # .from_h and #to_h both go by this table; messages change key type on
597
- # the way (symbols in memory, strings on disk); last_turn, prompt_notes
598
- # and pending_question are value objects in memory (LastTurn,
599
- # PromptNote, PendingQuestion).
600
- #
601
- # A new field: a chi older than it (from the release after 0.130.0)
602
- # keeps the key as it was (#extra_fields) but doesn't honour it, and the
603
- # next newer chi reads it again. So it must be safe to come back
604
- # unchanged after an older chi ran turns on the session: static data, or
605
- # append-only data the older chi never appends to (review, verify_fresh,
606
- # first_turn_id, verdicts); a cursor into messages, which an older chi
607
- # would have moved, needs a check against what it points at. Only
608
- # top-level keys are kept: the value objects (SessionReview,
609
- # VerifyFreshOrigin, Toolkit, LastTurn, PendingQuestion, InterruptedTurn,
610
- # PromptNote, WorkingSetPin, LLMContextOverride) write their own members
611
- # only, so an older chi still drops a new sub-key of one of them; add a
612
- # top-level field when an older chi must keep it. A removed key would
613
- # stay an extra forever: leave it out of the extras in .from_h then (no
614
- # key was ever removed).
615
- FIELDS = {
616
- "metadata_version" => 1,
617
- "written_by" => nil,
618
- "id" => REQUIRED,
619
- "mode" => REQUIRED,
620
- "model_name" => REQUIRED,
621
- "model_typed" => nil,
622
- "working_directory" => REQUIRED,
623
- "messages" => [],
624
- "created_at" => REQUIRED,
625
- "updated_at" => REQUIRED,
626
- "status" => STATUS_IDLE,
627
- "last_prompt" => "",
628
- "first_preview" => "",
629
- "title" => nil,
630
- "test_run" => false,
631
- "pending_question" => nil,
632
- "used_memory_names" => [],
633
- "project_root" => nil,
634
- "preloaded_memory_names" => [],
635
- "muted_memory_names" => [],
636
- "prompt_notes" => [],
637
- "parent_id" => nil,
638
- "scratch" => false,
639
- "last_turn" => nil,
640
- "verdicts" => nil,
641
- "delegate" => false,
642
- "llm_context" => nil,
643
- "continues" => nil,
644
- "thinking" => nil,
645
- "reasoning_history" => nil,
646
- "appended_prompt_files" => [],
647
- "toolkit" => nil,
648
- "toolkit_origin" => "chosen",
649
- "bundle_settings" => {},
650
- "config_settings" => {},
651
- "interrupted_turn" => nil,
652
- "parent_answers" => nil,
653
- "working_set_pins" => [],
654
- "review" => nil,
655
- "first_turn_id" => nil,
656
- "verify_fresh" => nil
657
- }.freeze
658
-
659
- # FIELDS a session file has only when set: a session without them saves
660
- # as it did before they existed.
661
- OPTIONAL_FIELDS = %w[review first_turn_id verdicts verify_fresh].freeze
662
-
663
631
  # A session from a parsed session file. +messages+ false leaves the
664
632
  # conversation out (the lists' lightweight summaries).
665
633
  # @raise [KeyError] for a missing REQUIRED field
666
634
  def self.from_h(data, messages: true)
667
- attrs = FIELDS.to_h do |key, default|
668
- [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
635
+ attrs = FIELDS.to_h do |key, field|
636
+ [key.to_sym, field.required? ? data.fetch(key) : data.fetch(key, field.default)]
669
637
  end
670
638
  attrs[:settings] = SessionConfig::Settings.new(core: attrs.delete(:config_settings), bundles: attrs.delete(:bundle_settings))
671
639
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
@@ -717,7 +685,8 @@ module Samagotchi
717
685
  id = id_or_prefix.to_s
718
686
  return id unless valid_id?(id) && !File.exist?(session_path(id, state_dir: state_dir))
719
687
 
720
- matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}")).map { |path| File.basename(path, FILE_EXT) }.sort
688
+ matches = Dir.glob(File.join(state_dir, "#{id}*#{SessionPaths::FILE_EXT}"))
689
+ .map { |path| File.basename(path, SessionPaths::FILE_EXT) }.sort
721
690
  return matches.fetch(0, id) if matches.size <= 1
722
691
 
723
692
  lines = matches.map do |match|
@@ -743,7 +712,7 @@ module Samagotchi
743
712
  sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
744
713
  sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
745
714
 
746
- sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
715
+ sessions = Dir.glob(File.join(state_dir, "*#{SessionPaths::FILE_EXT}")).filter_map { |path| summary_from_file(path) }
747
716
  sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
748
717
  sessions.reject!(&:archived) unless include_archived
749
718
  if project_root
@@ -823,40 +792,35 @@ module Samagotchi
823
792
  # messages.
824
793
  def to_h
825
794
  checkpoint = checkpointed
826
- FIELDS.keys.to_h do |key|
827
- value = case key
828
- when "messages"
829
- (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
830
- when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
831
- when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
832
- when "written_by" then VERSION
833
- when "test_run" then !!@test_run
834
- when "delegate" then @delegate
835
- when "llm_context" then llm_context_file
836
- when "thinking" then thinking_file
837
- when "reasoning_history" then reasoning_history_file
838
- when "toolkit" then @toolkit&.to_file
839
- when "bundle_settings" then @settings.bundles
840
- when "config_settings" then @settings.core
841
- when "prompt_notes" then @prompt_notes.map(&:to_file)
842
- when "last_turn" then @last_turn&.to_file
843
- when "working_set_pins" then @working_set_pins.map(&:to_file)
844
- when "review" then @review&.to_file
845
- when "verify_fresh" then @verify_fresh&.to_file
846
- when "verdicts"
847
- @verdicts&.map do |record|
848
- scrub_utf8(record.to_h do |key, entry|
849
- [key.to_s, entry.is_a?(Hash) ? stringify_message_keys(entry) : entry]
850
- end)
851
- end
852
- when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
853
- Array(instance_variable_get(:"@#{key}"))
854
- else instance_variable_get(:"@#{key}")
795
+ FIELDS.each_value.with_object({}) do |field, file|
796
+ value = case field.key
797
+ when "messages" then field.dump(checkpoint ? checkpoint[:messages] : @messages)
798
+ when "interrupted_turn" then field.dump(checkpoint ? checkpoint[:marker] : @interrupted_turn)
799
+ else field.dump(instance_variable_get(field.ivar), @unread[field.key])
855
800
  end
856
- [key, value]
857
- end.reject { |key, value| value.nil? && OPTIONAL_FIELDS.include?(key) }
858
- .merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
801
+ file[field.key] = value unless value.nil? && field.optional
802
+ end.merge(@extra_fields.reject { |key, _| FIELDS.key?(key) })
803
+ end
804
+
805
+ # +value+ as +key+'s FIELDS row reads it (its writer's value); what of
806
+ # it a level field couldn't read is kept for #to_h.
807
+ def load_field(key, value)
808
+ field = FIELDS.fetch(key)
809
+ @unread[key] = field.unread.call(value) if field.unread
810
+ field.cast_value(value)
811
+ end
812
+ private :load_field
813
+
814
+ # @raise [ArgumentError] a keyword Session.new doesn't take, or a
815
+ # required one missing (Ruby's own wording)
816
+ def check_attrs!(attrs)
817
+ unknown = attrs.keys - ATTRS
818
+ raise ArgumentError, "unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
819
+
820
+ missing = FIELDS.values.select(&:fresh_required?).map(&:attr) - attrs.keys
821
+ raise ArgumentError, "missing keyword#{"s" if missing.size > 1}: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
859
822
  end
823
+ private :check_attrs!
860
824
 
861
825
  # The running turn's checkpoint, while the turn runs (#turn_checkpoint).
862
826
  def checkpointed
@@ -916,45 +880,6 @@ module Samagotchi
916
880
  nil
917
881
  end
918
882
 
919
- # An id that is not a session id: it could name a path outside the
920
- # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
921
- class InvalidId < ArgumentError; end
922
-
923
- # Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
924
- # "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
925
- # CLI resolves passes too. This is the one check every id from outside
926
- # (the bridge, the web routes, chi send/note, delegate) meets before it
927
- # becomes a path: session_dir and the session file path refuse others.
928
- VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
929
-
930
- def self.valid_id?(session_id)
931
- session_id.is_a?(String) && VALID_ID.match?(session_id)
932
- end
933
-
934
- # @raise [InvalidId] unless valid_id?(session_id)
935
- def self.check_id!(session_id)
936
- return session_id if valid_id?(session_id)
937
-
938
- raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
939
- end
940
-
941
- # Directory for a specific session (holds IPC files alongside session.json).
942
- # @raise [InvalidId] for an id that isn't one (valid_id?)
943
- def self.session_dir(session_id, state_dir: default_state_dir)
944
- File.join(state_dir, check_id!(session_id))
945
- end
946
-
947
- # A session's <id>.json.
948
- # @raise [InvalidId] for an id that isn't one (valid_id?)
949
- def self.session_file(session_id, state_dir: default_state_dir)
950
- File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
951
- end
952
-
953
- # XDG-aware sessions directory.
954
- def self.default_state_dir(env: ENV)
955
- File.join(Paths.state_home(env: env), STATE_SUBDIR)
956
- end
957
-
958
883
  # What names the session: its #title when it has one, else its
959
884
  # #first_preview.
960
885
  def display_title
@@ -984,8 +909,9 @@ module Samagotchi
984
909
  # A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
985
910
  # A quoted or annotated message previews by what was typed under the
986
911
  # quote; a quote alone by its words, without the "> " markers or label.
912
+ # Bytes that aren't UTF-8 preview as ?, as the file saves them.
987
913
  def self.preview_of(text)
988
- norm = preview_words(text.to_s).gsub(/\s+/, " ").strip
914
+ norm = preview_words(SessionField.scrub_utf8(text.to_s)).gsub(/\s+/, " ").strip
989
915
  norm.length > 80 ? "#{norm[0, 80]}…" : norm
990
916
  end
991
917
 
@@ -1013,24 +939,6 @@ module Samagotchi
1013
939
  text.empty? ? nil : self.class.preview_of("note: #{text}")
1014
940
  end
1015
941
 
1016
- def stringify_message_keys(hash)
1017
- hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
1018
- end
1019
-
1020
- # The last guard before JSON.generate, which raises on bytes that
1021
- # aren't UTF-8: a save that raised would lose the whole conversation.
1022
- # ToolRunner already scrubs tool output; this covers any other source.
1023
- def scrub_utf8(obj)
1024
- case obj
1025
- when String
1026
- str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
1027
- str.valid_encoding? ? str : str.scrub("?")
1028
- when Array then obj.map { |element| scrub_utf8(element) }
1029
- when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
1030
- else obj
1031
- end
1032
- end
1033
-
1034
942
  class << self
1035
943
  private
1036
944