samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -8,6 +8,9 @@ require "time"
8
8
  require_relative "project_scope"
9
9
 
10
10
  module Samagotchi
11
+ # archive_store requires this file.
12
+ autoload :ArchiveStore, File.expand_path("archive_store", __dir__)
13
+
11
14
  class Session
12
15
  METADATA_VERSION = 3
13
16
  STATE_SUBDIR = File.join("samagotchi", "sessions")
@@ -44,6 +47,17 @@ module Samagotchi
44
47
  # plugin forked it from (ctx.sessions.fork), else nil.
45
48
  # Set before the spawn and kept on respawns, like preloaded_memory_names.
46
49
  attr_accessor :parent_id
50
+ # A `chi scratch` session: deleted when its REPL ends, and by the next
51
+ # sweep (or `chi sessions clean`) when the process died first.
52
+ attr_accessor :scratch
53
+ # How the last turn ended, for the web's notifications (the hub sends
54
+ # it in the summary): {"outcome" => "completed"|"failed"|"canceled",
55
+ # "ended_at" => iso8601, "seconds" => Float, "origin" =>
56
+ # "client"|"reminder"|"delegate"}; nil before the first turn.
57
+ attr_accessor :last_turn
58
+ # Archived (ArchiveStore): hidden from the lists. Set by .list (with
59
+ # include_archived); not saved in session.json.
60
+ attr_accessor :archived
47
61
 
48
62
  # How many image refs a fork's seed lost (SessionManager.spawn_session
49
63
  # sets it); not saved.
@@ -53,7 +67,8 @@ module Samagotchi
53
67
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
54
68
  first_preview: "", test_run: false, pending_question: nil,
55
69
  used_memory_names: [], project_root: nil,
56
- preloaded_memory_names: [], muted_memory_names: [], parent_id: nil)
70
+ preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
71
+ last_turn: nil)
57
72
  @id = id
58
73
  @metadata_version = metadata_version
59
74
  @mode = mode
@@ -72,6 +87,9 @@ module Samagotchi
72
87
  @muted_memory_names = self.class.name_list(muted_memory_names)
73
88
  @project_root = project_root
74
89
  @parent_id = parent_id&.to_s
90
+ @scratch = !!scratch
91
+ @last_turn = last_turn
92
+ @archived = false
75
93
  end
76
94
 
77
95
  # The stored project root, else (a file from before the field) the
@@ -92,7 +110,8 @@ module Samagotchi
92
110
  # @param messages [Array<Hash>] a conversation to start from (a fork's
93
111
  # seed); [] by default
94
112
  def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
95
- preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [])
113
+ preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
114
+ scratch: false)
96
115
  now = Time.now.iso8601(3)
97
116
  resolved_test = if test_run.nil?
98
117
  test_session_env?
@@ -113,7 +132,8 @@ module Samagotchi
113
132
  project_root: ProjectScope.root_for(working_directory),
114
133
  preloaded_memory_names: preloaded_memory_names,
115
134
  muted_memory_names: muted_memory_names,
116
- parent_id: parent_id
135
+ parent_id: parent_id,
136
+ scratch: scratch
117
137
  )
118
138
  end
119
139
 
@@ -154,7 +174,9 @@ module Samagotchi
154
174
  project_root: data["project_root"],
155
175
  preloaded_memory_names: Array(data["preloaded_memory_names"]),
156
176
  muted_memory_names: Array(data["muted_memory_names"]),
157
- parent_id: data["parent_id"]
177
+ parent_id: data["parent_id"],
178
+ scratch: data.fetch("scratch", false),
179
+ last_turn: data["last_turn"]
158
180
  )
159
181
  rescue JSON::ParserError => e
160
182
  raise ArgumentError, "Session file corrupted (#{session_id}): #{e.message}"
@@ -186,14 +208,19 @@ module Samagotchi
186
208
  # Supports sort: created_at|updated_at and order: asc|desc.
187
209
  # +project_root+ keeps only that project's sessions (Session#project_root),
188
210
  # before offset/limit so pages count within the project.
211
+ # Archived sessions (ArchiveStore) are left out unless +include_archived+;
212
+ # everything built on .list follows (the lists, the summaries, the
213
+ # retention prune, which then neither deletes nor counts them).
189
214
  def self.list(state_dir: default_state_dir, sort: "updated_at", order: "desc", limit: nil, offset: 0,
190
- project_root: nil)
215
+ project_root: nil, include_archived: false)
191
216
  return [] unless Dir.exist?(state_dir)
192
217
 
193
218
  sort_key = SORT_KEYS.include?(sort.to_s) ? sort.to_s : "updated_at"
194
219
  sort_order = SORT_ORDERS.include?(order.to_s) ? order.to_s : "desc"
195
220
 
196
221
  sessions = Dir.glob(File.join(state_dir, "*#{FILE_EXT}")).filter_map { |path| summary_from_file(path) }
222
+ sessions.each { |s| s.archived = ArchiveStore.archived?(session_dir(s.id, state_dir: state_dir)) }
223
+ sessions.reject!(&:archived) unless include_archived
197
224
  if project_root
198
225
  roots = {}
199
226
  sessions.select! { |s| s.project_root(cache: roots) == project_root }
@@ -225,6 +252,7 @@ module Samagotchi
225
252
  def self.summary_from_file(path)
226
253
  data = JSON.parse(File.read(path))
227
254
  used_mems = data["used_memory_names"] || data["used_memories"] || []
255
+ pending = data["pending_question"]
228
256
  new(
229
257
  id: data.fetch("id"),
230
258
  metadata_version: data.fetch("metadata_version", 1),
@@ -238,11 +266,14 @@ module Samagotchi
238
266
  last_prompt: data.fetch("last_prompt", ""),
239
267
  first_preview: data.fetch("first_preview", ""),
240
268
  test_run: data.fetch("test_run", false),
269
+ pending_question: pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil,
241
270
  used_memory_names: Array(used_mems),
242
271
  project_root: data["project_root"],
243
272
  preloaded_memory_names: Array(data["preloaded_memory_names"]),
244
273
  muted_memory_names: Array(data["muted_memory_names"]),
245
- parent_id: data["parent_id"]
274
+ parent_id: data["parent_id"],
275
+ scratch: data.fetch("scratch", false),
276
+ last_turn: data["last_turn"]
246
277
  )
247
278
  rescue JSON::ParserError, KeyError, SystemCallError
248
279
  nil
@@ -261,6 +292,12 @@ module Samagotchi
261
292
  # +any_age+ makes every session eligible, whatever its age and the
262
293
  # count (`chi sessions clean` with no --days: test runs are throwaway).
263
294
  #
295
+ # A scratch session nobody owns (its REPL was killed) goes whatever its
296
+ # age, its status and the count; +test_only+ takes it too.
297
+ #
298
+ # Archived sessions are not in .list, so they are neither deleted nor
299
+ # counted. One unarchived is aged from when it was unarchived, if later.
300
+ #
264
301
  # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
265
302
  def self.prune(state_dir: default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
266
303
  keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
@@ -270,7 +307,7 @@ module Samagotchi
270
307
  all = list(state_dir: state_dir, sort: "updated_at", order: "desc")
271
308
  # Filter test_only if requested
272
309
  if test_only
273
- all = all.select(&:test_run)
310
+ all = all.select { |session| session.test_run || session.scratch }
274
311
  end
275
312
 
276
313
  now = Time.now
@@ -290,7 +327,7 @@ module Samagotchi
290
327
  end
291
328
 
292
329
  # Protected by keep_status
293
- if keep_status.include?(session.status.to_s)
330
+ if keep_status.include?(session.status.to_s) && !session.scratch
294
331
  kept << session.id
295
332
  next
296
333
  end
@@ -321,19 +358,21 @@ module Samagotchi
321
358
  rescue ArgumentError
322
359
  updated = File.mtime(path) rescue now
323
360
  end
361
+ unarchived = ArchiveStore.unarchived_at(session_dir(session.id, state_dir: state_dir))
362
+ updated = unarchived if unarchived && unarchived > updated
324
363
  expired = updated < cutoff
325
364
  end
326
365
 
327
366
  overflow = max.positive? && idx >= max
328
367
 
329
368
  # retain forever when both disabled
330
- if max.zero? && cutoff.nil? && !left_empty && !any_age
369
+ if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
331
370
  kept << session.id
332
371
  next
333
372
  end
334
373
 
335
374
  # If neither expired nor overflow, keep
336
- unless expired || overflow || left_empty || any_age
375
+ unless expired || overflow || left_empty || any_age || session.scratch
337
376
  kept << session.id
338
377
  next
339
378
  end
@@ -387,7 +426,9 @@ module Samagotchi
387
426
  "project_root" => @project_root,
388
427
  "preloaded_memory_names" => Array(@preloaded_memory_names),
389
428
  "muted_memory_names" => Array(@muted_memory_names),
390
- "parent_id" => @parent_id
429
+ "parent_id" => @parent_id,
430
+ "scratch" => @scratch,
431
+ "last_turn" => @last_turn
391
432
  }
392
433
 
393
434
  File.write(temp_path, JSON.pretty_generate(record) + "\n")
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "session_manager"
5
+
6
+ module Samagotchi
7
+ # `chi sessions archive|unarchive`: hide sessions from every list and keep
8
+ # them for good, or bring them back (SessionManager.archive_session /
9
+ # unarchive_session), one line per id. Delegated children follow their
10
+ # parent.
11
+ class SessionArchiveCommand
12
+ USAGE = {
13
+ "archive" => <<~TEXT,
14
+ Usage: chi sessions archive (ID|PREFIX)...
15
+ Hides each session (and its delegates) from every list and keeps it
16
+ for good: the retention sweep never deletes it. A live worker is
17
+ stopped first; a session running a turn, or open in a chi REPL, is
18
+ refused. chi sessions list --archived shows them; a message you send
19
+ to one brings it back.
20
+ TEXT
21
+ "unarchive" => <<~TEXT
22
+ Usage: chi sessions unarchive (ID|PREFIX)...
23
+ Brings archived sessions (and their delegates) back to the lists.
24
+ TEXT
25
+ }.freeze
26
+
27
+ # @param action [String] "archive" or "unarchive"
28
+ # @param argv [Array<String>] the arguments after "sessions <action>"
29
+ def initialize(action, argv, stdout: $stdout, stderr: $stderr, state_dir: nil)
30
+ @action = action
31
+ @argv = argv.dup
32
+ @stdout = stdout
33
+ @stderr = stderr
34
+ @state_dir = state_dir || Session.default_state_dir
35
+ end
36
+
37
+ # @return [Integer] exit status: 0 all done, 1 any refused or unknown, 2 usage
38
+ def run
39
+ if @argv.any? { |arg| %w[-h --help help].include?(arg) }
40
+ @stdout.puts(USAGE.fetch(@action))
41
+ return 0
42
+ end
43
+ bad = @argv.find { |arg| arg.start_with?("-") }
44
+ return usage_error(bad ? "unknown option #{bad}" : "give session ids") if bad || @argv.empty?
45
+
46
+ @argv.uniq.map { |given| @action == "archive" ? archive(given) : unarchive(given) }.all? ? 0 : 1
47
+ end
48
+
49
+ private
50
+
51
+ def archive(given)
52
+ id = Session.resolve_id(given, state_dir: @state_dir)
53
+ short = id[0, 8]
54
+ result = SessionManager.archive_session(id, state_dir: @state_dir)
55
+ if result[:archived].include?(id)
56
+ notes = []
57
+ notes << "stopped its worker" if result[:stopped].include?(id)
58
+ others = result[:archived].size - 1
59
+ notes << "and #{others} delegate#{"s" if others != 1}" if others.positive?
60
+ @stdout.puts("#{short} archived#{" (#{notes.join(", ")})" unless notes.empty?}")
61
+ else
62
+ @stdout.puts("#{short} empty session discarded")
63
+ end
64
+ true
65
+ rescue SessionManager::OwnedByTUI => e
66
+ where = e.session_id == id ? "it is" : "a delegate of it is"
67
+ @stdout.puts("#{short} refused: #{where} open in a chi REPL; close it there first")
68
+ false
69
+ rescue SessionManager::ArchiveRefused => e
70
+ @stdout.puts("#{short} refused: #{e.message}")
71
+ false
72
+ rescue ArgumentError => e
73
+ error_line("chi sessions archive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
74
+ false
75
+ rescue SystemCallError => e
76
+ @stdout.puts("#{short} failed: #{e.message}")
77
+ false
78
+ end
79
+
80
+ def unarchive(given)
81
+ id = Session.resolve_id(given, state_dir: @state_dir)
82
+ short = id[0, 8]
83
+ result = SessionManager.unarchive_session(id, state_dir: @state_dir)
84
+ @stdout.puts(result[:unarchived].empty? ? "#{short} not archived" : "#{short} unarchived")
85
+ true
86
+ rescue ArgumentError => e
87
+ error_line("chi sessions unarchive: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
88
+ false
89
+ rescue SystemCallError => e
90
+ @stdout.puts("#{short} failed: #{e.message}")
91
+ false
92
+ end
93
+
94
+ # stdout is buffered when it's a pipe: flush the lines already printed,
95
+ # so the output keeps the order of the ids given.
96
+ def error_line(text)
97
+ @stdout.flush
98
+ @stderr.puts(text)
99
+ end
100
+
101
+ def usage_error(message)
102
+ error_line("chi sessions #{@action}: #{message}")
103
+ @stderr.puts(USAGE.fetch(@action))
104
+ 2
105
+ end
106
+ end
107
+ end
@@ -15,8 +15,10 @@ module Samagotchi
15
15
  # the Engine and its TurnFlow; the host prints the result's output and
16
16
  # runs a continue turn when asked to (#run never runs a turn).
17
17
  #
18
- # /stats, /recap and /exit are the UI's own: they are in the registry
19
- # (local: for Tab and help) but #run never runs them.
18
+ # /stats, /recap, /exit, /quit, /archive and /detach are the terminal
19
+ # UIs' own: they are in the registry (local:) for Tab and help, and so
20
+ # both TUIs read the same words (Registry#lookup_local gives the entry,
21
+ # its id says what to do), but #run never runs them.
20
22
  class SessionCommands
21
23
  MODEL_COMMAND = "/model"
22
24
  MODELS_COMMAND = "/models"
@@ -30,6 +32,8 @@ module Samagotchi
30
32
  SHELL_BANG_PREFIX = "!"
31
33
  RESERVED_MODEL_ARGS = %w[clear default none off].freeze
32
34
  ALIAS_USAGE = "usage /model <model> --alias <name> [--default]"
35
+ # /exit --delete: delete the session on the way out.
36
+ EXIT_DELETE_FLAG = "--delete"
33
37
 
34
38
  # @!attribute status [Symbol] :ok, or :error when the line was refused
35
39
  # @!attribute output [String, nil] what to tell the user
@@ -66,12 +70,28 @@ module Samagotchi
66
70
  registry.register(HELP_COMMAND, "list the commands, the bundles' too", anytime: true) { |_text| reply(help_listing) }
67
71
  registry.register("/stats", "show the session's stats", local: true)
68
72
  registry.register("/recap", "show the session's recap", local: true)
69
- registry.register("/exit", "leave (--delete also deletes the session)", local: true)
70
- registry.register("/quit", "leave, like /exit", local: true, uis: [:attached])
71
- registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached])
73
+ # Bare `exit` too; any case; --delete after it.
74
+ registry.register("/exit", "leave (--delete also deletes the session)", local: true,
75
+ match: exit_match("/?exit"))
76
+ registry.register("/quit", "leave, like /exit", id: :exit, local: true, match: exit_match("/quit"))
77
+ registry.register("/archive", "leave and archive the session: hidden from the lists, kept for good", local: true,
78
+ match: ->(text) { text.casecmp?("/archive") })
79
+ # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
80
+ registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
81
+ match: ->(text) { text.casecmp?("/detach") })
72
82
  registry
73
83
  end
74
84
 
85
+ # @param word [String] the regexp source of the exit word
86
+ def self.exit_match(word)
87
+ pattern = /\A#{word}(?:\s+#{EXIT_DELETE_FLAG})?\z/i
88
+ ->(text) { text.match?(pattern) }
89
+ end
90
+ private_class_method :exit_match
91
+
92
+ # @return [Boolean] an exit line (/exit, /quit, exit) that deletes the session too
93
+ def self.delete_on_exit?(line) = line.to_s.split.last.to_s.casecmp?(EXIT_DELETE_FLAG)
94
+
75
95
  # The built-ins alone, for callers without an Engine (an attached TUI
76
96
  # before its snapshot names the session's commands, specs).
77
97
  # @return [Commands::Registry] frozen
@@ -276,15 +296,18 @@ module Samagotchi
276
296
  def model(text)
277
297
  output, switched = model_command(text)
278
298
  reply(output, changed: switched ? [:model] : [])
299
+ rescue ModelProfile::MissingModel => e
300
+ # A model qualified with an unknown host: nothing switched.
301
+ reply(e.message, status: :error)
279
302
  end
280
303
 
281
304
  # @return [Array(String, Boolean)] the message, and whether the model changed
282
305
  def model_command(input)
283
306
  suffix = input.delete_prefix(MODEL_COMMAND).strip
284
307
  if suffix.empty?
285
- return ["runtime model: #{model_name}#{model_note}#{served_note}", false] if model_name == @default_model
308
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}", false] if model_name == @default_model
286
309
 
287
- return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}", false]
310
+ return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}", false]
288
311
  end
289
312
 
290
313
  # Parse flags: --default and --alias <name> / --alias=<name> (tolerant order)
@@ -411,6 +434,22 @@ module Samagotchi
411
434
  ""
412
435
  end
413
436
 
437
+ # "; sampling: temperature=0.6 (hosts.work)" when the model has any.
438
+ def sampling_note
439
+ summary = @engine.respond_to?(:sampling_summary) ? @engine.sampling_summary : nil
440
+ summary ? "; sampling: #{summary}" : ""
441
+ rescue StandardError
442
+ ""
443
+ end
444
+
445
+ # "; thinking: off (models: qwen)" when the model has a level set.
446
+ def thinking_note
447
+ summary = @engine.respond_to?(:thinking_summary) ? @engine.thinking_summary : nil
448
+ summary ? "; thinking: #{summary}" : ""
449
+ rescue StandardError
450
+ ""
451
+ end
452
+
414
453
  # " (default: x, profile=qwen36, name)": +extra+ and the prompt profile,
415
454
  # which a chat host's model doesn't have (its loop uses none); "" when
416
455
  # there is neither.
@@ -8,6 +8,7 @@ require "securerandom"
8
8
  require "rbconfig"
9
9
 
10
10
  require_relative "session"
11
+ require_relative "session_metrics"
11
12
  require_relative "turn_note"
12
13
  require_relative "owner_lock"
13
14
  require_relative "bridge_client"
@@ -15,6 +16,7 @@ require_relative "log"
15
16
  require_relative "log_path"
16
17
  require_relative "installed_gem"
17
18
  require_relative "recap_store"
19
+ require_relative "archive_store"
18
20
  require_relative "image_store"
19
21
  require_relative "terminal_ui"
20
22
 
@@ -73,7 +75,12 @@ module Samagotchi
73
75
  # Raised when the interactive TUI owns the session: it runs its own Engine
74
76
  # and reads no input files, so a worker must not be spawned or signalled.
75
77
  class OwnedByTUI < StandardError
78
+ # @return [String] the session the REPL owns (a delegate's, when an
79
+ # archive met one)
80
+ attr_reader :session_id
81
+
76
82
  def initialize(session_id)
83
+ @session_id = session_id
77
84
  super("session #{session_id} is owned by an interactive TUI")
78
85
  end
79
86
  end
@@ -95,6 +102,29 @@ module Samagotchi
95
102
  end
96
103
  end
97
104
 
105
+ # An archive that can't happen now: a turn runs in the session or in one
106
+ # of its children (+busy_id+ names which), or it is a scratch session.
107
+ class ArchiveRefused < StandardError
108
+ attr_reader :session_id, :reason, :busy_id
109
+
110
+ def initialize(session_id, reason, busy_id: nil)
111
+ @session_id = session_id
112
+ @reason = reason
113
+ @busy_id = busy_id
114
+ super(archive_refused_message)
115
+ end
116
+
117
+ private
118
+
119
+ def archive_refused_message
120
+ case @reason
121
+ when :scratch then "a scratch session is deleted when you leave; nothing to archive"
122
+ when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
123
+ else "a turn is running; wait for it or cancel it first"
124
+ end
125
+ end
126
+ end
127
+
98
128
  # Spawn a new background session that processes the given prompt (or,
99
129
  # with none, waits idle for input).
100
130
  #
@@ -118,7 +148,7 @@ module Samagotchi
118
148
  sd = state_dir || Session.default_state_dir
119
149
  session = Session.new_session(
120
150
  mode: mode,
121
- model_name: model_name || Samagotchi::ModelProfile.required_model_name,
151
+ model_name: Samagotchi::ModelProfile.check_host!(Samagotchi::ModelProfile.required_model_name(model_name)),
122
152
  working_directory: working_directory || Dir.pwd,
123
153
  preloaded_memory_names: memories,
124
154
  muted_memory_names: muted_memories,
@@ -190,9 +220,11 @@ module Samagotchi
190
220
 
191
221
  # List all sessions, reading status from persisted session.json files.
192
222
  # +project_root+: only that project's sessions (nil: every session).
193
- def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil)
223
+ # +include_archived+: archived sessions too (Session#archived says which).
224
+ def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil,
225
+ include_archived: false)
194
226
  Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
195
- offset: offset, project_root: project_root)
227
+ offset: offset, project_root: project_root, include_archived: include_archived)
196
228
  end
197
229
 
198
230
  # Prune sessions per retention policy. Delegates to Session.prune with live-worker guard.
@@ -211,17 +243,19 @@ module Samagotchi
211
243
  # @param limit [Integer, nil] taken after the filters
212
244
  # @param include_tests [Boolean] false leaves out test runs
213
245
  # @param exclude [String, nil] a session id to leave out (the asker)
246
+ # @param include_archived [Boolean] archived sessions too
214
247
  # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
215
248
  # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
216
- # parent_short_id:}; busy = live with
249
+ # parent_short_id:, archived:, scratch:}; busy = live with
217
250
  # a turn running, recap = the saved recap's first sentence, project =
218
251
  # Session#project_root
219
252
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
220
- project_root: nil)
253
+ project_root: nil, include_archived: false)
221
254
  sd = state_dir || Session.default_state_dir
222
255
  root = cwd && folder_path(cwd)
223
256
  roots = {}
224
- summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root).lazy
257
+ summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root,
258
+ include_archived: include_archived).lazy
225
259
  .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
226
260
  .select { |s| root.nil? || in_folder?(s.working_directory, root) }
227
261
  .filter_map do |s|
@@ -232,18 +266,89 @@ module Samagotchi
232
266
  { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
233
267
  project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
234
268
  owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
235
- parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8) }
269
+ ctx_pct: SessionMetrics.saved_context_pct(Session.session_dir(s.id, state_dir: sd))&.round(1),
270
+ parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8), archived: s.archived,
271
+ scratch: s.scratch }
236
272
  end
237
273
  (limit ? summaries.first(limit) : summaries.to_a)
238
274
  end
239
275
 
240
276
  # The sessions delegated by +parent_id+ (the `delegate` tool), newest
241
277
  # first, as .session_summaries rows. A running one (busy) counts against
242
- # session.max_children.
278
+ # session.max_children. Archived ones too.
243
279
  def self.children_of(parent_id, state_dir: nil)
244
280
  return [] if parent_id.to_s.empty?
245
281
 
246
- session_summaries(state_dir: state_dir, include_tests: true).select { |s| s[:parent_id] == parent_id.to_s }
282
+ session_summaries(state_dir: state_dir, include_tests: true, include_archived: true)
283
+ .select { |s| s[:parent_id] == parent_id.to_s }
284
+ end
285
+
286
+ # Archive a session and its delegated children (ArchiveStore): hidden
287
+ # from every list, kept by the retention sweep. A live idle worker is
288
+ # stopped first; the marker is written even while it shuts down.
289
+ # @return [Hash] {id:, archived: [ids], stopped: [ids], discarded: [ids]};
290
+ # discarded: empty sessions their stopping worker deleted
291
+ # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
292
+ # @raise [OwnedByTUI] a chi REPL owns it or one of its children
293
+ # @raise [ArchiveRefused] a turn runs in it or in a child, or it is a
294
+ # scratch session
295
+ def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
296
+ sd = state_dir || Session.default_state_dir
297
+ id = archive_target(id_or_prefix, sd)
298
+ raise ArchiveRefused.new(id, :scratch) if Session.load(id, state_dir: sd).scratch
299
+
300
+ tree = [id, *descendant_ids(id, sd)]
301
+ owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
302
+ tree.each do |sid|
303
+ owner = owners[sid]
304
+ next unless owner
305
+
306
+ raise OwnedByTUI, sid if owner["kind"] == "tui"
307
+ next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
308
+
309
+ raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
310
+ end
311
+
312
+ stopped = tree.select { |sid| owners[sid] }
313
+ stopped.each { |sid| stop_session(sid, state_dir: sd, wait: wait) }
314
+ archived, discarded = tree.partition { |sid| ArchiveStore.archive(sid, state_dir: sd) }
315
+ { id: id, archived: archived, stopped: stopped, discarded: discarded }
316
+ end
317
+
318
+ # Unarchive a session and its delegated children.
319
+ # @return [Hash] {id:, unarchived: [ids that were archived]}
320
+ # @raise [ArgumentError] unknown id
321
+ def self.unarchive_session(id_or_prefix, state_dir: nil)
322
+ sd = state_dir || Session.default_state_dir
323
+ id = archive_target(id_or_prefix, sd)
324
+ unarchived = [id, *descendant_ids(id, sd)].select { |sid| ArchiveStore.unarchive(sid, state_dir: sd) }
325
+ { id: id, unarchived: unarchived }
326
+ end
327
+
328
+ private_class_method def self.archive_target(id_or_prefix, state_dir)
329
+ given = id_or_prefix.to_s
330
+ raise ArgumentError, "no session #{given}" unless given.match?(/\A[\w-]+\z/)
331
+
332
+ id = Session.resolve_id(given, state_dir: state_dir)
333
+ raise ArgumentError, "no session #{given}" unless Session.exist?(id, state_dir: state_dir)
334
+
335
+ id
336
+ end
337
+
338
+ # Children, their children, …: a delegated session doesn't delegate
339
+ # further today, a plugin's fork may.
340
+ private_class_method def self.descendant_ids(id, state_dir)
341
+ seen = [id]
342
+ queue = [id]
343
+ until queue.empty?
344
+ children_of(queue.shift, state_dir: state_dir).each do |child|
345
+ next if seen.include?(child[:id])
346
+
347
+ seen << child[:id]
348
+ queue << child[:id]
349
+ end
350
+ end
351
+ seen.drop(1)
247
352
  end
248
353
 
249
354
  SUMMARY_PREVIEW_LIMIT = 120
@@ -534,7 +639,7 @@ module Samagotchi
534
639
  if EMPTY_SKELETON_DIRS.include?(name)
535
640
  File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
536
641
  elsif name == "analytics.json"
537
- JSON.parse(File.read(path))["turns"].to_i.zero?
642
+ Array(JSON.parse(File.read(path))["turn_records"]).empty?
538
643
  else
539
644
  EMPTY_SKELETON_FILES.include?(name)
540
645
  end
@@ -592,21 +697,6 @@ module Samagotchi
592
697
  true
593
698
  end
594
699
 
595
- # Wait for a session to reach a terminal state (completed, error, stopped).
596
- # Returns true if the session finished, false if the timeout elapsed.
597
- def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
598
- sd = state_dir || Session.default_state_dir
599
- elapsed = 0
600
- while elapsed < timeout
601
- session = Session.load(session_id, state_dir: sd)
602
- return true if %w[completed error stopped].include?(session.status)
603
-
604
- sleep(0.5)
605
- elapsed += 0.5
606
- end
607
- false
608
- end
609
-
610
700
  # Run the session loop inside the forked process.
611
701
  # This is the entry point called by Process.spawn.
612
702
  #