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
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home project].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
@@ -81,12 +81,11 @@ module Samagotchi
81
81
  # @return [OwnerLock::Owner, nil] the worker or one-turn chi run that owns it
82
82
  def owner(id) = SessionManager.session_owner(id, **dir)
83
83
 
84
- # SessionManager.list_sessions: Session objects, for chi sessions list.
85
- def list(**filters) = SessionManager.list_sessions(**filters, **dir)
86
-
87
- # SessionManager.session_summaries: the summary hashes (chi sessions
88
- # list --live/--format, chi note --all).
89
- def summaries(**filters) = SessionManager.session_summaries(**filters, **dir)
84
+ # SessionManager.session_rows: the SessionRows chi sessions list
85
+ # prints and chi note --all reaches (chi web's summaries route answers
86
+ # their #to_summary_h).
87
+ # @return [Array<SessionRow>]
88
+ def summaries(**filters) = SessionManager.session_rows(**filters, **dir)
90
89
 
91
90
  # The session's worker for an attach: the live one as is, else
92
91
  # resumed and waited for up to +wait+ seconds (chi --attach on this
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../session"
4
+ require_relative "../session_row"
4
5
  require_relative "../session_access"
5
6
  require_relative "../remotes"
6
7
  require_relative "../reply_wait"
@@ -158,8 +159,9 @@ module Samagotchi
158
159
  # @return [OwnerLock::Owner, nil]
159
160
  def owner(id) = show(id).owner
160
161
 
161
- # The rows chi sessions list prints, as SessionManager.session_summaries
162
- # gives them (symbol keys).
162
+ # The rows chi sessions list prints: chi web's summaries
163
+ # (SessionRow#to_summary_h) read back as SessionRows.
164
+ # @return [Array<SessionRow>]
163
165
  def summaries(live: false, cwd: nil, limit: nil, include_tests: true, project_root: nil, include_archived: false,
164
166
  sort: nil, order: nil)
165
167
  query = { live: (1 if live), cwd: cwd, limit: limit, tests: (0 unless include_tests), dir: project_root,
@@ -167,7 +169,7 @@ module Samagotchi
167
169
  reply = request(:get, "/api/sessions/summaries", query: query)
168
170
  raise_refusal(reply) unless reply.status == 200
169
171
 
170
- Array(reply.json).map { |row| symbolize(row) }
172
+ Array(reply.json).map { |row| SessionRow.from_summary_h(symbolize(row)) }
171
173
  end
172
174
 
173
175
  # --- create and send
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # One key of a session file (a Session::FIELDS row): +key+ the file's
5
+ # key, +attr+ Session's attribute and keyword (nil for a key only the
6
+ # file has: the settings pair, written from Session's +from+), +default+
7
+ # the value of a file without the key (REQUIRED for one it must have),
8
+ # +fresh+ a new Session's when it isn't given (it differs only for
9
+ # metadata_version and messages), +optional+ a key written only when set,
10
+ # +cast+ what a value is read as (Session.new, .from_h and the writers),
11
+ # +write+ what the file gets from the attribute (the value as is without
12
+ # one). +unread+ and +keep+ are the Unread rule's (.level): what of a
13
+ # value the field couldn't read the session keeps, and how the file gets
14
+ # it back. A leaf: the types a row names are named in session.rb, in a
15
+ # block its lambdas call (`type.call`, not `yield`: never while the
16
+ # constructor runs, so Performance/RedundantBlockCall is off for them).
17
+ SessionField = Data.define(:key, :attr, :from, :default, :fresh, :optional, :cast, :write, :unread, :keep) do
18
+ # A field read as given, or through +cast+; +unread+ and +keep+ give
19
+ # it the Unread rule (see .level).
20
+ def self.plain(key, default, cast: nil, write: nil, fresh: default, optional: false, unread: nil, keep: nil)
21
+ new(key: key, attr: key.to_sym, from: nil, default: default, fresh: fresh, optional: optional, cast: cast,
22
+ write: write, unread: unread, keep: keep)
23
+ end
24
+
25
+ # rubocop:disable Performance/RedundantBlockCall
26
+ # A value object's field (nil for none): a value that is already one
27
+ # is taken as it is, nil is nil (the type isn't loaded for it: an
28
+ # autoloaded one stays unloaded), anything else is read with the type's
29
+ # +reader+ (from_file, of); the file gets its #to_file, through
30
+ # .scrub_utf8 with +scrub+. +type+ is a block, so the row names its type
31
+ # without loading it.
32
+ def self.value(key, reader, optional: false, scrub: false, &type)
33
+ cast = lambda do |value|
34
+ next nil if value.nil?
35
+
36
+ klass = type.call
37
+ value.is_a?(klass) ? value : klass.public_send(reader, value)
38
+ end
39
+ write = scrub ? ->(value) { value && scrub_utf8(value.to_file) } : ->(value) { value&.to_file }
40
+ new(key: key, attr: key.to_sym, from: nil, default: nil, fresh: nil, optional: optional, cast: cast,
41
+ write: write, unread: nil, keep: nil)
42
+ end
43
+
44
+ # A list of value objects ([] for none): read with the type's .list, each
45
+ # written with its #to_file.
46
+ def self.list(key, &type)
47
+ new(key: key, attr: key.to_sym, from: nil, default: [], fresh: [], optional: false,
48
+ cast: ->(value) { type.call.list(value) }, write: ->(values) { values.map(&:to_file) }, unread: nil,
49
+ keep: nil)
50
+ end
51
+
52
+ # A session's own level (nil for none), with the Unread rule: a value
53
+ # that isn't one reads as nil, and the session keeps it as written (a
54
+ # newer chi's level, a hand edit) and saves it back so, until it sets
55
+ # its own. A level is the type's .session_level of the value; a String
56
+ # that doesn't read as one, other than the +unset+ word (any case and
57
+ # spaces), is the one kept. With +merge_unread+ the field is a value
58
+ # object of several parts (LLMContextOverride: .from_file, .unread):
59
+ # the parts it couldn't read are kept, and the file gets them under the
60
+ # parts it has (nil when there is none).
61
+ def self.level(key, unset: nil, merge_unread: false, &type)
62
+ if merge_unread
63
+ cast = ->(value) { type.call.from_file(value) }
64
+ unread = ->(value) { type.call.unread(value) }
65
+ write = ->(value) { value&.to_file }
66
+ keep = lambda do |written, kept|
67
+ saved = kept.merge(written || {})
68
+ saved.empty? ? nil : saved
69
+ end
70
+ else
71
+ cast = ->(value) { type.call.session_level(value) }
72
+ unread = lambda do |value|
73
+ value if value.is_a?(String) && type.call.session_level(value).nil? && !value.strip.casecmp?(unset)
74
+ end
75
+ write = ->(value) { value&.to_s }
76
+ keep = ->(written, kept) { written || kept }
77
+ end
78
+ new(key: key, attr: key.to_sym, from: nil, default: nil, fresh: nil, optional: false, cast: cast, write: write,
79
+ unread: unread, keep: keep)
80
+ end
81
+ # rubocop:enable Performance/RedundantBlockCall
82
+
83
+ # A key only the file has: +write+ takes Session's +from+ attribute.
84
+ def self.file_only(key, default, from:, write:)
85
+ new(key: key, attr: nil, from: from, default: default, fresh: nil, optional: false, cast: nil, write: write,
86
+ unread: nil, keep: nil)
87
+ end
88
+
89
+ def required? = default.equal?(SessionField::REQUIRED)
90
+
91
+ def fresh_required? = fresh.equal?(SessionField::REQUIRED)
92
+
93
+ # A new Session's value when it isn't given: a fresh copy of a list or
94
+ # a Hash, never one shared with another session.
95
+ def fresh_value = fresh.is_a?(Array) || fresh.is_a?(Hash) ? fresh.dup : fresh
96
+
97
+ # The instance variable #to_h reads (never the reader: project_root's
98
+ # and verdicts' have logic of their own).
99
+ def ivar = :"@#{attr || from}"
100
+
101
+ # +value+ as the attribute holds it.
102
+ def cast_value(value) = cast ? cast.call(value) : value
103
+
104
+ # The attribute's +value+ as the file holds it, with what the session
105
+ # kept of a value it couldn't read (+kept+, .level's).
106
+ def dump(value, kept = nil)
107
+ written = write ? write.call(value) : value
108
+ keep ? keep.call(written, kept) : written
109
+ end
110
+
111
+ # The last guard before JSON.generate, which raises on bytes that
112
+ # aren't UTF-8: a save that raised would lose the whole conversation.
113
+ # ToolRunner already scrubs tool output; this covers any other source.
114
+ def self.scrub_utf8(obj)
115
+ case obj
116
+ when String
117
+ str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
118
+ str.valid_encoding? ? str : str.scrub("?")
119
+ when Array then obj.map { |element| scrub_utf8(element) }
120
+ when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
121
+ else obj
122
+ end
123
+ end
124
+
125
+ # A Hash with String keys (one level).
126
+ def self.stringify_keys(hash)
127
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
128
+ end
129
+ end
130
+
131
+ # A field a session file must have (KeyError without it, ArgumentError
132
+ # from Session.new).
133
+ SessionField::REQUIRED = Object.new.freeze
134
+ end
@@ -442,7 +442,7 @@ module Samagotchi
442
442
  private_class_method :unmark_removed
443
443
 
444
444
  # How old a per-input tombstone (input/removed/, TurnCheckpoint's
445
- # input/settled/) gets before a worker's start prunes it (#prune_old).
445
+ # input/settled/, a merge record in input/merged/) gets before a worker's start prunes it (#prune_old).
446
446
  # A wait reads one at its next poll (ReplyWait::POLL_INTERVAL) and a
447
447
  # later `chi send --wait --turn` reads a settled one at its start, so
448
448
  # only a wait whose process slept (a closed laptop) or one started long
@@ -470,6 +470,54 @@ module Samagotchi
470
470
  # The removed tombstones past +max_age+ (#prune_old).
471
471
  def self.prune_removed(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, REMOVED_DIR, max_age: max_age)
472
472
 
473
+ # Where a running turn's drain records an input it merged as a steer:
474
+ # input/merged/<enqueued_id> = {"into": the turn's enqueued_id, "at"}.
475
+ # Its own directory: a file in input/settled/ reads as worker_stopped to
476
+ # a chi before it, and no *.json glob sees a subdirectory.
477
+ MERGED_DIR = "merged"
478
+
479
+ # Records that the input +id+ went into the turn +into+ (both
480
+ # enqueued_ids) as a steer, before the drain deletes its claimed file
481
+ # (Worker#pending_input_drain): a wait for it (ReplyWait's turn:) then
482
+ # waits for that turn (Poll#follow_merge). Nothing for an id that
483
+ # can't name a file, nor for a turn with none; a failed write only
484
+ # logs (the wait infers the merge, as for a worker before this).
485
+ def self.mark_merged(session_dir, id, into:, now: Time.now)
486
+ return unless ENQUEUED_ID_NAME.match?(id.to_s) && ENQUEUED_ID_NAME.match?(into.to_s)
487
+
488
+ dir = File.join(session_dir, INPUT_DIR, MERGED_DIR)
489
+ FileUtils.mkdir_p(dir)
490
+ AtomicFile.write(File.join(dir, id), JSON.generate("into" => into, "at" => now.iso8601(3)))
491
+ rescue SystemCallError, IOError => e
492
+ Log.warn(:worker, "merge_record_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
493
+ end
494
+
495
+ # A merge record (#mark_merged): the enqueued_id of the turn an input
496
+ # went into, and when (a Time, nil when unreadable).
497
+ Merge = Data.define(:into, :at)
498
+
499
+ # @return [Merge, nil] the turn the input +id+ was merged into
500
+ # (#mark_merged), nil without a readable record
501
+ def self.merged_into(session_dir, id)
502
+ return nil unless ENQUEUED_ID_NAME.match?(id.to_s)
503
+
504
+ raw = JSON.parse(File.read(File.join(session_dir, INPUT_DIR, MERGED_DIR, id.to_s)))
505
+ into = raw["into"]
506
+ return nil unless ENQUEUED_ID_NAME.match?(into.to_s)
507
+
508
+ at = begin
509
+ Time.iso8601(raw["at"].to_s)
510
+ rescue ArgumentError
511
+ nil
512
+ end
513
+ Merge.new(into: into, at: at)
514
+ rescue JSON::ParserError, SystemCallError, TypeError, NoMethodError
515
+ nil
516
+ end
517
+
518
+ # The merge records past +max_age+ (#prune_old).
519
+ def self.prune_merged(session_dir, max_age: TOMBSTONE_MAX_AGE) = prune_old(session_dir, MERGED_DIR, max_age: max_age)
520
+
473
521
  # @return [Boolean] whether the queued input +enqueued_id+ was removed
474
522
  # before it ran (#take_queued_input's tombstone), or is being removed:
475
523
  # the tombstone goes first, so while its claimed file is still there
@@ -10,7 +10,8 @@ module Samagotchi
10
10
  # other entry (or a file in one of the EMPTY_DIRS) is something the
11
11
  # session keeps. "pid" is the pid file workers wrote until 2026-10-01:
12
12
  # nothing writes or reads it now, but a folder from before still has one
13
- # and is still empty.
13
+ # and is still empty. A turn's drawing (Bridge::TurnDrawings::FILE)
14
+ # exists only after a turn that kept messages: never a skeleton file.
14
15
  EMPTY_SKELETON_FILES = ["pid", "owner.lock", WorkerLink::FILE, AnalyticsFile::NAME].freeze
15
16
  EMPTY_DIRS = [SessionInbox::INPUT_DIR, SessionInbox::NOTES_DIR, "images"].freeze
16
17
  EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [SessionInbox::OUTPUT_DIR]).freeze
@@ -2,10 +2,6 @@
2
2
 
3
3
  module Samagotchi
4
4
  class SessionManager
5
- SUMMARY_DESC_LIMIT = 60
6
-
7
- SUMMARY_PREVIEW_LIMIT = 120
8
-
9
5
  # Listing: read-only queries over the saved sessions (the lists, their
10
6
  # summary rows, a parent's delegates, a session's replies). Calls the
11
7
  # facade's session_owner. Meant to be loaded through samagotchi/session_manager.
@@ -23,10 +19,10 @@ module Samagotchi
23
19
  # `chi sessions list --live --format json|tsv` (chi note from a script)
24
20
  # and the agent's list_sessions tool; both pass the current project as
25
21
  # +project_root+ unless asked for every project.
26
- # @param live [Boolean] only sessions a worker owns now (the owner lock,
27
- # not the saved status, which a dead worker leaves at "running"); a
28
- # session a one-shot (or an older chi's REPL) owns is left out: it
29
- # can't take notes
22
+ # @param live [Boolean] only sessions a worker owns now (WorkerLink's
23
+ # worker_owned?: the owner lock, not the saved status, which a dead
24
+ # worker leaves at "running"); a session a one-shot (or an older
25
+ # chi's REPL) owns is left out: it can't take notes
30
26
  # @param cwd [String, nil] only sessions in this folder or below it
31
27
  # @param project_root [String, nil] only this project's sessions
32
28
  # (Session#project_root)
@@ -34,76 +30,45 @@ module Samagotchi
34
30
  # @param include_tests [Boolean] false leaves out test runs
35
31
  # @param exclude [String, nil] a session id to leave out (the asker)
36
32
  # @param include_archived [Boolean] archived sessions too
37
- # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
38
- # updated_at:, status:, saved_status:, live:, busy:, owner:, recap:, auto_title:, parent_id:,
39
- # parent_short_id:, continues:, continues_short_id:, delegate:, archived:, scratch:, test_run:, waiting:,
40
- # waiting_id:, stopped_by:}; status = Session#displayed_status (a dead
41
- # worker's "running" is idle, as chi web shows it), saved_status = the
42
- # file's (a "running" with no owner yet is also a worker on its way:
43
- # Tools::Delegate.running?), busy =
44
- # live with a turn running, delegate = started by the delegate tool
45
- # (Session#delegate?; a fork has a parent_id but isn't one), continues = the session it continues (the
46
- # previous link of its chain, Session#continues), recap = the saved recap's first sentence, auto_title =
47
- # the auto title its reply led with (RecapTitle) or nil, project =
48
- # Session#project_root, waiting = the kind of question it waits on
49
- # ("question", "approval", "hook"; Session#waiting_question) or nil,
50
- # waiting_id = that question's id or nil, relayed_to = the parent
51
- # whose card it waits in too (the approval relay), its short id, or nil,
52
- # stopped_by = the hook that stopped the last turn (Session#stopped_by)
53
- def session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
54
- project_root: nil, include_archived: false, sort: nil, order: nil)
33
+ # @return [Array<SessionRow>] see SessionRow for the fields
34
+ def session_rows(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
35
+ project_root: nil, include_archived: false, sort: nil, order: nil)
55
36
  sd = state_dir || Session.default_state_dir
56
37
  root = cwd && folder_path(cwd)
57
38
  roots = {}
58
39
  # One registry per listing, built only when a row has a saved context
59
40
  # to count: its session's llm_context budget is what ctx_pct counts
60
41
  # against (SessionMetrics.saved_context_pct).
61
- budgets = listing_budgets
62
- summaries = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
63
- include_archived: include_archived).lazy
64
- .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
65
- .select { |s| root.nil? || in_folder?(s.working_directory, root) }
66
- .filter_map do |s|
42
+ budgets = SessionRow.budgets
43
+ rows = Session.list(state_dir: sd, sort: sort || "updated_at", order: order || "desc", project_root: project_root,
44
+ include_archived: include_archived).lazy
45
+ .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
46
+ .select { |s| root.nil? || in_folder?(s.working_directory, root) }
47
+ .filter_map do |s|
48
+ dir = Session.session_dir(s.id, state_dir: sd)
67
49
  owner = session_owner(s.id, state_dir: sd)
68
- owned = !!owner&.worker?
69
- next if live && !owned
50
+ link = WorkerLink.resolve(dir, unlink: false, owner: owner)
51
+ next if live && !link.worker_owned?
70
52
 
71
- { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), title: s.title, cwd: s.working_directory,
72
- project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.displayed_status(owner: owner), saved_status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
73
- owner: owner&.kind, **recap_fields(Session.session_dir(s.id, state_dir: sd)),
74
- ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd),
75
- budget_tokens: -> { budgets.call(s) })&.round(1),
76
- parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), continues: s.continues,
77
- continues_short_id: s.continues&.[](0, 8), delegate: s.delegate?, archived: s.archived,
78
- scratch: s.scratch, test_run: s.test_run, stopped_by: s.stopped_by }.merge(waiting_fields(s.waiting_question(live: owned)))
53
+ SessionRow.build(s, session_dir: dir, owner: owner, link: link, roots: roots, budgets: budgets)
79
54
  end
80
- (limit ? summaries.first(limit) : summaries.to_a)
55
+ (limit ? rows.first(limit) : rows.to_a)
81
56
  end
82
57
 
83
- # waiting: the question's kind, waiting_id: its id (what chi answer
84
- # --question takes); both nil with none.
85
- private def waiting_fields(waiting)
86
- { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
87
- end
88
-
89
- # One HostRegistry for a whole listing, built on the first row that
90
- # needs it (a saved context to count) and kept for the rest: resolving a
91
- # session's llm_context budget reads config.yml (mtime-cached), so not
92
- # one registry per row.
93
- # @return [#call] (session) -> the session's budget in tokens or nil
94
- private def listing_budgets
95
- registry = nil
96
- ->(session) { LLMContextStrategy.session_budget(session, registry: registry ||= HostRegistry.new) }
97
- end
58
+ # .session_rows as the summaries route's hashes (SessionRow#to_summary_h,
59
+ # its 28 keys in order): chi web's GET /api/sessions/summaries.
60
+ # @return [Array<Hash>]
61
+ def session_summaries(**filters) = session_rows(**filters).map(&:to_summary_h)
98
62
 
99
63
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
100
- # first, as .session_summaries rows. A running one (busy) counts against
64
+ # first, as .session_rows. A running one (busy) counts against
101
65
  # session.max_children. Archived ones too.
66
+ # @return [Array<SessionRow>]
102
67
  def children_of(parent_id, state_dir: nil)
103
68
  return [] if parent_id.to_s.empty?
104
69
 
105
- session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
106
- .select { |s| s[:parent_id] == parent_id.to_s }
70
+ session_rows(state_dir: state_dir, include_tests: true, include_archived: true)
71
+ .select { |s| s.parent_id == parent_id.to_s }
107
72
  end
108
73
 
109
74
  # Children, their children, …: a delegated session doesn't delegate
@@ -113,51 +78,15 @@ module Samagotchi
113
78
  queue = [id]
114
79
  until queue.empty?
115
80
  children_of(queue.shift, state_dir: state_dir).each do |child|
116
- next if seen.include?(child[:id]) || except.include?(child[:id])
81
+ next if seen.include?(child.id) || except.include?(child.id)
117
82
 
118
- seen << child[:id]
119
- queue << child[:id]
83
+ seen << child.id
84
+ queue << child.id
120
85
  end
121
86
  end
122
87
  seen.drop(1)
123
88
  end
124
89
 
125
- # "<cwd basename> · <last prompt, or the first preview>", one line.
126
- private def summary_desc(session)
127
- desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
128
- cut(desc, SUMMARY_DESC_LIMIT)
129
- end
130
-
131
- # A session row's recap: and auto_title: (RecapStore.card, one read).
132
- private def recap_fields(session_dir)
133
- card = RecapStore.card(session_dir)
134
- { recap: card.preview, auto_title: card.title }
135
- end
136
-
137
- private def summary_preview(session)
138
- cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
139
- end
140
-
141
- # A session as one line of text for the lists: its title (/title), else
142
- # its last prompt, else the first message it was started with (a session
143
- # command like `/model x`, or a first turn not saved yet).
144
- def summary_text(session)
145
- return one_line(session.title) unless session.title.to_s.strip.empty?
146
-
147
- one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
148
- end
149
-
150
- # A prompt as one line for a session list: whitespace collapsed, and a
151
- # quoted or annotated message (ContextQuote, the web's annotations)
152
- # without its "> " markers, so the words show.
153
- def one_line(text)
154
- text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
155
- end
156
-
157
- private def cut(text, limit)
158
- text.length > limit ? "#{text[0, limit - 1]}…" : text
159
- end
160
-
161
90
  private def folder_path(path)
162
91
  File.realpath(path)
163
92
  rescue SystemCallError
@@ -26,7 +26,7 @@ require_relative "participant_messages"
26
26
  require_relative "session"
27
27
  require_relative "session_inbox"
28
28
  require_relative "session_metrics"
29
- require_relative "llm_context_strategy"
29
+ require_relative "effective_model"
30
30
  require_relative "session_setup"
31
31
  require_relative "host_registry"
32
32
  require_relative "utf8_default"
@@ -48,6 +48,7 @@ require_relative "model_profile"
48
48
  require_relative "installed_gem"
49
49
  require_relative "installed_versions"
50
50
  require_relative "recap_store"
51
+ require_relative "session_row"
51
52
  require_relative "reminder_carry"
52
53
  require_relative "archive_store"
53
54
  require_relative "continue_offer"
@@ -1112,19 +1112,22 @@ module Samagotchi
1112
1112
  if guardrail.is_a?(Hash) && guardrail[:decided_by] == "system one"
1113
1113
  active[:guardrail] = { rule: guardrail[:rule], p: guardrail[:p], judgment_id: guardrail[:judgment_id] }.compact
1114
1114
  end
1115
- finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
1115
+ finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i,
1116
+ duration_ms: event[:duration_ms])
1116
1117
  end
1117
1118
  end
1118
1119
 
1119
- # Caller holds the mutex.
1120
- def finish_tool_record(active, status, waited_ms: 0)
1120
+ # Caller holds the mutex. +duration_ms+: the event's (ToolRunner's,
1121
+ # the figure every UI shows live); without one (a test kernel's
1122
+ # events), timed here.
1123
+ def finish_tool_record(active, status, waited_ms: 0, duration_ms: nil)
1121
1124
  record = active.slice(
1122
1125
  :id, :turn_id, :iteration, :call_index, :tool, :called_as, :via, :no_match, :guardrail, :started_at
1123
1126
  ).merge(
1124
1127
  status: status,
1125
1128
  completed_at: now.iso8601(3),
1126
1129
  # Less a guardrail approval wait (ToolRunner's waited_ms).
1127
- duration_ms: [elapsed_ms(active[:started_monotonic]) - waited_ms, 0].max
1130
+ duration_ms: duration_ms.is_a?(Integer) ? duration_ms : [elapsed_ms(active[:started_monotonic]) - waited_ms, 0].max
1128
1131
  )
1129
1132
  @tool_records << record
1130
1133
  count_tool(record)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paths"
4
+
5
+ module Samagotchi
6
+ # Where a session's files are and which ids name one: the sessions dir,
7
+ # a session's <id>.json and <id>/ dir, and the id rule every id from
8
+ # outside meets before it becomes a path. A leaf (Paths only), so a file
9
+ # that needs a session's path needs no Session: Session extends it, so
10
+ # Session.session_dir and the rest answer the same. `extend self`, not
11
+ # module_function: Session's copies must stay public.
12
+ module SessionPaths
13
+ extend self # rubocop:disable Style/ModuleFunction -- module_function would make Session's copies private
14
+
15
+ STATE_SUBDIR = File.join("samagotchi", "sessions")
16
+ FILE_EXT = ".json"
17
+
18
+ # An id that is not a session id: it could name a path outside the
19
+ # sessions dir ("../x", "/etc", "a/b", a NUL), or is empty.
20
+ class InvalidId < ArgumentError; end
21
+
22
+ # Ids are SecureRandom.uuid; anything made of letters, digits, "-" and
23
+ # "_" (up to 128, not starting with "-") is taken, so a UUID prefix the
24
+ # CLI resolves passes too. This is the one check every id from outside
25
+ # (the bridge, the web routes, chi send/note, delegate) meets before it
26
+ # becomes a path: session_dir and the session file path refuse others.
27
+ VALID_ID = /\A[A-Za-z0-9_][A-Za-z0-9_-]{0,127}\z/
28
+
29
+ def valid_id?(session_id)
30
+ session_id.is_a?(String) && VALID_ID.match?(session_id)
31
+ end
32
+
33
+ # @raise [InvalidId] unless valid_id?(session_id)
34
+ def check_id!(session_id)
35
+ return session_id if valid_id?(session_id)
36
+
37
+ raise InvalidId, "invalid session id: #{session_id.to_s[0, 80].inspect}"
38
+ end
39
+
40
+ # Directory for a specific session (holds IPC files alongside session.json).
41
+ # @raise [InvalidId] for an id that isn't one (valid_id?)
42
+ def session_dir(session_id, state_dir: default_state_dir)
43
+ File.join(state_dir, check_id!(session_id))
44
+ end
45
+
46
+ # A session's <id>.json.
47
+ # @raise [InvalidId] for an id that isn't one (valid_id?)
48
+ def session_file(session_id, state_dir: default_state_dir)
49
+ File.join(state_dir, "#{check_id!(session_id)}#{FILE_EXT}")
50
+ end
51
+
52
+ # XDG-aware sessions directory.
53
+ def default_state_dir(env: ENV)
54
+ File.join(Paths.state_home(env: env), STATE_SUBDIR)
55
+ end
56
+ end
57
+ end
@@ -78,7 +78,7 @@ module Samagotchi
78
78
  Dir.children(state_dir).filter_map do |name|
79
79
  dir = File.join(state_dir, name)
80
80
  next unless Session.valid_id?(name) && File.directory?(dir)
81
- next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
81
+ next if File.exist?(File.join(state_dir, "#{name}#{SessionPaths::FILE_EXT}"))
82
82
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
83
83
  next if SessionManager.session_owner(name, state_dir: state_dir)
84
84
 
@@ -203,7 +203,7 @@ module Samagotchi
203
203
  passed_over = 0
204
204
 
205
205
  all.each_with_index do |session, idx|
206
- path = File.join(state_dir, "#{session.id}#{Session::FILE_EXT}")
206
+ path = File.join(state_dir, "#{session.id}#{SessionPaths::FILE_EXT}")
207
207
  # Only when json present
208
208
  unless File.exist?(path)
209
209
  skipped << session.id