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
@@ -1,7 +1,8 @@
1
- // The Spotlight-like panel: a message line on top, the text (selection or
2
- // clipboard) below it as quoted context, then a "New session" row and the
3
- // sessions. ⏎ sends a message (`chi send`, or `chi send --new` on the new
4
- // row), ⌘⏎ a note (`chi note`), ⇧⏎ is a newline.
1
+ // The Spotlight-like panel: a message line on top, the images (a screenshot,
2
+ // Finder files, drops) as thumbnails, the text (selection or clipboard)
3
+ // below as quoted context, then a "New session" row and the sessions. ⏎
4
+ // sends a message (`chi send`, or `chi send --new` on the new row, with
5
+ // `--image` per image), ⌘⏎ a note (`chi note`, text only), ⇧⏎ a newline.
5
6
  import AppKit
6
7
  import SwiftUI
7
8
 
@@ -15,6 +16,8 @@ final class PanelModel: ObservableObject {
15
16
  /// The selection or clipboard: the quoted context of a message, or a note.
16
17
  @Published var text = ""
17
18
  @Published var source = ""
19
+ /// Sent as `--image` with a message; notes are text only.
20
+ @Published var images: [PanelImage] = []
18
21
  @Published var sessions: [LiveSession] = []
19
22
  @Published var selected: Set<String> = []
20
23
  /// The "New session" row; never together with sessions (`--new` takes no ids).
@@ -34,6 +37,19 @@ final class PanelModel: ObservableObject {
34
37
  canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
35
38
  }
36
39
  var live: [LiveSession] { sessions.filter { !$0.recent } }
40
+
41
+ /// Up to maxImages; the rest is dropped (and its temp files deleted).
42
+ func add(_ new: [PanelImage]) {
43
+ let room = max(0, maxImages - images.count)
44
+ images += new.prefix(room)
45
+ ImageIntake.delete(Array(new.dropFirst(room)))
46
+ if new.count > room { message = "At most \(maxImages) images" }
47
+ }
48
+
49
+ func remove(_ image: PanelImage) {
50
+ images.removeAll { $0 == image }
51
+ ImageIntake.delete([image])
52
+ }
37
53
  var recent: [LiveSession] { sessions.filter(\.recent) }
38
54
 
39
55
  /// The last choice, where still live; else the only live session; else,
@@ -87,13 +103,16 @@ struct PanelView: View {
87
103
 
88
104
  var body: some View {
89
105
  VStack(spacing: 0) {
90
- TextField("Ask chi…", text: $model.prompt, axis: .vertical)
106
+ // The message is required with images, as in the web.
107
+ TextField(model.images.isEmpty ? "Ask chi…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
91
108
  .textFieldStyle(.plain)
92
109
  .font(.system(size: 17))
93
110
  .lineLimit(1...4)
94
111
  .focused($promptFocused)
95
112
  .padding(.horizontal, 19).padding(.top, 14).padding(.bottom, 8)
96
113
 
114
+ imageStrip
115
+
97
116
  ZStack(alignment: .topLeading) {
98
117
  if model.text.isEmpty {
99
118
  Text("Context, quoted above the message (or the note)…")
@@ -121,6 +140,21 @@ struct PanelView: View {
121
140
  .onAppear { promptFocused = true }
122
141
  }
123
142
 
143
+ @ViewBuilder var imageStrip: some View {
144
+ if !model.images.isEmpty {
145
+ ScrollView(.horizontal, showsIndicators: false) {
146
+ HStack(spacing: 8) {
147
+ ForEach(model.images) { image in
148
+ Thumbnail(image: image, removable: model.phase != .sending) { model.remove(image) }
149
+ }
150
+ }
151
+ .padding(.horizontal, 19).padding(.vertical, 2)
152
+ }
153
+ .frame(height: 52)
154
+ .padding(.bottom, 8)
155
+ }
156
+ }
157
+
124
158
  @ViewBuilder var sessionList: some View {
125
159
  VStack(alignment: .leading, spacing: 2) {
126
160
  switch model.phase {
@@ -242,6 +276,43 @@ struct PanelView: View {
242
276
  }
243
277
  }
244
278
 
279
+ /// One image in the strip: 48 px high, its name as a tooltip, ✕ on hover.
280
+ struct Thumbnail: View {
281
+ let image: PanelImage
282
+ let removable: Bool
283
+ let remove: () -> Void
284
+ @State private var hover = false
285
+
286
+ var body: some View {
287
+ Group {
288
+ if let thumb = image.thumbnail {
289
+ Image(nsImage: thumb).resizable().aspectRatio(contentMode: .fit)
290
+ } else {
291
+ Image(systemName: "photo").font(.system(size: 20)).foregroundColor(.secondary).frame(width: 48)
292
+ }
293
+ }
294
+ .frame(height: 48)
295
+ .frame(maxWidth: 120)
296
+ .clipShape(RoundedRectangle(cornerRadius: 6))
297
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.primary.opacity(0.15), lineWidth: 0.5))
298
+ .overlay(alignment: .topTrailing) {
299
+ if hover && removable {
300
+ Button(action: remove) {
301
+ Image(systemName: "xmark.circle.fill")
302
+ .font(.system(size: 14))
303
+ .symbolRenderingMode(.palette)
304
+ .foregroundStyle(.white, Color.black.opacity(0.55))
305
+ }
306
+ .buttonStyle(.plain)
307
+ .padding(3)
308
+ .help("remove")
309
+ }
310
+ }
311
+ .onHover { hover = $0 }
312
+ .help(image.name)
313
+ }
314
+ }
315
+
245
316
  final class KeyPanel: NSPanel {
246
317
  var onKey: ((NSEvent) -> Bool)?
247
318
  /// Return with these modifiers; true when handled.
@@ -273,6 +344,8 @@ final class PanelController: NSObject, NSWindowDelegate {
273
344
  let runner = ChiRunner()
274
345
  private var panel: KeyPanel!
275
346
  private var returnTo: NSRunningApplication?
347
+ /// Images a running `chi send` still reads: not deleted on close.
348
+ private var inFlight: Set<UUID> = []
276
349
 
277
350
  override init() {
278
351
  super.init()
@@ -290,7 +363,7 @@ final class PanelController: NSObject, NSWindowDelegate {
290
363
  panel.backgroundColor = .clear
291
364
  panel.delegate = self
292
365
 
293
- let effect = NSVisualEffectView()
366
+ let effect = DropEffectView()
294
367
  effect.material = .popover
295
368
  effect.blendingMode = .behindWindow
296
369
  effect.state = .active
@@ -303,6 +376,7 @@ final class PanelController: NSObject, NSWindowDelegate {
303
376
  hosting.topAnchor.constraint(equalTo: effect.topAnchor),
304
377
  hosting.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
305
378
  ])
379
+ effect.onDrop = { [weak self] images in self?.model.add(images) }
306
380
  panel.contentView = effect
307
381
  panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
308
382
  panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
@@ -330,9 +404,13 @@ final class PanelController: NSObject, NSWindowDelegate {
330
404
  }
331
405
 
332
406
  /// @param returnTo the app to give focus back to on close
333
- func show(text: String, source: String, returnTo app: NSRunningApplication?) {
407
+ func show(text: String, images: [PanelImage] = [], source: String, returnTo app: NSRunningApplication?) {
334
408
  returnTo = app
409
+ // A show on an open panel resets it without a close.
410
+ dropImages()
335
411
  model.prompt = ""
412
+ model.images = []
413
+ model.add(images)
336
414
  model.text = text
337
415
  model.source = source
338
416
  model.message = ""
@@ -371,6 +449,11 @@ final class PanelController: NSObject, NSWindowDelegate {
371
449
  func send(_ kind: SendKind) {
372
450
  let ids = model.selectedIds
373
451
  let new = model.newSelected
452
+ if kind == .note, !model.images.isEmpty {
453
+ NSSound.beep()
454
+ model.message = "Notes are text only: ⏎ sends the image as a message"
455
+ return
456
+ }
374
457
  // A note into a session that doesn't exist yet makes no sense.
375
458
  guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
376
459
  model.phase = .sending
@@ -388,6 +471,7 @@ final class PanelController: NSObject, NSWindowDelegate {
388
471
  command = "chi send"
389
472
  args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
390
473
  if !prompt.isEmpty { args += ["-m", prompt] }
474
+ args += model.images.flatMap { ["--image", $0.url.path] }
391
475
  stdin = model.hasContext ? Data(model.text.utf8) : nil
392
476
  case .note:
393
477
  command = "chi note"
@@ -398,8 +482,19 @@ final class PanelController: NSObject, NSWindowDelegate {
398
482
  stdin = Data(parts.joined(separator: "\n\n").utf8)
399
483
  }
400
484
  if !new { args += ids }
401
- runner.run(args, stdin: stdin) { [weak self] result in
485
+ let images = kind == .message ? model.images : []
486
+ inFlight.formUnion(images.map(\.id))
487
+ // Reading, converting and a new worker's start take longer than text.
488
+ let timeout = images.isEmpty ? runner.timeout : 30
489
+ runner.run(args, stdin: stdin, timeout: timeout) { [weak self] result in
402
490
  guard let self else { return }
491
+ self.inFlight.subtract(images.map(\.id))
492
+ // Sent: the temp files go (and the thumbnails). Else they stay for
493
+ // another try, unless the panel was closed or reopened meanwhile.
494
+ var sent = false
495
+ if case .success(let r) = result { sent = r.status == 0 && !r.timedOut }
496
+ if sent { self.model.images.removeAll { images.contains($0) } }
497
+ ImageIntake.delete(images.filter { !self.model.images.contains($0) })
403
498
  switch result {
404
499
  case .failure(let error):
405
500
  self.model.phase = .failed
@@ -410,7 +505,7 @@ final class PanelController: NSObject, NSWindowDelegate {
410
505
  if r.timedOut {
411
506
  self.model.phase = .failed
412
507
  let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
413
- self.model.message = "\(command) took over \(Int(self.runner.timeout)) s and was stopped; \(what). \(out)"
508
+ self.model.message = "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"
414
509
  } else if r.status == 0 {
415
510
  self.model.phase = .sent
416
511
  // --new prints "<full id> started".
@@ -429,7 +524,15 @@ final class PanelController: NSObject, NSWindowDelegate {
429
524
  }
430
525
 
431
526
  func windowWillClose(_ notification: Notification) {
527
+ dropImages()
432
528
  returnTo?.activate()
433
529
  returnTo = nil
434
530
  }
531
+
532
+ /// The panel's images leave it; their temp files go unless a send still
533
+ /// reads them (its completion deletes them).
534
+ private func dropImages() {
535
+ ImageIntake.delete(model.images.filter { !inFlight.contains($0.id) })
536
+ model.images = []
537
+ }
435
538
  }
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "erb"
4
5
  require "fileutils"
5
6
  require "json"
@@ -81,7 +82,42 @@ module Samagotchi
81
82
  def launch_config
82
83
  env = { "LANG" => LANG }
83
84
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
84
- { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env }
85
+ { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
86
+ end
87
+
88
+ # A digest of what a build compiles: the Swift sources and the
89
+ # Info.plist template. The launch file records the one it was built
90
+ # from, so a new chi rebuilds only when these changed.
91
+ def sources_sha
92
+ files = Dir[File.join(@sources_dir, "*.swift")].sort + [File.join(SOURCES_DIR, "Info.plist.erb")]
93
+ Digest::SHA256.hexdigest(files.map { |f| "#{File.basename(f)}\0#{File.binread(f)}" }.join("\0"))
94
+ end
95
+
96
+ # Whether the installed app needs a rebuild (chi desktop upgrade): its
97
+ # sources changed since the build (a launch file without a digest
98
+ # counts as changed), or the launch file's ruby or chi is gone (a Ruby
99
+ # upgrade moved them).
100
+ def stale?
101
+ return false unless installed?
102
+
103
+ launch = read_launch
104
+ launch["sources_sha"] != sources_sha || !launch_ok?(launch)
105
+ end
106
+
107
+ # The launch file names another chi version or path than this one:
108
+ # refresh_launch_file fixes that without a rebuild.
109
+ def launch_outdated?
110
+ launch = read_launch
111
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"]
112
+ end
113
+
114
+ # Rewrite the launch file for this chi, keeping the env it was
115
+ # installed with. The app reads it at each send: no restart.
116
+ def refresh_launch_file
117
+ config = launch_config
118
+ env = read_launch["env"]
119
+ config["env"] = env if env.is_a?(Hash)
120
+ write_launch_file(config)
85
121
  end
86
122
 
87
123
  def warnings
@@ -155,9 +191,9 @@ module Samagotchi
155
191
  result = { installed: installed?, app_path: app_path, chi_version: @version }
156
192
  return result unless result[:installed]
157
193
 
158
- launch = (JSON.parse(File.read(launch_path)) rescue nil)
159
- argv = launch.is_a?(Hash) ? Array(launch["argv"]) : []
160
- env = launch.is_a?(Hash) && launch["env"].is_a?(Hash) ? launch["env"] : {}
194
+ launch = read_launch
195
+ argv = Array(launch["argv"])
196
+ env = launch["env"].is_a?(Hash) ? launch["env"] : {}
161
197
  dump, = @runner.run([PBS, "-dump"])
162
198
  _, running = @runner.run(["pgrep", "-x", EXECUTABLE])
163
199
  hotkey = (JSON.parse(File.read(hotkey_path)) rescue nil)
@@ -166,7 +202,8 @@ module Samagotchi
166
202
  login: login,
167
203
  hotkey: hotkey.is_a?(Hash) ? hotkey : nil,
168
204
  launch_argv: argv,
169
- launch_ok: !argv.empty? && argv.all? { |path| File.exist?(path) },
205
+ launch_ok: launch_ok?(launch),
206
+ stale: stale?,
170
207
  baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
171
208
  service: dump.include?(BUNDLE_ID),
172
209
  running: running
@@ -232,18 +269,32 @@ module Samagotchi
232
269
  @arch ||= RbConfig::CONFIG["host_cpu"] == "arm64" ? "arm64" : `uname -m`.strip
233
270
  end
234
271
 
235
- def write_launch_file
272
+ def read_launch
273
+ launch = JSON.parse(File.read(launch_path))
274
+ launch.is_a?(Hash) ? launch : {}
275
+ rescue SystemCallError, JSON::ParserError
276
+ {}
277
+ end
278
+
279
+ def launch_ok?(launch)
280
+ argv = Array(launch["argv"])
281
+ !argv.empty? && argv.all? { |path| File.exist?(path) }
282
+ end
283
+
284
+ def write_launch_file(config = launch_config)
236
285
  FileUtils.mkdir_p(@support_dir)
237
286
  tmp = "#{launch_path}.tmp"
238
- File.write(tmp, JSON.pretty_generate(launch_config) + "\n")
287
+ File.write(tmp, JSON.pretty_generate(config) + "\n")
239
288
  File.rename(tmp, launch_path)
240
289
  end
241
290
 
242
291
  def register_app
243
292
  return unless @register
244
293
 
245
- @runner.run([PBS, "-update"])
294
+ # The app first, so pbs reads the Service types it has now (a new
295
+ # type may otherwise show only after a re-login).
246
296
  @runner.run([LSREGISTER, "-f", app_path])
297
+ @runner.run([PBS, "-update"])
247
298
  @runner.run(["open", "-g", app_path])
248
299
  end
249
300
 
@@ -13,7 +13,8 @@ module Samagotchi
13
13
  and start it: Services > "Send to chi", or ⌃⌥⌘N with the clipboard,
14
14
  sends text to a live session as a context note. --login: start it
15
15
  at login too (else it runs until you log out)
16
- upgrade rebuild it for this chi and restart it
16
+ upgrade rebuild it for this chi and restart it (chi update does this only
17
+ when its sources changed)
17
18
  uninstall remove it, its settings and its launch file
18
19
  status installed version, how it runs chi, Service and process state
19
20
  TEXT
@@ -96,11 +97,13 @@ module Samagotchi
96
97
 
97
98
  version = if s[:app_version] == s[:chi_version]
98
99
  "#{s[:app_version]} (matches chi)"
100
+ elsif s[:stale]
101
+ "#{s[:app_version] || "?"} (chi is #{s[:chi_version]}: chi update)"
99
102
  else
100
- "#{s[:app_version] || "?"} (chi is #{s[:chi_version]}: chi desktop upgrade)"
103
+ "#{s[:app_version] || "?"} (up to date: chi #{s[:chi_version]} changed nothing in it)"
101
104
  end
102
105
  launch = s[:launch_argv].empty? ? "(none)" : s[:launch_argv].join(" ")
103
- launch += s[:launch_ok] ? " (ok)" : " (missing: chi desktop upgrade)"
106
+ launch += s[:launch_ok] ? " (ok)" : " (missing: chi update)"
104
107
  dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
105
108
  service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
106
109
  running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text_diff"
4
+ require_relative "tools/edit"
5
+ require_relative "tools/tool_path"
6
+
7
+ module Samagotchi
8
+ # What chi's own edit/write tools change, as a TextDiff. Used twice:
9
+ #
10
+ # - EditPreview.for(call): a dry run before the call runs (the approval
11
+ # card). It reads the file once and never writes.
12
+ # - EditPreview.snapshot + change: the file before and after the call ran
13
+ # (the tool row), so the row shows what really happened.
14
+ #
15
+ # A diff is {text:, added:, removed:, truncated:, new_file:}; a preview can
16
+ # also be {error: "old text not found in …"} (the call would fail) or
17
+ # {skipped: "binary file" | "file over 1 MB"}.
18
+ module EditPreview
19
+ TOOLS = %w[edit write].freeze
20
+ MAX_FILE_BYTES = 1024 * 1024
21
+ SNIFF_BYTES = 8 * 1024
22
+
23
+ module_function
24
+
25
+ def tool?(name) = TOOLS.include?(name.to_s)
26
+
27
+ # @return [Hash, nil] nil for a tool that isn't edit/write
28
+ def for(call)
29
+ return nil unless tool?(call[:name])
30
+
31
+ path = Tools::ToolPath.normalize(call[:path])
32
+ before = snapshot(path)
33
+ return before if before.is_a?(Hash)
34
+
35
+ if call[:name].to_s == "edit"
36
+ result = Tools::Edit.apply(call[:content], path: path, start_line: call[:start_line],
37
+ end_line: call[:end_line], read: ->(_) { before })
38
+ return { error: result.delete_prefix("Error: ") } if result.is_a?(String)
39
+
40
+ after = result.first
41
+ else
42
+ after = call[:content]
43
+ return { error: "missing content" } unless after.is_a?(String)
44
+
45
+ skip = skip_reason(after)
46
+ return { skipped: skip } if skip
47
+ end
48
+ # Unlike change, a no-op still gets a (0/0) diff: nil means "not edit/write".
49
+ TextDiff.unified(before.to_s, after).merge(new_file: before.nil?)
50
+ end
51
+
52
+ # The file as the tools read it: nil when it doesn't exist, the text, or
53
+ # {skipped:} / {error:} when it can't be diffed.
54
+ def snapshot(path)
55
+ return nil unless File.file?(path)
56
+ return { skipped: "file over 1 MB" } if File.size(path) > MAX_FILE_BYTES
57
+
58
+ text = File.read(path)
59
+ skip = skip_reason(text)
60
+ skip ? { skipped: skip } : text
61
+ rescue SystemCallError, IOError => e
62
+ { error: e.message }
63
+ end
64
+
65
+ # @param before [String, nil] nil for a file that didn't exist
66
+ # @return [Hash, nil] nil when there's nothing to show (same text, or
67
+ # the after side isn't a readable text file)
68
+ def change(before, after)
69
+ return nil unless after.is_a?(String)
70
+ return nil unless before.nil? || before.is_a?(String)
71
+ return nil if before == after
72
+
73
+ TextDiff.unified(before.to_s, after).merge(new_file: before.nil?)
74
+ end
75
+
76
+ def skip_reason(text)
77
+ return "file over 1 MB" if text.bytesize > MAX_FILE_BYTES
78
+
79
+ "binary file" if text.byteslice(0, SNIFF_BYTES).include?("\0")
80
+ end
81
+ end
82
+ end