samagotchi 0.5.0 → 0.5.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94cd11abcfad07ad2843b4e0fc2eea368d77ab6080403d308db906270a1832c6
4
- data.tar.gz: 21ac6e7093ee196d98e2ba50979cdbb28f1a3a10ab0e9cac9bad01c88a078daf
3
+ metadata.gz: 2992e0608fe9019274ad60016432c5d1a7cc6c3b6656b8f294adcfb30d93b843
4
+ data.tar.gz: 94ba5cde3571e628cf79183bd905a0b16a408ab136120cb66557bb5b09ab8c3c
5
5
  SHA512:
6
- metadata.gz: fa39259864928d70d8cf830d0c7cc15c6865cea75d383484979897800a9f79d6f9fa4d79df3700285d9871e8d0cf5420e9af7c0143ab08e71182befa287b52af
7
- data.tar.gz: 24f0fe6863767b628c1eec6957bf6cf33d8966b45f8b2cbdfd7ab268d6c323835dba8ff09fbdd9359775263693228a7a11f0d125cf0a38d7f044d6932ca7c4b9
6
+ metadata.gz: 0f8924a312d690bd366f793d0a13bd26f17ffd7db8dfe106b7edbab122348550cd221721225f1ee7db40b7a071dd1f162ad8d2bb6b637dec9f9607dcc09def86
7
+ data.tar.gz: 7e5fd2d1d211f9da17621ff2dce4d99c1aa76656c6a37a2308ed3de6e7a4bcf8e1edf506c4ef02473e7391505b1671a6db4da9102231aa20881ed289dd303dab
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.5.1] - 2026-09-30
12
+
13
+ ### Fixed
14
+
15
+ - `chi desktop install` / `upgrade` builds the helper again on Macs whose
16
+ Command Line Tools lack SwiftUI's macro plugin ("plugin for module
17
+ 'SwiftUIMacros' not found", since 0.5.0's image thumbnails).
18
+ - After updating chi and restarting `chi web`, the browser loads the new page
19
+ code on the next reload instead of running the old one for up to an hour.
20
+ - A plugin's warning card stays in sight when its step closes while the turn
21
+ goes on.
22
+ - Sessions on OpenAI-compatible hosts no longer fail to send a request when a
23
+ message with an image also carries text with broken UTF-8 bytes.
24
+ - `delegate` and `delegate_result` return as soon as a delegated session's
25
+ turn fails, instead of waiting out the 10-minute timeout when it failed
26
+ quickly.
27
+
28
+ Update with `chi update`. No bundle versions changed.
29
+
11
30
  ## [0.5.0] - 2026-09-30
12
31
 
13
32
  ### Security
@@ -312,7 +331,8 @@ and long-lived sessions.
312
331
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
313
332
  hotkey panel that send selected text or the clipboard to your sessions.
314
333
 
315
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.5.0...HEAD
334
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.5.1...HEAD
335
+ [0.5.1]: https://github.com/dm1try/samagotchi/compare/v0.5.0...v0.5.1
316
336
  [0.5.0]: https://github.com/dm1try/samagotchi/compare/v0.4.0...v0.5.0
317
337
  [0.4.0]: https://github.com/dm1try/samagotchi/compare/v0.3.0...v0.4.0
318
338
  [0.3.0]: https://github.com/dm1try/samagotchi/compare/v0.2.0...v0.3.0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.5.0
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -18,6 +18,10 @@ final class PanelModel: ObservableObject {
18
18
  @Published var source = ""
19
19
  /// Sent as `--image` with a message; notes are text only.
20
20
  @Published var images: [PanelImage] = []
21
+ /// The thumbnail under the pointer (shows its ✕). Kept here, not in a
22
+ /// @State: some Command Line Tools SDKs want the SwiftUIMacros plugin for
23
+ /// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
24
+ @Published var hoveredImage: UUID?
21
25
  @Published var sessions: [LiveSession] = []
22
26
  @Published var selected: Set<String> = []
23
27
  /// The "New session" row; never together with sessions (`--new` takes no ids).
@@ -48,8 +52,13 @@ final class PanelModel: ObservableObject {
48
52
 
49
53
  func remove(_ image: PanelImage) {
50
54
  images.removeAll { $0 == image }
55
+ if hoveredImage == image.id { hoveredImage = nil }
51
56
  ImageIntake.delete([image])
52
57
  }
58
+ /// Leaving one thumbnail may come after entering the next.
59
+ func hover(_ image: PanelImage, _ inside: Bool) {
60
+ if inside { hoveredImage = image.id } else if hoveredImage == image.id { hoveredImage = nil }
61
+ }
53
62
  var recent: [LiveSession] { sessions.filter(\.recent) }
54
63
 
55
64
  /// The last choice, where still live; else the only live session; else,
@@ -145,7 +154,10 @@ struct PanelView: View {
145
154
  ScrollView(.horizontal, showsIndicators: false) {
146
155
  HStack(spacing: 8) {
147
156
  ForEach(model.images) { image in
148
- Thumbnail(image: image, removable: model.phase != .sending) { model.remove(image) }
157
+ Thumbnail(image: image, removable: model.phase != .sending, hover: model.hoveredImage == image.id,
158
+ onHover: { model.hover(image, $0) }) {
159
+ model.remove(image)
160
+ }
149
161
  }
150
162
  }
151
163
  .padding(.horizontal, 19).padding(.vertical, 2)
@@ -280,8 +292,9 @@ struct PanelView: View {
280
292
  struct Thumbnail: View {
281
293
  let image: PanelImage
282
294
  let removable: Bool
295
+ let hover: Bool
296
+ let onHover: (Bool) -> Void
283
297
  let remove: () -> Void
284
- @State private var hover = false
285
298
 
286
299
  var body: some View {
287
300
  Group {
@@ -308,7 +321,7 @@ struct Thumbnail: View {
308
321
  .help("remove")
309
322
  }
310
323
  }
311
- .onHover { hover = $0 }
324
+ .onHover(perform: onHover)
312
325
  .help(image.name)
313
326
  }
314
327
  }
@@ -218,7 +218,8 @@ module Samagotchi
218
218
  SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
219
219
  end
220
220
 
221
- # A String stays a String; an Array of parts passes as given.
221
+ # A String stays a String; an Array of parts passes as given, its text
222
+ # parts scrubbed like a String.
222
223
  def wire_message(message)
223
224
  wire = message.to_h.transform_keys(&:to_sym)
224
225
  wire[:content] = format_content(wire[:content]) if wire.key?(:content)
@@ -226,11 +227,20 @@ module Samagotchi
226
227
  end
227
228
 
228
229
  def format_content(content)
229
- return content if content.is_a?(Array) || content.nil?
230
+ return content if content.nil?
231
+ return content.map { |part| scrub_part(part) } if content.is_a?(Array)
230
232
 
231
233
  scrub(content.to_s)
232
234
  end
233
235
 
236
+ def scrub_part(part)
237
+ return part unless part.is_a?(Hash)
238
+
239
+ part.to_h do |key, value|
240
+ [key, key.to_s == "text" && value.is_a?(String) ? scrub(value) : value]
241
+ end
242
+ end
243
+
234
244
  # Invalid UTF-8 (a tool's garbled output) would make to_json raise.
235
245
  def scrub(text)
236
246
  text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
@@ -103,6 +103,15 @@ module Samagotchi
103
103
  end
104
104
  end
105
105
 
106
+ # The session as it was before a message went in (call's baseline:):
107
+ # its messages, the question pending then, and when its last turn
108
+ # ended, so a turn that ends before the first look still ends the wait.
109
+ # @return [Hash]
110
+ def baseline_of(session, question_id: session.pending_question&.dig(:id))
111
+ last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
112
+ { messages: session.messages.size, question_id: question_id, last_turn: last }
113
+ end
114
+
106
115
  # An idle session whose turn ended after the baseline was taken: its
107
116
  # last_turn moved on, or its messages grew (a turn's note).
108
117
  def turn_ended_since?(session, baseline)
@@ -307,12 +307,9 @@ module Samagotchi
307
307
  end
308
308
 
309
309
  # The session as it was before the message went in (ReplyWait's
310
- # baseline): its messages, the question pending then, and when its last
311
- # turn ended, so a turn that ends before the first look still ends the
312
- # wait.
313
- def baseline_of(session, question_id: session.pending_question&.dig(:id))
314
- last = session.last_turn.is_a?(Hash) ? session.last_turn["ended_at"] : nil
315
- { messages: session.messages.size, question_id: question_id, last_turn: last }
310
+ # baseline).
311
+ def baseline_of(session, **options)
312
+ ReplyWait.baseline_of(session, **options)
316
313
  end
317
314
 
318
315
  # @return [Integer] the exit status
@@ -83,6 +83,7 @@ module Samagotchi
83
83
  child = SessionManager.spawn_session(prompt: task, working_directory: parent.working_directory,
84
84
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
85
85
  parent_id: parent.id, state_dir: state_dir)
86
+ DelegateWait.mark_started(parent.id, child)
86
87
  child.id
87
88
  end
88
89
  private_class_method :start_child
@@ -100,7 +101,7 @@ module Samagotchi
100
101
  end
101
102
 
102
103
  # Only a reply after this delivery counts as the follow-up's.
103
- DelegateWait.mark_seen(parent.id, id, state_dir: state_dir)
104
+ DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
104
105
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{CLIENT_PREFIX}:#{parent.id[0, 8]}",
105
106
  state_dir: state_dir)
106
107
  case delivered[:status]
@@ -9,10 +9,12 @@ module Samagotchi
9
9
  module Tools
10
10
  # Waiting for a delegated session's next reply, shared by delegate and
11
11
  # delegate_result: ReplyWait in the tools' words, with a cursor per
12
- # child (the newest reply this parent was already given). The cursors
13
- # live in this process (a worker runs one session), keyed by parent and
14
- # child; a worker respawn loses them, which only repeats the newest reply
15
- # once.
12
+ # child (the newest reply this parent was already given) and the child
13
+ # as it was before the message went in (ReplyWait's baseline), so a turn
14
+ # that ends before the wait's first look (a fast failure) still ends it.
15
+ # Both live in this process (a worker runs one session), keyed by parent
16
+ # and child; a worker respawn loses them, which only repeats the newest
17
+ # reply once or waits for a later turn.
16
18
  module DelegateWait
17
19
  TIMEOUT_DEFAULT = 600
18
20
  POLL_INTERVAL = 0.5
@@ -28,6 +30,11 @@ module Samagotchi
28
30
  @seen ||= {}
29
31
  end
30
32
 
33
+ # @return [Hash{Array(String, String) => Hash}] [parent id, child id] → ReplyWait baseline taken before sending
34
+ def baselines
35
+ @baselines ||= {}
36
+ end
37
+
31
38
  # @param child_id [String] the child's full id
32
39
  # @param peers [Peers] the parent (session_id, state_dir, cancelled?)
33
40
  # @param timeout [Integer] seconds
@@ -38,7 +45,9 @@ module Samagotchi
38
45
  key = [peers.session_id, child_id]
39
46
  cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
40
47
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
41
- poll_interval: poll_interval, cancelled: cancelled)
48
+ poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key])
49
+ # The turn sent to is handed over: a later wait looks for a later one.
50
+ baselines.delete(key) if %i[done no_reply].include?(wait.status)
42
51
  case wait.status
43
52
  when :done
44
53
  seen[key] = wait.file
@@ -61,9 +70,18 @@ module Samagotchi
61
70
  end
62
71
 
63
72
  # Point the cursor at the newest reply now, so only a later one counts
64
- # (a follow-up sent to a child that already answered).
65
- def mark_seen(parent_id, child_id, state_dir:)
66
- seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
73
+ # (a follow-up sent to a child that already answered), and take the
74
+ # child's baseline before the follow-up goes in.
75
+ # @param child [Session] the child as loaded before sending
76
+ def mark_seen(parent_id, child, state_dir:)
77
+ seen[[parent_id, child.id]] = ReplyWait.newest_reply(child.id, state_dir: state_dir)
78
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child)
79
+ end
80
+
81
+ # A new child's baseline: as spawned, before its first turn ran.
82
+ # @param child [Session] the session spawn_session returned
83
+ def mark_started(parent_id, child)
84
+ baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
67
85
  end
68
86
 
69
87
  def reply_result(child_id, text)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi/escape" # CGI.escapeHTML; Ruby 4.0 ships only cgi/escape
4
+ require "digest"
4
5
  require "fileutils"
5
6
  require "json"
6
7
  require "time"
@@ -1161,13 +1162,26 @@ module Samagotchi
1161
1162
  full = File.join(@public_dir, rel)
1162
1163
  if File.file?(full) && full.start_with?(@public_dir)
1163
1164
  body = File.binread(full)
1164
- ctype = mime_type(full)
1165
- [200, { "Content-Type" => ctype, "Content-Length" => body.bytesize.to_s, "Cache-Control" => "public, max-age=3600" }, [body]]
1165
+ # no-cache + a content ETag: the browser revalidates every asset
1166
+ # (the ES-module imports too) and gets a cheap 304 while it is
1167
+ # unchanged, so a gem upgrade + chi web restart takes effect on the
1168
+ # next load instead of after max-age runs out.
1169
+ etag = %("#{Digest::SHA256.hexdigest(body)[0, 32]}")
1170
+ headers = { "Cache-Control" => "no-cache", "ETag" => etag }
1171
+ return [304, headers, []] if etag_match?(req.get_header("HTTP_IF_NONE_MATCH"), etag)
1172
+
1173
+ [200, headers.merge("Content-Type" => mime_type(full), "Content-Length" => body.bytesize.to_s), [body]]
1166
1174
  else
1167
1175
  not_found(path: req.path_info)
1168
1176
  end
1169
1177
  end
1170
1178
 
1179
+ def etag_match?(if_none_match, etag)
1180
+ return false if if_none_match.nil?
1181
+
1182
+ if_none_match.split(",").map { |t| t.strip.delete_prefix("W/") }.any? { |t| t == "*" || t == etag }
1183
+ end
1184
+
1171
1185
  def mime_type(path)
1172
1186
  case File.extname(path).downcase
1173
1187
  when ".html" then "text/html; charset=utf-8"
@@ -313,8 +313,20 @@ export function createTurnView({
313
313
  if (k) sawText(k);
314
314
  }
315
315
 
316
+ // A step that closes mid-turn (the next one starts) collapses: a warn card
317
+ // in it would go out of sight until the turn ends. It moves out of the
318
+ // step, right after it in the block (open while the turn runs), where
319
+ // turnEnded still finds it and takes it out of the block (leavesBlock).
320
+ function liftWarnCards(gen) {
321
+ const entry = genEls.get(gen);
322
+ if (!entry) return;
323
+ const cards = [...entry.el.querySelectorAll(".plugin-card.warn")];
324
+ if (cards.length) entry.el.after(...cards);
325
+ }
326
+
316
327
  function apply(r) {
317
328
  if (!r) return;
329
+ if (r.closed && r.opened) liftWarnCards(r.closed);
318
330
  if (r.closed) closeGenEl(r.closed);
319
331
  if (r.opened) createGenEl(r.gen);
320
332
  if (r.closed || r.opened) updateBlockSummary();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov