samagotchi 0.6.0 → 0.8.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/configuration.md +20 -0
  7. data/docs/desktop.md +49 -0
  8. data/docs/guardrails.md +2 -2
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/context-telemetry.md +3 -1
  11. data/docs/memory.md +48 -1
  12. data/docs/plugins.md +7 -5
  13. data/docs/releasing.md +5 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/archive_store.rb +3 -3
  16. data/lib/samagotchi/atomic_file.rb +34 -0
  17. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  18. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  19. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  20. data/lib/samagotchi/bootstrap_command.rb +78 -8
  21. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  22. data/lib/samagotchi/bridge.rb +56 -116
  23. data/lib/samagotchi/bridge_client.rb +7 -11
  24. data/lib/samagotchi/bundle_command.rb +81 -21
  25. data/lib/samagotchi/bundle_needs.rb +2 -11
  26. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  27. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  28. data/lib/samagotchi/bundles/system/identity.md +1 -0
  29. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  30. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  31. data/lib/samagotchi/client.rb +3 -23
  32. data/lib/samagotchi/config.rb +11 -11
  33. data/lib/samagotchi/context_note.rb +1 -1
  34. data/lib/samagotchi/context_status.rb +255 -0
  35. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  36. data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
  37. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  38. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  39. data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
  40. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  41. data/lib/samagotchi/desktop/macos.rb +25 -11
  42. data/lib/samagotchi/desktop_command.rb +1 -1
  43. data/lib/samagotchi/engine.rb +6 -7
  44. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  45. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  46. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  47. data/lib/samagotchi/hooks/loader.rb +2 -1
  48. data/lib/samagotchi/hooks/registry.rb +21 -0
  49. data/lib/samagotchi/image_store.rb +2 -3
  50. data/lib/samagotchi/kernel_loop.rb +21 -283
  51. data/lib/samagotchi/live_versions.rb +9 -13
  52. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  53. data/lib/samagotchi/llm/native_backend.rb +8 -8
  54. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  55. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  56. data/lib/samagotchi/log_path.rb +2 -3
  57. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  58. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  59. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  60. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  61. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  64. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  65. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  66. data/lib/samagotchi/memory_bundle.rb +1 -0
  67. data/lib/samagotchi/note_command.rb +5 -4
  68. data/lib/samagotchi/owner_lock.rb +0 -6
  69. data/lib/samagotchi/paths.rb +32 -0
  70. data/lib/samagotchi/plugin/api.rb +3 -6
  71. data/lib/samagotchi/plugin/context.rb +2 -3
  72. data/lib/samagotchi/plugin/loader.rb +1 -1
  73. data/lib/samagotchi/question_desk.rb +3 -2
  74. data/lib/samagotchi/recap_store.rb +2 -2
  75. data/lib/samagotchi/reply_wait.rb +2 -1
  76. data/lib/samagotchi/self_report.rb +2 -3
  77. data/lib/samagotchi/send_command.rb +3 -2
  78. data/lib/samagotchi/session.rb +41 -143
  79. data/lib/samagotchi/session_inbox.rb +175 -0
  80. data/lib/samagotchi/session_manager.rb +62 -363
  81. data/lib/samagotchi/session_metrics.rb +2 -3
  82. data/lib/samagotchi/session_retention.rb +259 -0
  83. data/lib/samagotchi/sessions_command.rb +3 -2
  84. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  85. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  86. data/lib/samagotchi/terminal_ui.rb +10 -20
  87. data/lib/samagotchi/tools/memory.rb +3 -2
  88. data/lib/samagotchi/tools/send_note.rb +4 -3
  89. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  90. data/lib/samagotchi/turn_note.rb +13 -2
  91. data/lib/samagotchi/update_command.rb +10 -3
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +44 -76
  94. data/lib/samagotchi/web/info_probe.rb +33 -0
  95. data/lib/samagotchi/web/public/app.js +1 -1
  96. data/lib/samagotchi/web/public/index.html +1 -0
  97. data/lib/samagotchi/web/public/stage_model.js +22 -3
  98. data/lib/samagotchi/web/public/stage_view.js +25 -10
  99. data/lib/samagotchi/web/server.rb +8 -11
  100. data/lib/samagotchi/web/session_hub.rb +8 -7
  101. data/lib/samagotchi/web/session_summary.rb +4 -3
  102. data/lib/samagotchi/web/token.rb +2 -5
  103. data/lib/samagotchi/worker.rb +34 -20
  104. data/lib/samagotchi/worker_sidecar.rb +42 -0
  105. metadata +15 -1
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "session"
6
+ require_relative "archive_store"
7
+ require_relative "config"
8
+ require_relative "log"
9
+ require_relative "model_profile"
10
+ require_relative "session_manager"
11
+
12
+ module Samagotchi
13
+ # Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
14
+ # sweep chi web runs at most once a day (.sweep_if_due). Age
15
+ # (session.retention_days), count (session.max_count, counting only the
16
+ # sessions that stay), statuses kept (session.keep_status), sessions left
17
+ # empty (SessionManager.discardable?, an hour on), leftover scratch
18
+ # sessions and skeleton-only directories; a session with a live owner
19
+ # always stays.
20
+ module SessionRetention
21
+ # The session.retention_days / max_count / keep_status defaults.
22
+ DEFAULT_RETENTION_DAYS = 14
23
+ DEFAULT_MAX_COUNT = 500
24
+ # status is turn state; a live owner (the prune alive_check) is what
25
+ # protects a session in use, so no status is kept by default: a
26
+ # "running" left by a crashed worker must not keep it forever.
27
+ DEFAULT_KEEP_STATUS = [].freeze
28
+
29
+ # How long a session may sit empty before the sweep takes it: its
30
+ # worker (or a REPL) deletes it as it leaves, so the sweep only catches
31
+ # those killed first (a reboot, kill -9).
32
+ EMPTY_GRACE_SECONDS = 3600
33
+
34
+ # The settings (or the caller's flags) applied, with live owners kept and
35
+ # sessions left empty an hour taken (unless session.keep_empty).
36
+ # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
37
+ def self.prune(state_dir: nil, days: nil, max_count: nil, keep_status: nil, dry_run: false, test_only: false,
38
+ any_age: false)
39
+ sd = state_dir || Session.default_state_dir
40
+ days = resolve_days(days)
41
+ max_count = resolve_max_count(max_count)
42
+ keep_status = resolve_keep_status(keep_status)
43
+ discard = SessionManager.discard_empty?
44
+ default_model = discard ? (begin ModelProfile.required_model_name(nil) rescue nil end) : nil
45
+ result = apply(
46
+ state_dir: sd,
47
+ days: days,
48
+ max_count: max_count,
49
+ keep_status: keep_status,
50
+ dry_run: dry_run,
51
+ test_only: test_only,
52
+ any_age: any_age,
53
+ alive_check: ->(sid) { !SessionManager.session_owner(sid, state_dir: sd).nil? },
54
+ empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
55
+ )
56
+ result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
57
+ result
58
+ end
59
+
60
+ private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
61
+ path = Session.session_file(session_id, state_dir: state_dir)
62
+ Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
63
+ SessionManager.discardable?(session_id, state_dir: state_dir, default_model: default_model)
64
+ rescue SystemCallError
65
+ false
66
+ end
67
+
68
+ # Directories with no session file and nothing but the skeleton, nobody
69
+ # owning them: a REPL killed before its first save, or a worker woken
70
+ # just as its session was discarded.
71
+ # @return [Array<String>] their ids
72
+ private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
73
+ return [] unless Dir.exist?(state_dir)
74
+
75
+ Dir.children(state_dir).filter_map do |name|
76
+ dir = File.join(state_dir, name)
77
+ next unless Session.valid_id?(name) && File.directory?(dir)
78
+ next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
79
+ next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
80
+ next if SessionManager.session_owner(name, state_dir: state_dir)
81
+
82
+ FileUtils.rm_rf(dir) unless dry_run
83
+ name
84
+ rescue SystemCallError
85
+ nil
86
+ end
87
+ end
88
+
89
+ # Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
90
+ # (session.sweep_interval_hours).
91
+ MARKER = ".last_retention"
92
+ SWEEP_INTERVAL_HOURS = 24
93
+
94
+ def self.sweep_if_due(state_dir: nil)
95
+ sd = state_dir || Session.default_state_dir
96
+ return unless Dir.exist?(sd)
97
+
98
+ hours = Samagotchi::Config.get("session.sweep_interval_hours").to_i
99
+ interval = (hours.positive? ? hours : SWEEP_INTERVAL_HOURS) * 3600
100
+ marker = File.join(sd, MARKER)
101
+ if File.exist?(marker)
102
+ age = Time.now - File.mtime(marker)
103
+ return if age < interval
104
+ end
105
+ result = prune(state_dir: sd)
106
+ FileUtils.touch(marker)
107
+ if result[:deleted].any?
108
+ Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
109
+ end
110
+ result
111
+ rescue StandardError => e
112
+ Log.warn(:worker, "retention_failed", echo: "[retention] sweep failed: #{e.class}: #{e.message}", error: e.class.name)
113
+ nil
114
+ end
115
+
116
+ # The caller's value (a sessions prune flag) or session.retention_days.
117
+ private_class_method def self.resolve_days(val)
118
+ return val.to_i if !val.nil? && val.to_s.strip != ""
119
+
120
+ Samagotchi::Config.get("session.retention_days").to_i
121
+ end
122
+
123
+ private_class_method def self.resolve_max_count(val)
124
+ return val.to_i if !val.nil? && val.to_s.strip != ""
125
+
126
+ Samagotchi::Config.get("session.max_count").to_i
127
+ end
128
+
129
+ # A comma list of statuses never pruned; "" keeps none.
130
+ private_class_method def self.resolve_keep_status(val)
131
+ raw = !val.nil? && val.to_s.strip != "" ? val.to_s : Samagotchi::Config.get("session.keep_status").to_s
132
+ raw.split(",").map(&:strip).reject(&:empty?)
133
+ end
134
+
135
+ # Apply the retention policy to the sessions on disk.
136
+ #
137
+ # Only deletes when the json file exists — orphan dirs without json are never removed.
138
+ # Honors keep_status and live-worker guard.
139
+ # A session is kept only if it is NOT expired by age AND within max_count;
140
+ # either expiry or overflow triggers deletion (unless protected).
141
+ #
142
+ # +empty_check+ (id → Boolean) marks a session left empty: it goes
143
+ # whatever its age and the count (.prune).
144
+ #
145
+ # +any_age+ makes every session eligible, whatever its age and the
146
+ # count (`chi sessions clean` with no --days: test runs are throwaway).
147
+ #
148
+ # A scratch session nobody owns (its REPL was killed) goes whatever its
149
+ # age, its status and the count; +test_only+ takes it too.
150
+ #
151
+ # +max_count+ counts the sessions that stay: one deleted whatever its
152
+ # place (scratch, left empty, expired, +any_age+) takes no slot.
153
+ #
154
+ # Archived sessions are not in Session.list, so they are neither deleted nor
155
+ # counted. One unarchived is aged from when it was unarchived, if later.
156
+ #
157
+ # @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
158
+ def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
159
+ keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
160
+ empty_check: nil, any_age: false)
161
+ keep_status = Array(keep_status).map(&:to_s)
162
+ # Fetch all sessions sorted newest-first for count logic
163
+ all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
164
+ # Filter test_only if requested
165
+ if test_only
166
+ all = all.select { |session| session.test_run || session.scratch }
167
+ end
168
+
169
+ now = Time.now
170
+ cutoff = days.to_i.positive? ? now - days.to_i * 86_400 : nil
171
+ max = max_count.to_i
172
+
173
+ deleted = []
174
+ kept = []
175
+ skipped = []
176
+ passed_over = 0
177
+
178
+ all.each_with_index do |session, idx|
179
+ path = File.join(state_dir, "#{session.id}#{Session::FILE_EXT}")
180
+ # Only when json present
181
+ unless File.exist?(path)
182
+ skipped << session.id
183
+ next
184
+ end
185
+
186
+ # Protected by keep_status
187
+ if keep_status.include?(session.status.to_s) && !session.scratch
188
+ kept << session.id
189
+ next
190
+ end
191
+
192
+ # Protected by live worker
193
+ if alive_check
194
+ begin
195
+ if alive_check.call(session.id)
196
+ kept << session.id
197
+ next
198
+ end
199
+ rescue StandardError
200
+ nil
201
+ end
202
+ end
203
+
204
+ left_empty = begin
205
+ empty_check&.call(session.id)
206
+ rescue StandardError
207
+ false
208
+ end
209
+
210
+ # Determine expiry and overflow
211
+ expired = false
212
+ if cutoff
213
+ begin
214
+ updated = Time.iso8601(session.updated_at.to_s)
215
+ rescue ArgumentError
216
+ updated = File.mtime(path) rescue now
217
+ end
218
+ unarchived = ArchiveStore.unarchived_at(Session.session_dir(session.id, state_dir: state_dir))
219
+ updated = unarchived if unarchived && unarchived > updated
220
+ expired = updated < cutoff
221
+ end
222
+
223
+ # A session deleted whatever its place (scratch, left empty,
224
+ # expired, any_age) takes no max_count slot: the count is of the
225
+ # sessions ahead that stay.
226
+ overflow = max.positive? && idx - passed_over >= max
227
+ passed_over += 1 if expired || left_empty || any_age || session.scratch
228
+
229
+ # retain forever when both disabled
230
+ if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
231
+ kept << session.id
232
+ next
233
+ end
234
+
235
+ # If neither expired nor overflow, keep
236
+ unless expired || overflow || left_empty || any_age || session.scratch
237
+ kept << session.id
238
+ next
239
+ end
240
+
241
+ # Eligible for deletion
242
+ if dry_run
243
+ deleted << session.id
244
+ else
245
+ begin
246
+ FileUtils.rm_f(path)
247
+ sidecar = File.join(state_dir, session.id)
248
+ FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
249
+ deleted << session.id
250
+ rescue StandardError
251
+ skipped << session.id
252
+ end
253
+ end
254
+ end
255
+
256
+ { deleted: deleted, kept: kept, skipped: skipped }
257
+ end
258
+ end
259
+ end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "session"
5
5
  require_relative "session_manager"
6
+ require_relative "session_retention"
6
7
  require_relative "project_scope"
7
8
  require_relative "session_metrics"
8
9
  require_relative "recap_store"
@@ -239,8 +240,8 @@ module Samagotchi
239
240
  # unless --days asks for the older ones only. Live workers and
240
241
  # keep_status still protect a session.
241
242
  any_age = sub == "clean" && test_only && days.nil?
242
- result = Samagotchi::SessionManager.prune_sessions(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
243
- test_only: test_only, any_age: any_age)
243
+ result = Samagotchi::SessionRetention.prune(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
244
+ test_only: test_only, any_age: any_age)
244
245
  mode = dry_run ? "Would delete" : "Deleted"
245
246
  @stdout.puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
246
247
  if result[:deleted].any?
@@ -523,8 +523,7 @@ module Samagotchi
523
523
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
524
524
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
525
525
 
526
- explained = reply.json&.fetch("detail", nil) if detail == "images_unsupported"
527
- @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})#{": #{explained}" if explained}")
526
+ @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
528
527
  rescue SystemCallError, IOError => e
529
528
  own_prompt_failed
530
529
  @screen.commit("could not send the prompt (#{worker_down(e)})")
@@ -6,6 +6,7 @@ require "reline"
6
6
 
7
7
  require_relative "../config"
8
8
  require_relative "../memory_paths"
9
+ require_relative "../paths"
9
10
  require_relative "../tools/memory"
10
11
  require_relative "../session_commands"
11
12
 
@@ -20,7 +21,6 @@ module Samagotchi
20
21
  # Included for private use; it keeps state in @next_input_prefill and
21
22
  # reads @no_default_input.
22
23
  module InputSupport
23
- XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
24
24
  PROMPT_HISTORY_FILE = "history.json"
25
25
  PROMPT_HISTORY_STATE_DIR = "samagotchi"
26
26
  PROMPT_HISTORY_LIMIT = 20
@@ -207,14 +207,7 @@ module Samagotchi
207
207
  explicit = Samagotchi::Config.get("history.file").to_s.strip
208
208
  return explicit unless explicit.empty?
209
209
 
210
- File.join(xdg_state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
211
- end
212
-
213
- def xdg_state_home
214
- configured = ENV[XDG_STATE_HOME_ENV].to_s.strip
215
- return configured unless configured.empty?
216
-
217
- File.join(Dir.home, ".local", "state")
210
+ File.join(Paths.state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
218
211
  end
219
212
 
220
213
  def load_persistent_history
@@ -37,9 +37,11 @@ module Samagotchi
37
37
  # process loads the whole UI stack through it), a require cycle otherwise.
38
38
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
39
39
 
40
- # TerminalUI encapsulates the single operating mode of the harness.
41
- #
42
- # assist mode — interactive REPL: user types, model responds, tools execute inline.
40
+ # The in-process terminal front end (`chi --no-shared`, `chi scratch`,
41
+ # --verbose, --non-interactive): it owns one session and its Engine, runs
42
+ # an optional -p prompt (--non-interactive: that one turn and out), then the REPL,
43
+ # where the user types, the model answers and tools run inline. The
44
+ # attached terminal (a worker's session) is TerminalUI::AttachedLoop.
43
45
  class TerminalUI
44
46
  include Formatting
45
47
  include InputSupport
@@ -68,9 +70,8 @@ module Samagotchi
68
70
 
69
71
  # @param scratch [Boolean] `chi scratch`: a new session that is deleted
70
72
  # however the REPL ends, saves no memories and writes no recap
71
- def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
73
+ def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
72
74
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false)
73
- @mode = mode.to_sym
74
75
  @scratch = scratch
75
76
  @prompt = prompt
76
77
  @default_model_name = ModelProfile.required_model_name(nil)
@@ -138,7 +139,6 @@ module Samagotchi
138
139
  @renderer = EventRenderer.new(@view)
139
140
  @render_event = ->(event) { handle_stream_event(event) }
140
141
  @engine = Engine.new(
141
- mode: :assist,
142
142
  client: client,
143
143
  host_registry: @host_registry,
144
144
  profile: profile,
@@ -206,16 +206,12 @@ module Samagotchi
206
206
  # @return [Symbol, nil] :turn_failed when lines came from a pipe and a
207
207
  # turn failed (bin/chi exits 1)
208
208
  def run
209
- unless @mode == :assist
210
- raise ArgumentError, "Unknown mode '#{@mode}'. Use: assist"
211
- end
212
-
213
209
  # --non-interactive with no --prompt is a harmless no-op exit: build
214
210
  # nothing and return (no transient session, no banner).
215
211
  return if @non_interactive && @prompt.nil?
216
212
 
217
213
  session = @resume_session || Session.new_session(
218
- mode: @mode.to_s,
214
+ mode: "assist",
219
215
  model_name: @effective_model_name,
220
216
  working_directory: Dir.pwd,
221
217
  preloaded_memory_names: @requested_memories,
@@ -316,19 +312,13 @@ module Samagotchi
316
312
  nil
317
313
  end
318
314
 
319
- # Nothing happened in the session (SessionManager.empty_session?). The
315
+ # Nothing happened in the session (SessionManager.discardable?). The
320
316
  # REPL saves only after a turn and keeps /model in its Engine, so a
321
317
  # session never saved is judged from memory, and one on another model
322
318
  # than the default (/model, --model) is kept.
323
319
  def discard_on_exit?(session)
324
- return false unless SessionManager.discard_empty?
325
- return false unless @effective_model_name == @default_model_name && Array(@engine.used_memory_names).empty?
326
- if File.exist?(File.join(Session.default_state_dir, "#{session.id}#{Session::FILE_EXT}"))
327
- return SessionManager.empty_session?(session.id, default_model: @default_model_name)
328
- end
329
-
330
- SessionManager.no_conversation?(session.messages) && session.last_prompt.to_s.strip.empty? &&
331
- SessionManager.empty_session_dir?(Session.session_dir(session.id))
320
+ SessionManager.discardable?(session.id, default_model: @default_model_name, model_name: @effective_model_name,
321
+ used_memory_names: @engine.used_memory_names, unsaved: session)
332
322
  end
333
323
 
334
324
  # A session left empty: give it up and delete it, one quiet line.
@@ -4,6 +4,7 @@
4
4
  require "fileutils"
5
5
  require "digest"
6
6
  require "date"
7
+ require_relative "../atomic_file"
7
8
  require_relative "../memory_paths"
8
9
 
9
10
  module Samagotchi
@@ -186,7 +187,7 @@ module Samagotchi
186
187
  end
187
188
 
188
189
  file_path = File.join(dir, "#{entry_name}.#{model_key}.md")
189
- Samagotchi::MemoryBundle::IndexUpdater.atomic_write(file_path, body)
190
+ Samagotchi::AtomicFile.write(file_path, body)
190
191
  bytes = body.bytesize
191
192
  return "Model overlay '#{entry_name}' for #{model_key} saved to #{resolved_scope} scope (#{bytes} bytes). File written: #{file_path}"
192
193
  end
@@ -195,7 +196,7 @@ module Samagotchi
195
196
  if entry_name == MEMORY_INDEX
196
197
  Samagotchi::MemoryBundle::IndexUpdater.locked_write(dir) { body }
197
198
  else
198
- Samagotchi::MemoryBundle::IndexUpdater.atomic_write(file_path, body)
199
+ Samagotchi::AtomicFile.write(file_path, body)
199
200
  end
200
201
  bytes = body.bytesize
201
202
  message = "Memory '#{entry_name}' saved to #{resolved_scope} scope (#{bytes} bytes). File written: #{file_path}"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../session"
4
4
  require_relative "../context_note"
5
+ require_relative "../session_inbox"
5
6
  require_relative "peers"
6
7
 
7
8
  module Samagotchi
@@ -33,13 +34,13 @@ module Samagotchi
33
34
  return "Error: session #{id[0, 8]} is open in a chi REPL, which can't take notes"
34
35
  end
35
36
 
36
- SessionManager.write_note(id, text: content, source: "session", from_session: peers.session_id,
37
- from_cwd: peers.cwd, state_dir: peers.state_dir)
37
+ SessionInbox.write_note(id, text: content, source: "session", from_session: peers.session_id,
38
+ from_cwd: peers.cwd, state_dir: peers.state_dir)
38
39
  where = owner ? "its worker adds it before its next turn" : "it has no worker now, so it waits for its next start"
39
40
  "Queued a note for session #{id[0, 8]}: #{where}. It does not start a turn there."
40
41
  rescue Session::AmbiguousId => e
41
42
  "Error: #{e.message}"
42
- rescue SessionManager::NoteRejected => e
43
+ rescue SessionInbox::NoteRejected => e
43
44
  "Error: #{e.message}"
44
45
  rescue ArgumentError
45
46
  "Error: no session #{session.to_s.strip} (list_sessions shows them)"
@@ -5,6 +5,7 @@ require "json"
5
5
  require "securerandom"
6
6
  require "shellwords"
7
7
  require "time"
8
+ require_relative "../atomic_file"
8
9
 
9
10
  module Samagotchi
10
11
  module Tools
@@ -198,9 +199,7 @@ module Samagotchi
198
199
  FileUtils.mkdir_p(dir)
199
200
 
200
201
  path = File.join(dir, METADATA_FILENAME)
201
- temp_path = "#{path}.tmp"
202
- File.write(temp_path, JSON.pretty_generate(record) + "\n")
203
- File.rename(temp_path, path)
202
+ AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
204
203
  end
205
204
 
206
205
  def metadata_path(task_id)
@@ -39,13 +39,24 @@ module Samagotchi
39
39
  # model message precedes this note)
40
40
  # @param running_tasks [Array<Hash>] {id:, command:} of this session's
41
41
  # background tasks still running: a Stop doesn't end them
42
- def cancelled(reason, seconds: nil, shown: false, running_tasks: [])
43
- why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
42
+ # @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
43
+ # it (stop_turn, or a cut with no retry left)
44
+ def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
45
+ why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
44
46
  after = seconds ? " after #{seconds.round}s" : ""
45
47
  what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
46
48
  message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
47
49
  end
48
50
 
51
+ # " <by>: <reason>" of a hook that stopped the turn, as far as known.
52
+ def stopper(stopped_by)
53
+ return "" unless stopped_by.is_a?(Hash)
54
+
55
+ by = one_line(stopped_by[:by])
56
+ reason = one_line(stopped_by[:reason])
57
+ [by.empty? ? nil : " #{by}", reason.empty? ? nil : ": #{reason}"].compact.join
58
+ end
59
+
49
60
  def still_running(tasks)
50
61
  return "" if tasks.empty?
51
62
 
@@ -62,7 +62,7 @@ module Samagotchi
62
62
  @stderr = stderr
63
63
  @gem_spec = gem_spec
64
64
  @shipped_dir = shipped_dir
65
- @platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
65
+ @platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
66
66
  @supported = supported
67
67
  @state_dir = state_dir
68
68
  @web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
@@ -192,7 +192,7 @@ module Samagotchi
192
192
  row = Row.new(component: update.name, from: update.from, to: update.to)
193
193
  case update.status
194
194
  when :would_update, :updated
195
- notes = [kept(update.kept, update.name), replaced(update.replaced)].compact
195
+ notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced)].compact
196
196
  done(row, :update, notes.empty? ? nil : notes.join("; "))
197
197
  when :up_to_date then up_to_date(row)
198
198
  when :skipped then skipped(row, update.note)
@@ -247,7 +247,9 @@ module Samagotchi
247
247
  def footers(options)
248
248
  return [] if off(options, "bundles")
249
249
 
250
- names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map(&:source)
250
+ names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map do |s|
251
+ s.includes.empty? ? s.source : "#{s.source} (#{s.includes.join(", ")})"
252
+ end
251
253
  names.empty? ? [] : ["Also shipped, not installed: #{names.join(", ")} (chi bundle install NAME)"]
252
254
  end
253
255
 
@@ -284,6 +286,11 @@ module Samagotchi
284
286
  row
285
287
  end
286
288
 
289
+ # A profile's new members: "+ btw".
290
+ def adds(members)
291
+ members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
292
+ end
293
+
287
294
  def kept(files, bundle)
288
295
  return nil if files.empty?
289
296
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.6.0"
4
+ VERSION = "0.8.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end