samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -13,13 +13,14 @@ module Samagotchi
13
13
 
14
14
  module_function
15
15
 
16
- def env_positive_int(key, default)
17
- value = ENV.fetch(key, default.to_s).to_i
16
+ # The config setting +key+ (read.*, execute.*, context.*) when positive, else +default+.
17
+ def config_positive_int(key, default)
18
+ value = Samagotchi::Config.get(key).to_i
18
19
  value.positive? ? value : default
19
20
  end
20
21
 
21
- def env_positive_float(key, default)
22
- value = ENV.fetch(key, default.to_s).to_f
22
+ def config_positive_float(key, default)
23
+ value = Samagotchi::Config.get(key).to_f
23
24
  value.positive? ? value : default
24
25
  end
25
26
 
@@ -57,10 +58,10 @@ module Samagotchi
57
58
  build_preview_parts(head: head, tail: tail, total_bytes: content.bytesize)
58
59
  end
59
60
 
60
- def telemetry_lines_for(content:, threshold_env:, threshold_default:, token_key:, pct_key:)
61
- chars_per_token = env_positive_float("SAMAGOTCHI_CONTEXT_CHARS_PER_TOKEN", DEFAULT_CHARS_PER_TOKEN)
61
+ def telemetry_lines_for(content:, threshold_key:, threshold_default:, token_key:, pct_key:)
62
+ chars_per_token = config_positive_float("context.chars_per_token", DEFAULT_CHARS_PER_TOKEN)
62
63
  window_tokens = ContextWindow.current.tokens
63
- telemetry_threshold_pct = env_positive_float(threshold_env, threshold_default)
64
+ telemetry_threshold_pct = config_positive_float(threshold_key, threshold_default)
64
65
 
65
66
  estimated_tokens = (content.length / chars_per_token).ceil
66
67
  estimated_window_pct = (estimated_tokens.to_f / window_tokens) * 100.0
@@ -52,9 +52,9 @@ module Samagotchi
52
52
 
53
53
  size = File.size(path)
54
54
 
55
- hard_max_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_HARD_MAX_BYTES", DEFAULT_HARD_MAX_BYTES)
56
- truncate_at_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_TRUNCATE_AT_BYTES", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
57
- preview_bytes = OutputGuardrails.env_positive_int("SAMAGOTCHI_READ_PREVIEW_BYTES", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
55
+ hard_max_bytes = OutputGuardrails.config_positive_int("read.hard_max_bytes", DEFAULT_HARD_MAX_BYTES)
56
+ truncate_at_bytes = OutputGuardrails.config_positive_int("read.truncate_at_bytes", OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES)
57
+ preview_bytes = OutputGuardrails.config_positive_int("read.preview_bytes", OutputGuardrails::DEFAULT_PREVIEW_BYTES)
58
58
 
59
59
  if size > hard_max_bytes
60
60
  return "Error: file too large: #{path} (#{size} bytes, hard limit #{hard_max_bytes} bytes)."
@@ -105,7 +105,7 @@ module Samagotchi
105
105
  def self.telemetry_lines_for(content)
106
106
  OutputGuardrails.telemetry_lines_for(
107
107
  content: content,
108
- threshold_env: "SAMAGOTCHI_READ_TELEMETRY_THRESHOLD_PCT",
108
+ threshold_key: "read.telemetry_threshold_pct",
109
109
  threshold_default: OutputGuardrails::DEFAULT_TELEMETRY_THRESHOLD_PCT,
110
110
  token_key: "estimated_tokens_for_preview",
111
111
  pct_key: "estimated_window_pct_for_preview"
@@ -14,6 +14,10 @@ module Samagotchi
14
14
  def self.name = NAME
15
15
 
16
16
  def self.call(content, path:)
17
+ # A native call without content/text arrives as nil; check before
18
+ # touching the file, or File.write(path, nil) would empty it.
19
+ return "Error: missing content" unless content.is_a?(String)
20
+
17
21
  path = ToolPath.normalize(path)
18
22
  FileUtils.mkdir_p(File.dirname(path))
19
23
  File.write(path, content)
@@ -143,9 +143,19 @@ module Samagotchi
143
143
  @checkpoint = nil
144
144
  end
145
145
 
146
- # A reminder turn ran. The checkpoint stays only for a pending offer.
146
+ # A reminder turn is about to run: a pending continue offer goes, as for
147
+ # a new prompt (the partial turn stays), so a later "no" can't roll the
148
+ # reminder's exchange back with it.
149
+ # @return [Boolean] whether an offer was pending
150
+ def before_reminder_turn
151
+ offered = awaiting_continue?
152
+ @offer = nil
153
+ offered
154
+ end
155
+
156
+ # A reminder turn ran: the rollback window closes.
147
157
  def after_reminder_turn
148
- @checkpoint = nil unless awaiting_continue?
158
+ @checkpoint = nil
149
159
  end
150
160
 
151
161
  private
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "desktop"
5
+ require_relative "gem_update"
6
+ require_relative "installed_gem"
7
+ require_relative "live_versions"
8
+ require_relative "memory_bundle/shipped_update"
9
+ require_relative "memory_bundle/system_bundle"
10
+ require_relative "session"
11
+ require_relative "version"
12
+ require_relative "web/lan"
13
+
14
+ module Samagotchi
15
+ # `chi update`: brings an installed chi up to date in one go: the system
16
+ # bundle, the shipped bundles the user installed, and the macOS helper.
17
+ # Unedited things update; edited ones are kept (.md) or replaced and
18
+ # reported (hooks, plugin, rules, which didn't load once edited). Live
19
+ # workers and a running chi web are reported, never restarted. Prints one
20
+ # table; a second run changes nothing and says so.
21
+ class UpdateCommand
22
+ USAGE = <<~TEXT
23
+ Usage: chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]
24
+ Updates this chi's install: the gem (from rubygems.org), then the
25
+ system bundle, the bundles chi ships that you installed (chi bundle
26
+ list), and the desktop helper (macOS; rebuilt only when its sources
27
+ changed). Edited memory files are kept and reported; nothing is
28
+ installed that isn't already, and old gem versions stay.
29
+ --dry-run show what would change; change nothing
30
+ --no-gem don't install a newer gem (config update.gem: false)
31
+ --no-bundles leave the shipped bundles alone (config update.bundles: false)
32
+ --no-desktop leave the desktop helper alone (config update.desktop: false)
33
+ Running sessions keep their chi until they idle out (30 min) or
34
+ chi sessions stop ID; a running chi web needs a restart.
35
+ TEXT
36
+
37
+ FLAGS = { "--dry-run" => :dry_run, "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
38
+ # Hidden: no pbs/lsregister/open/pkill for a helper under HOME=<tmp>.
39
+ "--no-register" => :no_register }.freeze
40
+
41
+ # A table row. info rows (live workers, chi web) say what runs; they
42
+ # never fail.
43
+ Row = Struct.new(:component, :from, :to, :status, :failed, keyword_init: true)
44
+
45
+ # What a row says after a change, and in a dry run.
46
+ VERBS = { update: ["updated", "would update"], install: ["installed", "would install"],
47
+ restore: ["restored missing files", "would restore missing files"] }.freeze
48
+
49
+ # @param gem_spec [Gem::Specification, nil] the installed gem (nil: a checkout)
50
+ # @param platform [#call] register: → a Desktop::MacOS-like object
51
+ # @param exec [#call] argv → replaces this process (the new chi's update)
52
+ # @param bundler [Boolean] running under Bundler (bundle exec)
53
+ def initialize(argv, stdout: $stdout, stderr: $stderr, gem_spec: InstalledGem.spec,
54
+ shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR, platform: nil,
55
+ supported: Desktop.supported?, state_dir: Session.default_state_dir, web: nil,
56
+ gem_update: GemUpdate.new, exec: nil, bundler: nil)
57
+ @gem_update = gem_update
58
+ @exec = exec || ->(argv) { Kernel.exec(*argv) }
59
+ @bundler = bundler.nil? ? (defined?(::Bundler) || !ENV["BUNDLE_GEMFILE"].to_s.empty?) : bundler
60
+ @argv = argv.dup
61
+ @stdout = stdout
62
+ @stderr = stderr
63
+ @gem_spec = gem_spec
64
+ @shipped_dir = shipped_dir
65
+ @platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
66
+ @supported = supported
67
+ @state_dir = state_dir
68
+ @web = web || [Web::Lan.local_host(Config.get("web.host")), Config.get("web.port").to_i]
69
+ end
70
+
71
+ # @return [Integer] 0 nothing failed, 1 a part failed (or a checkout), 2 usage
72
+ def run
73
+ options = parse or return 2
74
+ if options[:help]
75
+ @stdout.puts(USAGE)
76
+ return 0
77
+ end
78
+ unless @gem_spec
79
+ @stderr.puts("chi update: running from a checkout (#{InstalledGem::ROOT}): chi update updates an installed chi; " \
80
+ "git pull, or chi bundle upgrade NAME for one bundle")
81
+ return 1
82
+ end
83
+
84
+ @dry_run = options[:dry_run]
85
+ gem = gem_row(options)
86
+ rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
87
+ print_table(rows)
88
+ footers(options).each { |line| @stdout.puts(line) }
89
+ @stdout.puts(summary(rows))
90
+ rows.any?(&:failed) ? 1 : 0
91
+ end
92
+
93
+ private
94
+
95
+ def parse
96
+ argv = @argv.dup
97
+ options = {}
98
+ while (arg = argv.shift)
99
+ if %w[-h --help help].include?(arg)
100
+ options[:help] = true
101
+ elsif arg == "--gem-from" && argv.first
102
+ # Hidden: the old version, passed by the chi that installed this one.
103
+ options[:gem_from] = argv.shift
104
+ elsif FLAGS.key?(arg)
105
+ options[FLAGS[arg]] = true
106
+ else
107
+ @stderr.puts("chi update: unknown option #{arg}")
108
+ @stderr.puts(USAGE)
109
+ return nil
110
+ end
111
+ end
112
+ options
113
+ end
114
+
115
+ # Installs a newer gem and hands over to it (exec: the new code syncs
116
+ # and prints the table), or says why not. A failed install is a failed
117
+ # row; the sync then runs on this version.
118
+ def gem_row(options)
119
+ row = Row.new(component: "chi (gem)", from: VERSION)
120
+ if options[:gem_from]
121
+ row.from = options[:gem_from]
122
+ row.to = VERSION
123
+ return done(row, :update)
124
+ end
125
+ if (why = off(options, "gem"))
126
+ return skipped(row, why)
127
+ end
128
+ return skipped(row, "under Bundler: bundle update samagotchi") if @bundler
129
+
130
+ latest = begin
131
+ @gem_update.latest
132
+ rescue GemUpdate::Error => e
133
+ return row.tap { |r| r.status = "couldn't check (#{e.message})" }
134
+ end
135
+ return up_to_date(row) unless @gem_update.newer?(latest)
136
+
137
+ row.to = latest.version
138
+ return done(row, :update) if @dry_run
139
+
140
+ @stdout.puts("installing samagotchi #{latest.version}…")
141
+ output, ok = @gem_update.install(latest)
142
+ return failed(row, output.lines.map(&:strip).reject(&:empty?).last.to_s) unless ok
143
+
144
+ hand_over(options, row)
145
+ end
146
+
147
+ # exec the wrapper, which activates the newest installed samagotchi.
148
+ # Without one (installed with --bindir), this version syncs.
149
+ def hand_over(options, row)
150
+ wrapper = InstalledGem.wrapper(@gem_spec)
151
+ return done(row, :update, "run chi update again to sync with it") unless wrapper
152
+
153
+ passed = FLAGS.select { |_, key| options[key] && key != :dry_run }.keys
154
+ @stdout.flush
155
+ @exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
156
+ done(row, :update) # only when exec is a spec's stand-in
157
+ end
158
+
159
+ # Why a part is off for this run, or nil.
160
+ def off(options, part)
161
+ return "--no-#{part}" if options[:"no_#{part}"]
162
+ return "update.#{part}: false" unless Config.get("update.#{part}")
163
+
164
+ nil
165
+ end
166
+
167
+ def system_bundle_row
168
+ result = MemoryBundle::SystemBundle.sync(dry_run: @dry_run)
169
+ row = Row.new(component: "system bundle", from: result.from, to: result.to)
170
+ case result.status
171
+ when :installed then done(row, :install)
172
+ when :updated then done(row, :update, kept(result.kept, MemoryBundle::SystemBundle::BUNDLE_NAME))
173
+ when :restored then done(row, :restore).tap { |r| r.to = nil }
174
+ when :up_to_date then up_to_date(row)
175
+ when :newer_installed then skipped(row, "newer than this chi's #{result.to}: left").tap { |r| r.to = nil }
176
+ when :skipped then skipped(row, result.error)
177
+ else failed(row, result.error)
178
+ end
179
+ end
180
+
181
+ def bundle_rows(options)
182
+ if (why = off(options, "bundles"))
183
+ return [Row.new(component: "bundles", status: "skipped (#{why})")]
184
+ end
185
+
186
+ rows = MemoryBundle::ShippedUpdate.plan(shipped_dir: @shipped_dir)
187
+ rows = MemoryBundle::ShippedUpdate.apply(rows) unless @dry_run
188
+ rows.map { |r| bundle_row(r) }
189
+ end
190
+
191
+ def bundle_row(update)
192
+ row = Row.new(component: update.name, from: update.from, to: update.to)
193
+ case update.status
194
+ when :would_update, :updated
195
+ notes = [kept(update.kept, update.name), replaced(update.replaced)].compact
196
+ done(row, :update, notes.empty? ? nil : notes.join("; "))
197
+ when :up_to_date then up_to_date(row)
198
+ when :skipped then skipped(row, update.note)
199
+ else failed(row, update.note)
200
+ end
201
+ end
202
+
203
+ def desktop_row(options)
204
+ return nil unless @supported
205
+
206
+ row = Row.new(component: "Chi Helper")
207
+ if (why = off(options, "desktop"))
208
+ return skipped(row, why)
209
+ end
210
+
211
+ helper = @platform.call(register: !options[:no_register])
212
+ return row.tap { |r| r.status = "not installed" } unless helper.installed?
213
+
214
+ row.from = helper.app_version
215
+ if helper.stale?
216
+ row.to = VERSION
217
+ return done(row, :update, @dry_run ? "rebuilds and restarts it" : "restarted") { helper.upgrade { nil } }
218
+ end
219
+
220
+ return up_to_date(row) unless helper.launch_outdated?
221
+
222
+ helper.refresh_launch_file unless @dry_run
223
+ up_to_date(row).tap { |r| r.status += @dry_run ? " (would refresh the launch file)" : " (launch file refreshed)" }
224
+ rescue Desktop::MacOS::Error, SystemCallError => e
225
+ failed(row, e.message.lines.first.to_s.strip)
226
+ end
227
+
228
+ def live_rows
229
+ rows = []
230
+ stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
231
+ unless stale.empty?
232
+ versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
233
+ ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
234
+ rows << Row.new(component: "workers",
235
+ status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
236
+ "or chi sessions stop #{ids}")
237
+ end
238
+ host, port = @web
239
+ web = LiveVersions.web_version(host, port)
240
+ if web && web != VERSION
241
+ rows << Row.new(component: "chi web :#{port}", from: web,
242
+ status: "running old code: new sessions it starts run #{web} too; restart it")
243
+ end
244
+ rows
245
+ end
246
+
247
+ def footers(options)
248
+ return [] if off(options, "bundles")
249
+
250
+ names = MemoryBundle::ShippedUpdate.not_installed(shipped_dir: @shipped_dir).map(&:source)
251
+ names.empty? ? [] : ["Also shipped, not installed: #{names.join(", ")} (chi bundle install NAME)"]
252
+ end
253
+
254
+ def summary(rows)
255
+ return "(dry run: nothing was changed)" if @dry_run
256
+ return "some parts failed (see above)" if rows.any?(&:failed)
257
+
258
+ rows.all? { |r| r.status.start_with?("up to date", "skipped", "not installed", "couldn't check") } ? "everything is up to date" : "done"
259
+ end
260
+
261
+ # "updated" (or "would update" in a dry run), with a note; the block
262
+ # makes the change (not in a dry run).
263
+ def done(row, verb, note = nil)
264
+ yield if block_given? && !@dry_run
265
+ text = VERBS.fetch(verb)[@dry_run ? 1 : 0]
266
+ row.status = note ? "#{text} (#{note})" : text
267
+ row
268
+ end
269
+
270
+ def up_to_date(row)
271
+ row.to = nil
272
+ row.status = "up to date"
273
+ row
274
+ end
275
+
276
+ def skipped(row, why)
277
+ row.status = "skipped (#{why})"
278
+ row
279
+ end
280
+
281
+ def failed(row, why)
282
+ row.status = "failed (#{why})"
283
+ row.failed = true
284
+ row
285
+ end
286
+
287
+ def kept(files, bundle)
288
+ return nil if files.empty?
289
+
290
+ verb = @dry_run ? "would keep" : "kept"
291
+ "#{verb} your edits in #{files.join(", ")}: chi bundle diff #{bundle} #{files.first}"
292
+ end
293
+
294
+ def replaced(files)
295
+ return nil if files.empty?
296
+
297
+ "#{@dry_run ? "would replace" : "replaced"} edited #{files.join(", ")} (it wasn't loading)"
298
+ end
299
+
300
+ def print_table(rows)
301
+ header = %w[component from to status]
302
+ cells = rows.map { |r| [r.component, r.from.to_s, r.to.to_s, r.status] }
303
+ widths = (0..2).map { |i| ([header] + cells).map { |c| c[i].length }.max }
304
+ ([header] + cells).each do |c|
305
+ @stdout.puts((0..2).map { |i| c[i].ljust(widths[i]) }.join(" ") + " " + c[3])
306
+ end
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "desktop"
5
+ require_relative "memory_bundle/shipped_update"
6
+ require_relative "session"
7
+ require_relative "version"
8
+
9
+ module Samagotchi
10
+ # One line at the first interactive start of a new chi version (REPL,
11
+ # attached, chi web) when something chi update would update is behind:
12
+ # "chi 0.3.1: 2 bundles and the desktop helper can be updated: chi update".
13
+ # Noted once per version in $XDG_STATE_HOME/samagotchi/update_noted,
14
+ # whether or not anything was behind. No network; nothing is changed (the
15
+ # system bundle syncs itself at every start).
16
+ module UpdateHint
17
+ NOTED_FILE = "update_noted"
18
+
19
+ module_function
20
+
21
+ # Only a person starting chi at a terminal: not -p or --non-interactive
22
+ # (a script), not a checkout (git pull updates it), never a worker
23
+ # (workers don't start through bin/chi's launch).
24
+ def wanted?(prompt:, non_interactive:, tty:, installed:)
25
+ !!(tty && installed && !prompt && !non_interactive)
26
+ end
27
+
28
+ # @param helper [#stale?, nil] the desktop helper (nil off macOS)
29
+ # @return [String, nil] the line shown, if any
30
+ def show(io: $stderr, env: ENV, version: VERSION, helper: default_helper(env),
31
+ shipped_dir: MemoryBundle::SourceNormalizer::SHIPPED_DIR)
32
+ path = File.join(File.dirname(Session.default_state_dir(env: env)), NOTED_FILE)
33
+ noted = File.exist?(path) ? File.readlines(path, chomp: true) : []
34
+ return nil if noted.include?(version)
35
+
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ File.write(path, "#{version}\n", mode: "a")
38
+ line = message(version, shipped_dir: shipped_dir, helper: helper)
39
+ io.puts(line) if line
40
+ line
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ def message(version, shipped_dir:, helper:)
46
+ bundles = MemoryBundle::ShippedUpdate.plan(shipped_dir: shipped_dir).count { |r| r.status == :would_update }
47
+ parts = []
48
+ parts << (bundles == 1 ? "1 bundle" : "#{bundles} bundles") if bundles.positive?
49
+ parts << "the desktop helper" if helper&.stale?
50
+ return nil if parts.empty?
51
+
52
+ "chi #{version}: #{parts.join(" and ")} can be updated: chi update"
53
+ end
54
+
55
+ def default_helper(env)
56
+ Desktop.supported? ? Desktop::MacOS.new(env: env) : nil
57
+ end
58
+ end
59
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -25,10 +25,12 @@ module Samagotchi
25
25
  # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
26
  # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
27
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
28
+ # @param names [Array<String>, nil] the model's lookup names, the typed
29
+ # alias first (Engine#model_lookup_names); default: the target's names
28
30
  # @return [Answer]
29
- def self.for(target, profile: nil, adapter: nil, models: nil)
31
+ def self.for(target, profile: nil, adapter: nil, models: nil, names: nil)
30
32
  entry = target.entry
31
- configured = configured(target, entry, models)
33
+ configured = configured(target, entry, models, names)
32
34
  return configured if configured&.no?
33
35
 
34
36
  if entry.chat?
@@ -44,13 +46,13 @@ module Samagotchi
44
46
  marker.is_a?(String) && !marker.empty? ? marker : nil
45
47
  end
46
48
 
47
- def self.configured(target, entry, models)
49
+ def self.configured(target, entry, models, names = nil)
48
50
  models ||= begin
49
51
  ConfigFile.model_settings
50
52
  rescue StandardError
51
53
  {}
52
54
  end
53
- key, value = ConfigFile.model_setting([target.model, target.bare_model], :vision, models: models)
55
+ key, value = ConfigFile.model_setting(names || [target.model, target.bare_model], :vision, models: models)
54
56
  return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
55
57
  return nil if entry.vision.nil?
56
58