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
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "session"
5
+ require_relative "owner_lock"
6
+ require_relative "worker_link"
7
+ require_relative "recap_store"
8
+ require_relative "session_metrics"
9
+ require_relative "archive_store"
10
+ require_relative "effective_model"
11
+ require_relative "host_registry"
12
+
13
+ module Samagotchi
14
+ # A session as the lists see it: the one row every listing builds and
15
+ # reads (SessionManager.session_rows, chi sessions list on both backends,
16
+ # the picker, list_sessions, a broadcast's recipients, a parent's
17
+ # children, chi web's summaries route and Web::SessionSummary). Only
18
+ # scalar facts that go on the wire: #to_summary_h is the summaries route's
19
+ # body (28 keys, this order) and .from_summary_h reads it back, so a
20
+ # remote row prints the same #cli_line as a local one.
21
+ #
22
+ # The liveness rule: +live+ is WorkerLink's worker_owned? (a worker holds
23
+ # the lock, its Bridge up or on its way); +busy+ and +waiting+ need it,
24
+ # +status+ is Session#displayed_status (a dead worker's "running" is idle).
25
+ SessionRow = Data.define(:id, :desc, :preview, :title, :cwd, :project, :updated_at, :status, :saved_status, :live,
26
+ :busy, :owner, :recap, :auto_title, :ctx_pct, :parent_id, :continues, :delegate, :archived,
27
+ :scratch, :test_run, :stopped_by, :waiting, :waiting_id, :relayed_to)
28
+
29
+ class SessionRow
30
+ DESC_LIMIT = 60
31
+ PREVIEW_LIMIT = 120
32
+ # Seconds a child saved "running" with no worker yet counts as one on
33
+ # its way (a worker boots in a few seconds): .running_or_starting?.
34
+ STARTING_GRACE_SECONDS = 15
35
+
36
+ # The summaries route's keys, in their order (#to_summary_h).
37
+ SUMMARY_KEYS = %i[id short_id desc preview title cwd project updated_at status saved_status live busy owner recap
38
+ auto_title ctx_pct parent_id parent_short_id continues continues_short_id delegate archived
39
+ scratch test_run stopped_by waiting waiting_id relayed_to].freeze
40
+ DERIVED_KEYS = %i[short_id parent_short_id continues_short_id].freeze
41
+ # `chi sessions list --format json`'s keys (of SUMMARY_KEYS: no status).
42
+ # owner: "worker", "tui" (a one-turn chi run: it takes no notes or
43
+ # messages) or nil; recap: the saved recap's first sentence, or nil;
44
+ # project: its git project's root, or nil; parent_id: the session that
45
+ # delegated it (the delegate tool), or nil; delegate: started by the
46
+ # delegate tool (a fork has a parent_id too); continues: the session it
47
+ # continues (its chain's previous link), or nil; archived: hidden from
48
+ # the lists (only with --archived can it be true); scratch: a `chi
49
+ # scratch` session; ctx_pct: how full the context was after the last
50
+ # turn, or nil; title: the session's own name (/title; desc shows it
51
+ # too), or nil; auto_title: the one its recap gave it (RecapTitle), or
52
+ # nil; waiting: the kind of question it waits on (question, approval,
53
+ # hook), or nil; waiting_id: its id, for chi answer --question;
54
+ # relayed_to: the parent session (short id) whose card it also waits in
55
+ # (the approval relay), or nil.
56
+ JSON_KEYS = %i[id short_id desc title auto_title cwd project updated_at live busy owner recap parent_id continues
57
+ delegate archived scratch ctx_pct waiting waiting_id relayed_to stopped_by].freeze
58
+
59
+ # @param session [Session] a saved session
60
+ # @param session_dir [String] its folder
61
+ # @param owner [OwnerLock::Owner, nil] the lock's holder as read
62
+ # (SessionManager.session_owner)
63
+ # @param link [WorkerLink outcome, nil] resolved with +owner+; nil:
64
+ # resolved here (one read of bridge.json)
65
+ # @param roots [Hash] ProjectScope.root_for's cache, shared by a listing
66
+ # @param budgets [#call] (session) -> its llm_context budget (.budgets:
67
+ # one HostRegistry per listing)
68
+ # @param saved [SessionMetrics::SavedSummary, nil, :read] the saved
69
+ # analytics as a caller already read it; :read reads it here
70
+ # @param card [RecapStore::Card, :read] the recap card, likewise
71
+ # @return [SessionRow]
72
+ def self.build(session, session_dir:, owner:, link: nil, roots: {}, budgets: SessionRow.budgets, saved: :read,
73
+ card: :read)
74
+ link ||= WorkerLink.resolve(session_dir, unlink: false, owner: owner)
75
+ live = link.worker_owned?
76
+ card = RecapStore.card(session_dir) if card == :read
77
+ if saved == :read
78
+ saved = SessionMetrics.saved_summary(session_dir, budget_tokens: -> { budgets.call(session) })
79
+ end
80
+ waiting = session.waiting_question(live: live)
81
+ new(id: session.id, desc: desc_of(session), preview: cut(summary_text(session), PREVIEW_LIMIT),
82
+ title: session.title, cwd: session.working_directory, project: session.project_root(cache: roots),
83
+ updated_at: session.updated_at, status: session.displayed_status(owner: owner), saved_status: session.status,
84
+ live: live, busy: live && session.status == Session::STATUS_RUNNING, owner: owner&.kind,
85
+ recap: card.preview, auto_title: card.title, ctx_pct: saved&.ctx_pct&.round(1),
86
+ parent_id: session.parent_id, continues: session.continues, delegate: session.delegate?,
87
+ archived: ArchiveStore.archived?(session_dir), scratch: session.scratch, test_run: session.test_run,
88
+ stopped_by: session.stopped_by, waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id),
89
+ relayed_to: waiting&.dig(:relayed_to))
90
+ end
91
+
92
+ # A row read back from #to_summary_h's hash (symbol keys; a remote's
93
+ # rows, Remote#symbolize'd). A key the hash lacks reads nil.
94
+ def self.from_summary_h(hash)
95
+ new(**members.to_h { |key| [key, hash[key]] })
96
+ end
97
+
98
+ # The budget resolver a listing shares: the process's HostRegistry
99
+ # (HostRegistry.current), taken on the first row that needs it (a saved
100
+ # context to count) and kept for the rest of the listing.
101
+ # @return [#call] (session) -> the session's budget in tokens or nil
102
+ def self.budgets
103
+ registry = nil
104
+ ->(session) { EffectiveModel.session_budget(session, registry: registry ||= HostRegistry.current) }
105
+ end
106
+
107
+ # Busy (a worker runs its turn), or saved running with no worker yet
108
+ # for less than STARTING_GRACE_SECONDS: a worker on its way (it counts
109
+ # against session.max_children; Verify fresh waits for it). The
110
+ # displayed status shows that one as idle, so +saved_status+ is the
111
+ # file's.
112
+ def self.running_or_starting?(saved_status:, live:, busy:, updated_at:)
113
+ return true if busy
114
+ return false unless saved_status == Session::STATUS_RUNNING && !live
115
+
116
+ Time.now - Time.iso8601(updated_at.to_s) < STARTING_GRACE_SECONDS
117
+ rescue ArgumentError
118
+ false
119
+ end
120
+
121
+ # A session as one line of text for the lists: its title (/title), else
122
+ # its last prompt, else the first message it was started with (a session
123
+ # command like `/model x`, or a first turn not saved yet).
124
+ def self.summary_text(session)
125
+ return one_line(session.title) unless session.title.to_s.strip.empty?
126
+
127
+ one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
128
+ end
129
+
130
+ # A prompt as one line for a session list: whitespace collapsed, and a
131
+ # quoted or annotated message (ContextQuote, the web's annotations)
132
+ # without its "> " markers, so the words show.
133
+ def self.one_line(text)
134
+ text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
135
+ end
136
+
137
+ # "<cwd basename> · <last prompt, or the first preview>", one line.
138
+ # The flags a list line carries: a chi scratch session, a test run, an
139
+ # archived one, and the turn's stopper: "[looped]" for loop-guard (the
140
+ # bundle's thinking watch, and the word `chi sessions stats` uses for
141
+ # its cuts), "[stopped by X]" for any other hook (a cut with no retry
142
+ # left records the same stopped_by).
143
+ def self.flags(scratch:, test_run:, archived:, stopped_by: nil)
144
+ flag = if scratch
145
+ " [scratch]"
146
+ else
147
+ (test_run ? " [test]" : "")
148
+ end
149
+ flag += " [archived]" if archived
150
+ return flag if stopped_by.to_s.empty?
151
+
152
+ flag + (stopped_by.to_s == "loop-guard" ? " [looped]" : " [stopped by #{stopped_by}]")
153
+ end
154
+
155
+ def self.desc_of(session)
156
+ desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
157
+ cut(desc, DESC_LIMIT)
158
+ end
159
+ private_class_method :desc_of
160
+
161
+ def self.cut(text, limit)
162
+ text.length > limit ? "#{text[0, limit - 1]}…" : text
163
+ end
164
+ private_class_method :cut
165
+
166
+ def short_id = id.to_s[0, 8]
167
+ def parent_short_id = parent_id&.[](0, 8)
168
+ def continues_short_id = continues&.[](0, 8)
169
+
170
+ def running_or_starting?
171
+ self.class.running_or_starting?(saved_status: saved_status, live: live, busy: busy, updated_at: updated_at)
172
+ end
173
+
174
+ # The session as `chi sessions list` prints it, wherever it runs (the
175
+ # plain list, the --live/--cwd text, a remote's): its id, state (a
176
+ # question it waits on, else the displayed status), the context's fill,
177
+ # updated_at, its text (a title, else the recap's auto title or first
178
+ # sentence, else the last prompt; 60 characters), its flags, and the
179
+ # parent it was delegated from (↳) and the link it continues (↪). Reads
180
+ # only row fields, so a remote row prints the same line.
181
+ def cli_line
182
+ state = if relayed_to then "waiting (in parent #{relayed_to})"
183
+ elsif waiting then "waiting"
184
+ else status.to_s
185
+ end
186
+ text = (title ? preview : auto_title || recap || preview).to_s[0, DESC_LIMIT]
187
+ links = parent_short_id ? " ↳ #{parent_short_id}" : ""
188
+ links += " ↪ #{continues_short_id}" if continues_short_id
189
+ ctx = SessionMetrics.context_label(ctx_pct)
190
+ flag = self.class.flags(scratch: scratch, test_run: test_run, archived: archived, stopped_by: stopped_by)
191
+ "#{id} #{state.ljust(8)} #{ctx.ljust(8)} #{updated_at} #{text}#{flag}#{links}"
192
+ end
193
+
194
+ # The summaries route's row: SUMMARY_KEYS, in order.
195
+ # @return [Hash] symbol keys
196
+ def to_summary_h
197
+ SUMMARY_KEYS.to_h { |key| [key, public_send(key)] }
198
+ end
199
+ end
200
+ end
@@ -42,24 +42,27 @@ module Samagotchi
42
42
  working_set_pins: WorkingSetPin.list(working_set_pins))
43
43
  end
44
44
 
45
- def empty?
46
- llm_context.nil? && thinking.nil? && reasoning_history.nil? && appended_prompt_files.empty? && toolkit.nil? &&
47
- toolkit_origin.nil? && settings.empty? && parent_answers.nil? && working_set_pins.empty?
48
- end
45
+ # Every member at its default: none of the session's own settings.
46
+ def empty? = self == self.class.new
49
47
 
50
- # A session's own setup, for a session that starts from it. Its
51
- # toolkit always: one from before toolkits is full (Toolkit.of), not
52
- # whatever config's default is now.
48
+ # A session's own setup, for a session that starts from it: each
49
+ # member is the session's field of the same name (Session.new_session
50
+ # applies them by name), but for SessionSetup::CARRY's. Its toolkit
51
+ # always: one from before toolkits is full (Toolkit.of), not whatever
52
+ # config's default is now.
53
53
  # @param session [Session, nil]
54
54
  # @return [SessionSetup]
55
55
  def self.of(session)
56
56
  return new if session.nil?
57
57
 
58
- new(llm_context: session.llm_context, thinking: session.thinking, reasoning_history: session.reasoning_history,
59
- appended_prompt_files: session.appended_prompt_files, toolkit: Toolkit.of(session.toolkit),
60
- toolkit_origin: "inherited",
61
- settings: session.settings, parent_answers: session.parent_answers,
62
- working_set_pins: session.working_set_pins)
58
+ new(**members.to_h { |member| [member, self::CARRY.fetch(member) { ->(s) { s.public_send(member) } }.call(session)] })
63
59
  end
64
60
  end
61
+
62
+ # How SessionSetup.of carries a member that isn't its session's field as
63
+ # it is: member => (session) -> value.
64
+ SessionSetup::CARRY = {
65
+ toolkit: ->(session) { Toolkit.of(session.toolkit) },
66
+ toolkit_origin: ->(_session) { "inherited" }
67
+ }.freeze
65
68
  end
@@ -2,12 +2,12 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "session"
5
+ require_relative "session_row"
5
6
  require_relative "session_manager"
6
7
  require_relative "session_retention"
7
8
  require_relative "project_scope"
8
9
  require_relative "analytics_file"
9
10
  require_relative "session_metrics"
10
- require_relative "recap_store"
11
11
  require_relative "session_delete_command"
12
12
  require_relative "session_archive_command"
13
13
  require_relative "worker_link"
@@ -142,28 +142,6 @@ module Samagotchi
142
142
  options.to_h { |key, value| [key, INTEGER_OPTIONS.include?(key) ? Integer(value, 10) : value] }
143
143
  end
144
144
 
145
- # The flags a list row carries: a chi scratch session, a test run, an
146
- # archived one, and the turn's stopper (see #stopped_flag).
147
- def session_flag(scratch:, test_run:, archived:, stopped_by: nil)
148
- flag = if scratch
149
- " [scratch]"
150
- else
151
- (test_run ? " [test]" : "")
152
- end
153
- flag += " [archived]" if archived
154
- flag + stopped_flag(stopped_by)
155
- end
156
-
157
- # The marker for a turn a hook stopped: "[looped]" for loop-guard (the
158
- # bundle's thinking watch, and the word `chi sessions stats` uses for its
159
- # cuts), "[stopped by X]" for any other hook; "" otherwise. A cut with no
160
- # retry left records the same stopped_by.
161
- def stopped_flag(by)
162
- return "" if by.to_s.empty?
163
-
164
- by.to_s == "loop-guard" ? " [looped]" : " [stopped by #{by}]"
165
- end
166
-
167
145
  def list
168
146
  format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
169
147
  unless format.nil? || %w[text json tsv].include?(format)
@@ -175,7 +153,8 @@ module Samagotchi
175
153
  # chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
176
154
  # project's. Machine-local (this cwd's project): remotely every
177
155
  # project's (D4), and --scope=project is --cwd's project there (the
178
- # server finds its root). The rows are the server's (list_summaries).
156
+ # server finds its root). The rows are SessionRows, the server's
157
+ # remotely: one line wherever the list runs (SessionRow#cli_line).
179
158
  # The picker (chi note from a script): filters apply before the limit,
180
159
  # and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
181
160
  # then its own sessions are what it looks for. The plain listing shows
@@ -191,10 +170,7 @@ module Samagotchi
191
170
  end
192
171
  project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
193
172
  scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
194
-
195
- return list_summaries(project, scope_note, include_tests: include_tests) if picker
196
-
197
- list_plain(project, scope_note)
173
+ list_summaries(project, scope_note, include_tests: include_tests)
198
174
  end
199
175
 
200
176
  def list_summaries(project, scope_note, include_tests:)
@@ -207,117 +183,23 @@ module Samagotchi
207
183
  )
208
184
  case format
209
185
  when "json"
210
- # owner: "worker", "tui" (a one-turn chi run: it takes no notes or messages)
211
- # or nil; recap: the saved recap's first sentence, or nil; project:
212
- # its git project's root, or nil
213
- # parent_id: the session that delegated it (the delegate tool), or nil
214
- # delegate: started by the delegate tool (a fork has a parent_id too)
215
- # continues: the session it continues (its chain's previous link), or nil
216
- # archived: hidden from the lists (only with --archived can it be true)
217
- # scratch: a `chi scratch` session (deleted when its terminal leaves,
218
- # a leftover one at the next sweep)
219
- # ctx_pct: how full the context was after the last turn, or nil
220
- # title: the session's own name (/title; desc shows it too), or nil;
221
- # auto_title: the one its recap gave it (RecapTitle), or nil
222
- # waiting: the kind of question it waits on (question, approval,
223
- # hook: chi answer or the web answers it), or nil; waiting_id: its
224
- # id, for chi answer --question; relayed_to: the parent session (short
225
- # id) whose card it also waits in (the approval relay), or nil
226
- keys = %i[id short_id desc title auto_title cwd project updated_at live busy owner recap parent_id continues delegate archived scratch ctx_pct
227
- waiting waiting_id relayed_to stopped_by]
228
- @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
186
+ # SessionRow::JSON_KEYS (its comment says what each one is).
187
+ @stdout.puts JSON.generate(summaries.map { |row| row.to_summary_h.slice(*Samagotchi::SessionRow::JSON_KEYS) })
229
188
  when "tsv"
230
- summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
189
+ summaries.each { |row| @stdout.puts "#{row.id}\t#{row.desc}" }
231
190
  else
232
- summaries.each do |summary|
233
- state = if summary[:relayed_to] then "waiting (in parent #{summary[:relayed_to]})"
234
- elsif summary[:waiting] then "waiting"
235
- elsif summary[:busy] then "running"
236
- else summary[:live] ? "live" : summary[:status].to_s
237
- end
238
- # The recap's auto title, else its first sentence, says more than
239
- # the last prompt (text only: tsv and json keep desc for pickers);
240
- # a title (in desc) says what the user named it.
241
- desc = summary[:desc]
242
- named = summary[:auto_title] || summary[:recap]
243
- if named && !summary[:title]
244
- desc = [File.basename(summary[:cwd].to_s), named].reject(&:empty?).join(" · ")
245
- desc = "#{desc[0, 59]}…" if desc.length > 60
246
- end
247
- # A delegated session points at its parent.
248
- child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
249
- # A chain's link points at the one it continues.
250
- child += " ↪ #{summary[:continues_short_id]}" if summary[:continues_short_id]
251
- flag = session_flag(scratch: summary[:scratch], test_run: summary[:test_run], archived: summary[:archived],
252
- stopped_by: summary[:stopped_by])
253
- ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
254
- @stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
255
- end
191
+ summaries.each { |row| @stdout.puts row.cli_line }
256
192
  if project
257
193
  @stdout.puts "#{"\n" unless summaries.empty?}#{scope_note.call(summaries.size)}"
194
+ elsif summaries.empty?
195
+ @stdout.puts "No sessions."
258
196
  else
259
- @stdout.puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
197
+ @stdout.puts "\n#{summaries.size} session(s) (sort=#{sort || "updated_at"} order=#{order || "desc"})"
260
198
  end
261
199
  end
262
200
  0
263
201
  end
264
202
 
265
- def list_plain(project, scope_note)
266
- sort, order, limit, include_archived = @opts.values_at(:sort, :order, :limit, :include_archived)
267
- sort ||= "updated_at"
268
- order ||= "desc"
269
- sessions = @access.list(sort: sort, order: order, limit: limit, project_root: project, include_archived: include_archived)
270
- # The session's title, else the recap's auto title, else its first
271
- # sentence, else the last prompt (or the first message, before any
272
- # turn), cut as before.
273
- # These rows read each session's folder here (its recap, saved
274
- # context, owner): machine-local, the server's rows remotely (P2's
275
- # list parity).
276
- state_dir = Samagotchi::Session.default_state_dir
277
- list_text = lambda do |s|
278
- card = s.title ? nil : Samagotchi::RecapStore.card(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
279
- (card&.title || card&.preview || Samagotchi::SessionManager.summary_text(s))[0, 60]
280
- end
281
- # How full the context was after the last turn: "ctx 12%", counted
282
- # against the session's llm_context budget when one is set and smaller
283
- # than the window, as the live meter counts it (one registry for the
284
- # whole table, built when the first row has a saved context). Blank when
285
- # unknown.
286
- budgets = nil
287
- ctx = lambda do |s|
288
- Samagotchi::SessionMetrics.context_label(
289
- Samagotchi::SessionMetrics.saved_context_pct(
290
- Samagotchi::Session.session_dir(s.id, state_dir: state_dir),
291
- budget_tokens: -> { Samagotchi::LLMContextStrategy.session_budget(s, registry: budgets ||= Samagotchi::HostRegistry.new) }
292
- )
293
- )
294
- end
295
- # A delegated session points at its parent: ↳ <parent's short id>.
296
- row = lambda do |s|
297
- flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
298
- child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
299
- child += " ↪ #{s.continues[0, 8]}" if s.continues
300
- # A question waits for an answer (chi answer, the web, chi --attach).
301
- live = s.pending_question && worker_owned?(s.id, state_dir)
302
- waiting = s.waiting_question(live: !!live)
303
- status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
304
- elsif waiting then "waiting"
305
- else s.displayed_status(owner: Samagotchi::SessionManager.session_owner(s.id, state_dir: state_dir))
306
- end
307
- "#{s.id} #{status.ljust(8)} #{ctx.call(s).ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
308
- end
309
- if project
310
- sessions.each { |s| @stdout.puts row.call(s) }
311
- @stdout.puts "#{"\n" unless sessions.empty?}#{scope_note.call(sessions.size)}"
312
- elsif sessions.empty?
313
- @stdout.puts "No sessions."
314
- else
315
- sessions.each { |s| @stdout.puts row.call(s) }
316
- @stdout.puts "\n#{sessions.size} session(s) (sort=#{sort} order=#{order})"
317
- end
318
- 0
319
- end
320
-
321
203
  # One session's cost, tokens and progress, for a script or an agent that
322
204
  # drives sessions (chi send --wait): a live worker's GET stats, else the
323
205
  # snapshot its saved analytics.json rebuilds. Never starts a worker, and
@@ -364,6 +364,16 @@ module Samagotchi
364
364
  turn_prompt?(message) && !shell_line?(message)
365
365
  end
366
366
 
367
+ # An opening's stamp: its turn_id (a prompt's, a wake note's), else its
368
+ # own opening_id (/continue no's reason line); nil for neither (a line
369
+ # an older chi wrote). Where a card (Bridge::CardPlaces) and the recap
370
+ # (RecapPlace) sit.
371
+ # @return [String, nil]
372
+ def opening_stamp(message)
373
+ id = message[:turn_id] || message["turn_id"] || message[:opening_id] || message["opening_id"]
374
+ id.is_a?(String) && !id.empty? ? id : nil
375
+ end
376
+
367
377
  # Call a loop's drain: +at_answer+ goes only to a drain that takes it
368
378
  # (the Engine's); a caller's own drain (a queue's #drain) is called bare.
369
379
  # A failing drain drains nothing (the loop keeps going).
@@ -32,27 +32,20 @@ module Samagotchi
32
32
  nil
33
33
  end
34
34
 
35
- # The seconds for +target+ and where they came from
35
+ # The seconds for +lookup+'s model and where they came from
36
36
  # ("SAMAGOTCHI_STEER_CUT_AFTER", "models: qwen", "hosts.splash",
37
37
  # "steer.cut_after"; nil for the default).
38
- # @param target [HostRegistry::ModelTarget]
39
- # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
40
- # @param models [Hash{String => ModelSettings}, nil] ModelSettings.all (specs)
38
+ # @param lookup [ModelLookup] the model's layers (EffectiveModel#lookup)
41
39
  # @return [Array(Integer, String|nil)]
42
- def resolve(target, names:, models: nil)
43
- value, origin = Config.get_with_origin("steer.cut_after")
40
+ def resolve(lookup)
41
+ value, origin = lookup.global("steer.cut_after")
44
42
  return [value.to_i, ENV_KEY] if origin == :env
45
43
 
46
- models ||= begin
47
- ModelSettings.all
48
- rescue StandardError
49
- {}
50
- end
51
- setting = ModelSettings.find(names, :steer_cut_after, models: models)
44
+ setting = lookup.model(:steer_cut_after)
52
45
  return [setting.value, "models: #{setting.key}"] if setting
53
46
 
54
- host = target.entry.steer_cut_after
55
- return [host, "hosts.#{target.entry.name}"] unless host.nil?
47
+ host = lookup.host(:steer_cut_after)
48
+ return [host, "hosts.#{lookup.host_name}"] unless host.nil?
56
49
 
57
50
  return [value.to_i, "steer.cut_after"] if origin == :file
58
51
 
@@ -291,7 +291,7 @@ module Samagotchi
291
291
  6. Use write for full-file rewrites or creating new files.
292
292
 
293
293
  Memory convention:
294
- Project scope: one folder per git repository, shared by its worktrees and subdirectories (its path is on the "Project memories folder" line below)
294
+ Project scope: one folder per project (a git repository, shared by its worktrees and subdirectories, or a folder marked by `chi project init` and its subfolders; its path is on the "Project memories folder" line below)
295
295
  System scope: ~/.config/samagotchi/memories/ (cross-project)
296
296
  memory_read accepts optional scope (project|system).
297
297
  memory_write requires explicit scope and entry name.
@@ -623,10 +623,20 @@ module Samagotchi
623
623
  File.expand_path(path)
624
624
  end
625
625
 
626
+ # The project_location line over its top, for a checkout (git) or a
627
+ # marked project: [below the top, at the top].
628
+ TOP_LINES = {
629
+ git: ["Top of this checkout (where the project's commands run from; stay inside it):",
630
+ "This checkout (the project's files; its commands run here):"],
631
+ marker: ["Top of this project (where its commands run from; stay inside it):",
632
+ "This project (its files; its commands run here):"]
633
+ }.freeze
634
+
626
635
  # Where the session runs and which project memory folder it uses. Only
627
- # this checkout is named: its top (at its root too, so a model doesn't
628
- # take the memories folder for the project and cd there, Qwen
629
- # 2026-10-06), and in a linked worktree a line that work stays in it. The repository's main
636
+ # this checkout (or marked project, MemoryPaths.lookup) is named: its
637
+ # top (at its root too, so a model doesn't take the memories folder for
638
+ # the project and cd there, Qwen 2026-10-06), and in a linked worktree
639
+ # a line that work stays in it. The repository's main
630
640
  # checkout (MemoryPaths.project_root, which scopes memories and session
631
641
  # lists) is never shown: a model told its path went to work there
632
642
  # (a worktree child committed onto main, 2026-10-03 and 2026-10-06).
@@ -635,16 +645,15 @@ module Samagotchi
635
645
  def project_location
636
646
  Degrade.call(:prompt, "location_failed") do
637
647
  cwd = Dir.pwd
638
- top = MemoryPaths.work_tree_root(cwd)
648
+ found = MemoryPaths.lookup(cwd)
649
+ top = found.top
639
650
  lines = ["Current working directory:", cwd]
640
- if top && top != cwd
641
- lines << "Top of this checkout (where the project's commands run from; stay inside it):"
642
- lines << top
643
- elsif top
644
- lines << "This checkout (the project's files; its commands run here):"
651
+ if top
652
+ below, at = TOP_LINES.fetch(found.kind)
653
+ lines << (top == cwd ? at : below)
645
654
  lines << top
646
655
  end
647
- if top && MemoryPaths.project_root(cwd) != top
656
+ if found.git? && found.root != top
648
657
  lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
649
658
  "repository's other checkouts."
650
659
  end
@@ -613,39 +613,19 @@ module Samagotchi
613
613
  lane = :writing
614
614
  when :generation_chunk
615
615
  tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
616
- when :tool_call_started
617
- running_tool = event[:tool]
618
- @renderer.call(event)
619
- when :tool_call_completed
620
- running_tool = nil
621
- replay_tool_completed(event)
622
- when :merged_input then @screen.commit("input> #{event[:content]}")
623
- when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
624
- # The turn's start (its images), a plugin's steer, and its rows (a
625
- # hook's notice, a retry's line), as they were drawn live.
626
- # Questions are left out: the pending one is asked after the
627
- # replay, answered ones showed no line.
616
+ # The turn's start (its images), as it was drawn live.
628
617
  when :turn_started then @renderer.call(without_named_images(event, turn[:prompt]))
629
- when :pending_input_merged, :hook_notice, :empty_answer_retry, :steer_cut, :llm_context_edited
630
- @renderer.call(event)
618
+ # Its rows, as a drawn turn's (JoinReplay#replay_row).
619
+ else
620
+ running_tool = event[:tool] if event[:type] == :tool_call_started
621
+ running_tool = nil if event[:type] == :tool_call_completed
622
+ @join_replay.replay_row(event)
631
623
  end
632
624
  end
633
625
  @view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
634
626
  ask(turn[:pending_question]) if turn[:pending_question]
635
627
  end
636
628
 
637
- # A finished tool call of the joined turn: the live row (action,
638
- # duration). A snapshot from an older worker has no action: its row
639
- # names the tool only.
640
- def replay_tool_completed(event)
641
- return @renderer.call({ duration_ms: nil }.merge(event)) if event.dig(:activity, :action)
642
-
643
- activity = EventRenderer.described(event) || {}
644
- @screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
645
- description: activity[:description], output_truncated: event[:output_truncated],
646
- images: event[:images], diff: event[:diff] }))
647
- end
648
-
649
629
  def start_turn(event)
650
630
  @running = true
651
631
  @joined_mid_turn = false