samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ require_relative "project_scope"
9
+
10
+ module Samagotchi
11
+ class Session
12
+ METADATA_VERSION = 3
13
+ STATE_SUBDIR = File.join("samagotchi", "sessions")
14
+ FILE_EXT = ".json"
15
+
16
+ STATUS_IDLE = "idle"
17
+ STATUS_RUNNING = "running"
18
+ STATUS_COMPLETED = "completed"
19
+ STATUS_ERROR = "error"
20
+ STATUS_STOPPED = "stopped"
21
+
22
+ # Retention / ordering defaults (14 days, 500 sessions) — env overrides in SessionManager.
23
+ DEFAULT_RETENTION_DAYS = 14
24
+ DEFAULT_MAX_COUNT = 500
25
+ # status is turn state; a live owner (the prune alive_check) is what
26
+ # protects a session in use, so no status is kept by default.
27
+ DEFAULT_KEEP_STATUS = [].freeze
28
+ SORT_KEYS = %w[created_at updated_at].freeze
29
+ SORT_ORDERS = %w[asc desc].freeze
30
+
31
+ attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
32
+ :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
33
+ :pending_question, :used_memory_names
34
+ # Memories the session was started with (--memory) and memories hidden
35
+ # from it (--mute): the worker rebuilds the same prompt on a respawn.
36
+ # Names as given; the engine normalizes them.
37
+ attr_accessor :preloaded_memory_names, :muted_memory_names
38
+ # The project the session was started in (ProjectScope.root_for its
39
+ # folder), stored because worktrees are deleted after a merge and a
40
+ # deleted folder no longer leads to its repository. nil outside a repo,
41
+ # and in files written before the field existed (see #project_root).
42
+ attr_writer :project_root
43
+ # The session that delegated this one (the `delegate` tool) or that a
44
+ # plugin forked it from (ctx.sessions.fork), else nil.
45
+ # Set before the spawn and kept on respawns, like preloaded_memory_names.
46
+ attr_accessor :parent_id
47
+
48
+ # How many image refs a fork's seed lost (SessionManager.spawn_session
49
+ # sets it); not saved.
50
+ attr_accessor :seed_images_dropped
51
+
52
+ def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
53
+ metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
54
+ first_preview: "", test_run: false, pending_question: nil,
55
+ used_memory_names: [], project_root: nil,
56
+ preloaded_memory_names: [], muted_memory_names: [], parent_id: nil)
57
+ @id = id
58
+ @metadata_version = metadata_version
59
+ @mode = mode
60
+ @model_name = model_name
61
+ @working_directory = working_directory
62
+ @messages = messages
63
+ @created_at = created_at
64
+ @updated_at = updated_at
65
+ @status = status
66
+ @last_prompt = last_prompt
67
+ @first_preview = first_preview
68
+ @test_run = !!test_run
69
+ @pending_question = pending_question
70
+ @used_memory_names = self.class.name_list(used_memory_names)
71
+ @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
72
+ @muted_memory_names = self.class.name_list(muted_memory_names)
73
+ @project_root = project_root
74
+ @parent_id = parent_id&.to_s
75
+ end
76
+
77
+ # The stored project root, else (a file from before the field) the
78
+ # project of the working directory now: nil when that is in no repo or
79
+ # gone. +cache+ is ProjectScope.root_for's, shared across one listing.
80
+ def project_root(cache: nil)
81
+ return @project_root if @project_root
82
+
83
+ ProjectScope.root_for(@working_directory, cache: cache)
84
+ end
85
+
86
+ # A list of memory names: strings, stripped, no blanks, no repeats.
87
+ def self.name_list(names)
88
+ Array(names).map { |n| n.to_s.strip }.reject(&:empty?).uniq
89
+ end
90
+
91
+ # Build a new, unsaved session.
92
+ # @param messages [Array<Hash>] a conversation to start from (a fork's
93
+ # seed); [] by default
94
+ def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
95
+ preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [])
96
+ now = Time.now.iso8601(3)
97
+ resolved_test = if test_run.nil?
98
+ test_session_env?
99
+ else
100
+ !!test_run
101
+ end
102
+ new(
103
+ id: SecureRandom.uuid,
104
+ mode: mode.to_s,
105
+ model_name: model_name.to_s,
106
+ working_directory: working_directory.to_s,
107
+ messages: Array(messages).map(&:dup),
108
+ created_at: now,
109
+ updated_at: now,
110
+ status: STATUS_IDLE,
111
+ first_preview: "",
112
+ test_run: resolved_test,
113
+ project_root: ProjectScope.root_for(working_directory),
114
+ preloaded_memory_names: preloaded_memory_names,
115
+ muted_memory_names: muted_memory_names,
116
+ parent_id: parent_id
117
+ )
118
+ end
119
+
120
+ def self.test_session_env?(env: ENV)
121
+ env["SAMAGOTCHI_ENV"].to_s == "test" || env["RACK_ENV"].to_s == "test" || !env["CI"].to_s.strip.empty?
122
+ end
123
+
124
+ # Load a persisted session by its UUID.
125
+ # Whether +session_id+ has a saved session.
126
+ def self.exist?(session_id, state_dir: default_state_dir)
127
+ File.exist?(session_path(session_id, state_dir: state_dir))
128
+ end
129
+
130
+ def self.load(session_id, state_dir: default_state_dir)
131
+ path = session_path(session_id, state_dir: state_dir)
132
+ raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(path)
133
+
134
+ data = JSON.parse(File.read(path))
135
+ messages = (data["messages"] || []).map { |msg| symbolize_message_keys(msg) }
136
+ pending = data["pending_question"]
137
+ pending = symbolize_message_keys(pending) if pending.is_a?(Hash)
138
+ used_mems = data["used_memory_names"] || data["used_memories"] || []
139
+ new(
140
+ id: data.fetch("id"),
141
+ metadata_version: data.fetch("metadata_version", 1),
142
+ mode: data.fetch("mode"),
143
+ model_name: data.fetch("model_name"),
144
+ working_directory: data.fetch("working_directory"),
145
+ messages: messages,
146
+ created_at: data.fetch("created_at"),
147
+ updated_at: data.fetch("updated_at"),
148
+ status: data.fetch("status", STATUS_IDLE),
149
+ last_prompt: data.fetch("last_prompt", ""),
150
+ first_preview: data.fetch("first_preview", ""),
151
+ test_run: data.fetch("test_run", false),
152
+ pending_question: pending,
153
+ used_memory_names: Array(used_mems),
154
+ project_root: data["project_root"],
155
+ preloaded_memory_names: Array(data["preloaded_memory_names"]),
156
+ muted_memory_names: Array(data["muted_memory_names"]),
157
+ parent_id: data["parent_id"]
158
+ )
159
+ rescue JSON::ParserError => e
160
+ raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
161
+ end
162
+
163
+ # A prefix that names more than one session.
164
+ class AmbiguousId < ArgumentError; end
165
+
166
+ # A session id or a unique prefix of one (like git's): the full id. An
167
+ # unknown one comes back as is, for the caller's own "not found"; one that
168
+ # names several sessions raises AmbiguousId, which lists them.
169
+ def self.resolve_id(id_or_prefix, state_dir: default_state_dir)
170
+ id = id_or_prefix.to_s
171
+ return id unless id.match?(/\A[\w-]+\z/) && !File.exist?(session_path(id, state_dir: state_dir))
172
+
173
+ matches = Dir.glob(File.join(state_dir, "#{id}*#{FILE_EXT}")).map { |path| File.basename(path, FILE_EXT) }.sort
174
+ return matches.fetch(0, id) if matches.size <= 1
175
+
176
+ lines = matches.map do |match|
177
+ preview = JSON.parse(File.read(session_path(match, state_dir: state_dir)))["first_preview"].to_s
178
+ " #{match} #{preview}".rstrip
179
+ rescue JSON::ParserError, SystemCallError
180
+ " #{match}"
181
+ end
182
+ raise AmbiguousId, "session id #{id} matches #{matches.size} sessions:\n#{lines.join("\n")}"
183
+ end
184
+
185
+ # Return all saved sessions sorted by updated_at desc by default (newest first).
186
+ # Supports sort: created_at|updated_at and order: asc|desc.
187
+ # +project_root+ keeps only that project's sessions (Session#project_root),
188
+ # before offset/limit so pages count within the project.
189
+ def self.list(state_dir: default_state_dir, sort: "updated_at", order: "desc", limit: nil, offset: 0,
190
+ project_root: nil)
191
+ return [] unless Dir.exist?(state_dir)
192
+
193
+ sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
194
+ sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
195
+
196
+ sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
197
+ if project_root
198
+ roots = {}
199
+ sessions.select! { |s| s.project_root(cache: roots) == project_root }
200
+ end
201
+
202
+ sorted = sessions.sort_by do |s|
203
+ val = sort_key == "created_at" ? s.created_at : s.updated_at
204
+ begin
205
+ Time.iso8601(val.to_s)
206
+ rescue ArgumentError
207
+ Time.at(0)
208
+ end
209
+ end
210
+ sorted.reverse! if sort_order == "desc"
211
+ # Apply offset/limit if given
212
+ off = offset.to_i
213
+ sorted = sorted.drop(off) if off.positive?
214
+ if limit && limit.to_i.positive?
215
+ sorted = sorted.first(limit.to_i)
216
+ end
217
+ sorted
218
+ end
219
+
220
+ # One session file as .list reads it: the session without its messages
221
+ # (the list is lightweight). The session hub reads files the same way,
222
+ # so both agree on which files count.
223
+ # @return [Session, nil] nil for a file that is corrupt, missing a
224
+ # required field, or gone
225
+ def self.summary_from_file(path)
226
+ data = JSON.parse(File.read(path))
227
+ used_mems = data["used_memory_names"] || data["used_memories"] || []
228
+ new(
229
+ id: data.fetch("id"),
230
+ metadata_version: data.fetch("metadata_version", 1),
231
+ mode: data.fetch("mode"),
232
+ model_name: data.fetch("model_name"),
233
+ working_directory: data.fetch("working_directory"),
234
+ messages: [],
235
+ created_at: data.fetch("created_at"),
236
+ updated_at: data.fetch("updated_at"),
237
+ status: data.fetch("status", STATUS_IDLE),
238
+ last_prompt: data.fetch("last_prompt", ""),
239
+ first_preview: data.fetch("first_preview", ""),
240
+ test_run: data.fetch("test_run", false),
241
+ used_memory_names: Array(used_mems),
242
+ project_root: data["project_root"],
243
+ preloaded_memory_names: Array(data["preloaded_memory_names"]),
244
+ muted_memory_names: Array(data["muted_memory_names"]),
245
+ parent_id: data["parent_id"]
246
+ )
247
+ rescue JSON::ParserError, KeyError, SystemCallError
248
+ nil
249
+ end
250
+
251
+ # Prune old sessions according to retention policy.
252
+ #
253
+ # Only deletes when the json file exists — orphan dirs without json are never removed.
254
+ # Honors keep_status and live-worker guard.
255
+ # A session is kept only if it is NOT expired by age AND within max_count;
256
+ # either expiry or overflow triggers deletion (unless protected).
257
+ #
258
+ # +empty_check+ (id → Boolean) marks a session left empty: it goes
259
+ # whatever its age and the count (SessionManager.prune_sessions).
260
+ #
261
+ # +any_age+ makes every session eligible, whatever its age and the
262
+ # count (`chi sessions clean` with no --days: test runs are throwaway).
263
+ #
264
+ # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
265
+ def self.prune(state_dir: default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
266
+ keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
267
+ empty_check: nil, any_age: false)
268
+ keep_status = Array(keep_status).map(&:to_s)
269
+ # Fetch all sessions sorted newest-first for count logic
270
+ all = list(state_dir: state_dir, sort: "updated_at", order: "desc")
271
+ # Filter test_only if requested
272
+ if test_only
273
+ all = all.select(&:test_run)
274
+ end
275
+
276
+ now = Time.now
277
+ cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
278
+ max = max_count.to_i
279
+
280
+ deleted = []
281
+ kept = []
282
+ skipped = []
283
+
284
+ all.each_with_index do |session, idx|
285
+ path = File.join(state_dir, "#{session.id}#{FILE_EXT}")
286
+ # Only when json present
287
+ unless File.exist?(path)
288
+ skipped << session.id
289
+ next
290
+ end
291
+
292
+ # Protected by keep_status
293
+ if keep_status.include?(session.status.to_s)
294
+ kept << session.id
295
+ next
296
+ end
297
+
298
+ # Protected by live worker
299
+ if alive_check
300
+ begin
301
+ if alive_check.call(session.id)
302
+ kept << session.id
303
+ next
304
+ end
305
+ rescue StandardError
306
+ nil
307
+ end
308
+ end
309
+
310
+ left_empty = begin
311
+ empty_check&.call(session.id)
312
+ rescue StandardError
313
+ false
314
+ end
315
+
316
+ # Determine expiry and overflow
317
+ expired = false
318
+ if cutoff
319
+ begin
320
+ updated = Time.iso8601(session.updated_at.to_s)
321
+ rescue ArgumentError
322
+ updated = File.mtime(path) rescue now
323
+ end
324
+ expired = updated < cutoff
325
+ end
326
+
327
+ overflow = max.positive? && idx >= max
328
+
329
+ # retain forever when both disabled
330
+ if max.zero? && cutoff.nil? && !left_empty && !any_age
331
+ kept << session.id
332
+ next
333
+ end
334
+
335
+ # If neither expired nor overflow, keep
336
+ unless expired || overflow || left_empty || any_age
337
+ kept << session.id
338
+ next
339
+ end
340
+
341
+ # Eligible for deletion
342
+ if dry_run
343
+ deleted << session.id
344
+ else
345
+ begin
346
+ FileUtils.rm_f(path)
347
+ sidecar = File.join(state_dir, session.id)
348
+ FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
349
+ deleted << session.id
350
+ rescue StandardError
351
+ skipped << session.id
352
+ end
353
+ end
354
+ end
355
+
356
+ { deleted: deleted, kept: kept, skipped: skipped }
357
+ end
358
+
359
+ # Persist the session atomically. Updates +updated_at+ in place.
360
+ def save(state_dir: self.class.default_state_dir)
361
+ @updated_at = Time.now.iso8601(3)
362
+ # Persist with current metadata version so new flag is written
363
+ @metadata_version = METADATA_VERSION
364
+ FileUtils.mkdir_p(state_dir)
365
+
366
+ path = File.join(state_dir, "#{@id}#{FILE_EXT}")
367
+ temp_path = "#{path}.tmp"
368
+
369
+ # Auto-compute first_preview if not yet cached and messages contain a user entry.
370
+ compute_first_preview!
371
+
372
+ record = {
373
+ "metadata_version" => @metadata_version,
374
+ "id" => @id,
375
+ "mode" => @mode,
376
+ "model_name" => @model_name,
377
+ "working_directory" => @working_directory,
378
+ "messages" => @messages.map { |msg| scrub_utf8(stringify_message_keys(msg)) },
379
+ "created_at" => @created_at,
380
+ "updated_at" => @updated_at,
381
+ "status" => @status,
382
+ "last_prompt" => @last_prompt,
383
+ "first_preview" => @first_preview,
384
+ "test_run" => !!@test_run,
385
+ "pending_question" => @pending_question ? scrub_utf8(stringify_message_keys(@pending_question)) : nil,
386
+ "used_memory_names" => Array(@used_memory_names),
387
+ "project_root" => @project_root,
388
+ "preloaded_memory_names" => Array(@preloaded_memory_names),
389
+ "muted_memory_names" => Array(@muted_memory_names),
390
+ "parent_id" => @parent_id
391
+ }
392
+
393
+ File.write(temp_path, JSON.pretty_generate(record) + "\n")
394
+ File.rename(temp_path, path)
395
+ self
396
+ end
397
+
398
+ # Mark a session as running.
399
+ def self.mark_running(session_id, state_dir: default_state_dir)
400
+ session = load(session_id, state_dir: state_dir)
401
+ session.status = STATUS_RUNNING
402
+ session.save(state_dir: state_dir)
403
+ end
404
+
405
+ # Mark a session as completed.
406
+ def self.mark_completed(session_id, state_dir: default_state_dir)
407
+ session = load(session_id, state_dir: state_dir)
408
+ session.status = STATUS_COMPLETED
409
+ session.save(state_dir: state_dir)
410
+ end
411
+
412
+ # Mark a session as errored.
413
+ def self.mark_error(session_id, reason:, state_dir: default_state_dir)
414
+ session = load(session_id, state_dir: state_dir)
415
+ session.status = STATUS_ERROR
416
+ session.last_prompt = reason.to_s
417
+ session.save(state_dir: state_dir)
418
+ end
419
+
420
+ # Mark a session as stopped.
421
+ def self.mark_stopped(session_id, state_dir: default_state_dir)
422
+ session = load(session_id, state_dir: state_dir)
423
+ session.status = STATUS_STOPPED
424
+ session.save(state_dir: state_dir)
425
+ end
426
+
427
+ # Directory for a specific session (holds IPC files alongside session.json).
428
+ def self.session_dir(session_id, state_dir: default_state_dir)
429
+ File.join(state_dir, session_id)
430
+ end
431
+
432
+ # Default sessions directory path.
433
+ def self.default_sessions_dir
434
+ default_state_dir
435
+ end
436
+
437
+ # XDG-aware sessions directory.
438
+ def self.default_state_dir(env: ENV)
439
+ xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
440
+ base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
441
+ File.join(base, STATE_SUBDIR)
442
+ end
443
+
444
+ # Derive and cache the first user-message preview in the session record.
445
+ # Returns true if the cached value was set or updated.
446
+ def compute_first_preview!
447
+ return false if @first_preview && !@first_preview.empty?
448
+
449
+ first_user = @messages.find { |m| m[:role].to_s == "user" || m["role"].to_s == "user" }
450
+ return false unless first_user
451
+
452
+ preview = self.class.preview_of(first_user[:content] || first_user["content"])
453
+ return false if preview.empty?
454
+
455
+ @first_preview = preview
456
+ true
457
+ end
458
+
459
+ # A prompt as a one-line preview: whitespace collapsed, cut at 80 chars.
460
+ def self.preview_of(text)
461
+ norm = text.to_s.gsub(/\s+/, " ").strip
462
+ norm.length > 80 ? "#{norm[0, 80]}…" : norm
463
+ end
464
+
465
+ private
466
+
467
+ def stringify_message_keys(hash)
468
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
469
+ end
470
+
471
+ # The last guard before JSON.generate, which raises on bytes that
472
+ # aren't UTF-8: a save that raised would lose the whole conversation.
473
+ # ToolRunner already scrubs tool output; this covers any other source.
474
+ def scrub_utf8(obj)
475
+ case obj
476
+ when String
477
+ str = obj.encoding == Encoding::UTF_8 ? obj : obj.dup.force_encoding(Encoding::UTF_8)
478
+ str.valid_encoding? ? str : str.scrub("?")
479
+ when Array then obj.map { |element| scrub_utf8(element) }
480
+ when Hash then obj.to_h { |key, value| [key, scrub_utf8(value)] }
481
+ else obj
482
+ end
483
+ end
484
+
485
+ class << self
486
+ private
487
+
488
+ # A chat turn's tool_calls get symbol keys too ({id:, name:,
489
+ # arguments:}); the arguments keep the model's string keys. So do
490
+ # image refs ({file:, mime:, width:, …}).
491
+ def symbolize_message_keys(hash)
492
+ message = hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
493
+ %i[tool_calls images].each do |key|
494
+ next unless message[key].is_a?(Array)
495
+
496
+ message[key] = message[key].map do |entry|
497
+ entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
498
+ end
499
+ end
500
+ message
501
+ end
502
+
503
+ def session_path(session_id, state_dir:)
504
+ File.join(state_dir, "#{session_id}#{FILE_EXT}")
505
+ end
506
+ end
507
+ end
508
+ end