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 +4 -4
- data/CHANGELOG.md +21 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/Panel.swift +16 -3
- data/lib/samagotchi/llm/openai_chat.rb +12 -2
- data/lib/samagotchi/reply_wait.rb +9 -0
- data/lib/samagotchi/send_command.rb +3 -6
- data/lib/samagotchi/tools/delegate.rb +2 -1
- data/lib/samagotchi/tools/delegate_wait.rb +26 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +16 -2
- data/lib/samagotchi/web/public/turn_view.js +12 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2992e0608fe9019274ad60016432c5d1a7cc6c3b6656b8f294adcfb30d93b843
|
|
4
|
+
data.tar.gz: 94ba5cde3571e628cf79183bd905a0b16a408ab136120cb66557bb5b09ab8c3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
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.
|
|
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)
|
|
311
|
-
|
|
312
|
-
|
|
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,
|
|
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)
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
|
|
66
|
-
|
|
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)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
1165
|
-
|
|
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();
|