samagotchi 0.5.0 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +16 -4
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/reply_wait.rb +9 -0
- data/lib/samagotchi/send_command.rb +3 -6
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +6 -2
- data/lib/samagotchi/tools/delegate_wait.rb +26 -8
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -10
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +80 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -11,18 +11,185 @@
|
|
|
11
11
|
# before_turn resets them (a new user message can make an old call right
|
|
12
12
|
# again); steering merged mid-turn does not.
|
|
13
13
|
#
|
|
14
|
+
# It also watches the model's thinking while it streams (the
|
|
15
|
+
# :generation_progress hook): thinking that goes round in the same few
|
|
16
|
+
# sentences is cut (stop_generation) and the model asked again; if the
|
|
17
|
+
# retry loops too, the turn is stopped with a card. See ThinkingWatch.
|
|
18
|
+
#
|
|
14
19
|
# Settings (config.yml, bundles: loop-guard:):
|
|
15
20
|
# deny_after: 2 same call + same result this many times -> deny the next
|
|
16
21
|
# stop_after: 4 stop the turn at this many loop-guard denies
|
|
17
22
|
# ignore_tools: [task_wait, task_get, delegate_result, list_sessions, list_reminders]
|
|
18
23
|
# mode: deny deny | notify (warn once per call, never deny or stop)
|
|
24
|
+
# thinking: the thinking watch (ThinkingWatch::DEFAULTS)
|
|
25
|
+
# watch: true false: the tool-call guard only
|
|
26
|
+
# action: retry retry (cut, ask again; then stop) | stop | notify
|
|
19
27
|
require "digest"
|
|
20
28
|
|
|
29
|
+
# Sees thinking repeat itself, from the deltas of one generation.
|
|
30
|
+
#
|
|
31
|
+
# The thinking is cut into sentences (at . ! ? and newlines; a run-on over
|
|
32
|
+
# MAX_CARRY chars at its last space). Each sentence of min_words or more is
|
|
33
|
+
# normalized (lower case, letters and digits). Two sentences are alike when
|
|
34
|
+
# the mean of their word-set and word-bigram-set overlaps (Jaccard) is at
|
|
35
|
+
# least `similarity`: a reworded one ("Hmm, …", a synonym, a swapped
|
|
36
|
+
# clause) scores 0.4-0.9, two different ones rarely over 0.15. Two ways to
|
|
37
|
+
# see a loop:
|
|
38
|
+
# - a cycle: for each period p up to max_period, run[p] counts sentences in
|
|
39
|
+
# a row alike the one p before. A cycle seen `repeats` times (run[p] >=
|
|
40
|
+
# p * (repeats - 1)) is a loop once the looping stretch (run[p] + p
|
|
41
|
+
# sentences) spans min_span_sentences and min_span_chars. Templated
|
|
42
|
+
# sentences ("Now I need to open the file <path> and …") are alike too,
|
|
43
|
+
# and an enumeration of them makes every period's run grow, so: a period
|
|
44
|
+
# of 2 or more needs a cycle of distinct sentences (not all alike), and a
|
|
45
|
+
# period of 1 (one sentence over and over) needs `similarity` +
|
|
46
|
+
# SAME_MARGIN (0.9 by default: near-identical);
|
|
47
|
+
# - the same sentence max_same times in the generation, in any order.
|
|
48
|
+
# Nothing triggers before min_chars of thinking. O(new chars) per feed.
|
|
49
|
+
class ThinkingWatch
|
|
50
|
+
DEFAULTS = { "watch" => true, "action" => "retry", "min_chars" => 2000, "repeats" => 3, "max_period" => 6,
|
|
51
|
+
"similarity" => 0.5, "min_span_sentences" => 6, "min_span_chars" => 600, "max_same" => 8,
|
|
52
|
+
"min_words" => 5 }.freeze
|
|
53
|
+
ACTIONS = %w[retry stop notify].freeze
|
|
54
|
+
MAX_CARRY = 400
|
|
55
|
+
# One sentence again and again needs this much more alike than a cycle.
|
|
56
|
+
SAME_MARGIN = 0.4
|
|
57
|
+
MAX_DISTINCT = 5000
|
|
58
|
+
SPLIT = /(?<=[.!?])\s+|\n+/
|
|
59
|
+
|
|
60
|
+
Loop = Struct.new(:period, :times, :sentences, :chars, keyword_init: true)
|
|
61
|
+
Sentence = Struct.new(:text, :words, :bigrams, :key, :length, keyword_init: true)
|
|
62
|
+
|
|
63
|
+
attr_reader :thinking_chars
|
|
64
|
+
|
|
65
|
+
# @param settings [Hash] the thinking: settings (string keys), over DEFAULTS
|
|
66
|
+
def self.settings(raw)
|
|
67
|
+
raw = {} unless raw.is_a?(Hash)
|
|
68
|
+
out = DEFAULTS.dup
|
|
69
|
+
out["watch"] = !%w[false no off 0].include?(raw["watch"].to_s.downcase) if raw.key?("watch")
|
|
70
|
+
out["action"] = raw["action"].to_s if ACTIONS.include?(raw["action"].to_s)
|
|
71
|
+
%w[min_chars repeats max_period min_span_sentences min_span_chars max_same min_words].each do |key|
|
|
72
|
+
number = Integer(raw[key].to_s, exception: false)
|
|
73
|
+
out[key] = number if number&.positive?
|
|
74
|
+
end
|
|
75
|
+
similarity = Float(raw["similarity"].to_s, exception: false)
|
|
76
|
+
out["similarity"] = similarity if similarity && similarity.positive? && similarity <= 1
|
|
77
|
+
out["repeats"] = 2 if out["repeats"] < 2
|
|
78
|
+
out
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def initialize(settings = DEFAULTS)
|
|
82
|
+
@min_chars = settings["min_chars"]
|
|
83
|
+
@repeats = settings["repeats"]
|
|
84
|
+
@max_period = settings["max_period"]
|
|
85
|
+
@similarity = settings["similarity"]
|
|
86
|
+
@min_span_sentences = settings["min_span_sentences"]
|
|
87
|
+
@min_span_chars = settings["min_span_chars"]
|
|
88
|
+
@max_same = settings["max_same"]
|
|
89
|
+
@min_words = settings["min_words"]
|
|
90
|
+
@same_similarity = [@similarity + SAME_MARGIN, 0.95].min
|
|
91
|
+
@carry = +""
|
|
92
|
+
@window = [] # the last max_period + 1 sentences
|
|
93
|
+
@run = Array.new(@max_period + 1, 0)
|
|
94
|
+
@run_chars = Array.new(@max_period + 1, 0) # chars of the run's sentences plus the first cycle's
|
|
95
|
+
@counts = Hash.new(0)
|
|
96
|
+
@thinking_chars = 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param delta [String] new thinking
|
|
100
|
+
# @return [Loop, nil] the loop, once it is seen
|
|
101
|
+
def feed(delta)
|
|
102
|
+
delta = delta.to_s
|
|
103
|
+
@thinking_chars += delta.length
|
|
104
|
+
@carry << delta
|
|
105
|
+
found = nil
|
|
106
|
+
sentences.each do |text|
|
|
107
|
+
loop_seen = add(text)
|
|
108
|
+
found ||= loop_seen
|
|
109
|
+
end
|
|
110
|
+
found
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# The complete sentences in the carry; the unfinished tail stays.
|
|
116
|
+
def sentences
|
|
117
|
+
parts = @carry.split(SPLIT, -1)
|
|
118
|
+
@carry = parts.pop.to_s
|
|
119
|
+
if @carry.length > MAX_CARRY
|
|
120
|
+
cut = @carry.rindex(" ", MAX_CARRY) || MAX_CARRY
|
|
121
|
+
parts << @carry[0, cut]
|
|
122
|
+
@carry = @carry[cut..].to_s.lstrip
|
|
123
|
+
end
|
|
124
|
+
parts
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def add(text)
|
|
128
|
+
words = text.downcase.gsub(/[^[:alnum:]]+/, " ").split
|
|
129
|
+
return nil if words.length < @min_words
|
|
130
|
+
|
|
131
|
+
key = words.join(" ")
|
|
132
|
+
sentence = Sentence.new(text: text.strip, words: words.map(&:hash).uniq,
|
|
133
|
+
bigrams: words.each_cons(2).map { |pair| pair.join(" ").hash }.uniq, key: key, length: text.length)
|
|
134
|
+
same = count(key)
|
|
135
|
+
cycle = cycle_loop(sentence)
|
|
136
|
+
@window << sentence
|
|
137
|
+
@window.shift if @window.length > @max_period + 1
|
|
138
|
+
return nil if @thinking_chars < @min_chars
|
|
139
|
+
|
|
140
|
+
cycle || (same >= @max_same ? Loop.new(period: 1, times: same, sentences: [sentence.text], chars: nil) : nil)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def count(key)
|
|
144
|
+
return @counts[key] += 1 if @counts.key?(key) || @counts.size < MAX_DISTINCT
|
|
145
|
+
|
|
146
|
+
0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def cycle_loop(sentence)
|
|
150
|
+
found = nil
|
|
151
|
+
(1..@max_period).each do |p|
|
|
152
|
+
before = @window[-p]
|
|
153
|
+
if before && similarity(sentence, before) >= (p == 1 ? @same_similarity : @similarity)
|
|
154
|
+
# The run starts after one whole cycle: its chars count too.
|
|
155
|
+
@run_chars[p] = @window.last(p).sum(&:length) if @run[p].zero?
|
|
156
|
+
@run[p] += 1
|
|
157
|
+
@run_chars[p] += sentence.length
|
|
158
|
+
else
|
|
159
|
+
@run[p] = 0
|
|
160
|
+
@run_chars[p] = 0
|
|
161
|
+
next
|
|
162
|
+
end
|
|
163
|
+
next if found || @run[p] < p * (@repeats - 1)
|
|
164
|
+
next if @run[p] + p < @min_span_sentences || @run_chars[p] < @min_span_chars
|
|
165
|
+
|
|
166
|
+
cycle = @window.last(p - 1) + [sentence]
|
|
167
|
+
next if p > 1 && cycle.each_cons(2).all? { |a, b| similarity(a, b) >= @similarity }
|
|
168
|
+
|
|
169
|
+
found = Loop.new(period: p, times: (@run[p] + p) / p, sentences: cycle.map(&:text), chars: @run_chars[p])
|
|
170
|
+
end
|
|
171
|
+
found
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def similarity(a, b)
|
|
175
|
+
return 1.0 if a.key == b.key
|
|
176
|
+
|
|
177
|
+
(jaccard(a.words, b.words) + jaccard(a.bigrams, b.bigrams)) / 2
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def jaccard(a, b)
|
|
181
|
+
shared = (a & b).size
|
|
182
|
+
all = a.size + b.size - shared
|
|
183
|
+
all.zero? ? 0.0 : shared.to_f / all
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
21
187
|
class Plugin
|
|
22
188
|
DEFAULT_IGNORE = %w[task_wait task_get delegate_result list_sessions list_reminders].freeze
|
|
23
189
|
# Built-in tools carry flat fields; plugin and unknown tools carry args:.
|
|
24
190
|
KEY_FIELDS = %i[content path start_line end_line cwd env scope].freeze
|
|
25
191
|
SHORT_CHARS = 60
|
|
192
|
+
THOUGHT_CHARS = 80
|
|
26
193
|
SOURCE = "bundle loop-guard"
|
|
27
194
|
|
|
28
195
|
def initialize(settings = {})
|
|
@@ -31,6 +198,7 @@ class Plugin
|
|
|
31
198
|
@stop_after = positive(settings["stop_after"]) || 4
|
|
32
199
|
@ignore = settings.key?("ignore_tools") ? Array(settings["ignore_tools"]).map(&:to_s) : DEFAULT_IGNORE
|
|
33
200
|
@mode = settings["mode"].to_s == "notify" ? :notify : :deny
|
|
201
|
+
@thinking = ThinkingWatch.settings(settings["thinking"])
|
|
34
202
|
reset
|
|
35
203
|
end
|
|
36
204
|
|
|
@@ -38,10 +206,52 @@ class Plugin
|
|
|
38
206
|
chi.on(:before_turn) { |_event| reset }
|
|
39
207
|
chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
|
|
40
208
|
chi.on(:after_tool_call) { |event| after(event) }
|
|
209
|
+
return unless @thinking["watch"]
|
|
210
|
+
|
|
211
|
+
chi.on(:before_generation) do |_event|
|
|
212
|
+
@watch = ThinkingWatch.new(@thinking)
|
|
213
|
+
@watch_done = false
|
|
214
|
+
end
|
|
215
|
+
chi.on(:generation_progress) { |event, ctx| progress(event, ctx) }
|
|
41
216
|
end
|
|
42
217
|
|
|
43
218
|
private
|
|
44
219
|
|
|
220
|
+
# A stretch of streamed thinking. The first loop in a turn is cut and the
|
|
221
|
+
# model asked again (action retry); a second one, or action stop, stops
|
|
222
|
+
# the turn with a card; action notify only warns, once per generation.
|
|
223
|
+
def progress(event, ctx)
|
|
224
|
+
return if @watch.nil? || @watch_done
|
|
225
|
+
|
|
226
|
+
found = @watch.feed(event[:thinking])
|
|
227
|
+
return unless found
|
|
228
|
+
|
|
229
|
+
@watch_done = true
|
|
230
|
+
what = "#{found.period == 1 ? "one sentence" : "#{found.period} sentences"} ×#{found.times}, " \
|
|
231
|
+
"#{(@watch.thinking_chars / 1000.0).round}k chars, #{(event[:elapsed_ms].to_i / 1000.0).round} s"
|
|
232
|
+
case @thinking["action"]
|
|
233
|
+
when "notify"
|
|
234
|
+
ctx.notify("thinking repeats itself (#{what})", level: :warn)
|
|
235
|
+
when "retry"
|
|
236
|
+
@thinking_loops += 1
|
|
237
|
+
return stop_thinking(event, ctx, found, what) if @thinking_loops > 1
|
|
238
|
+
|
|
239
|
+
ctx.notify("thinking repeats itself (#{what}): cut", level: :warn) if event[:stop_generation]&.call("its thinking kept repeating itself")
|
|
240
|
+
else
|
|
241
|
+
stop_thinking(event, ctx, found, what)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def stop_thinking(event, ctx, found, what)
|
|
246
|
+
return unless event[:stop_turn]&.call("the model's thinking kept repeating itself (#{what})")
|
|
247
|
+
|
|
248
|
+
lines = found.sentences.first(3).map { |sentence| "- \"#{cut(sentence, THOUGHT_CHARS)}\"" }
|
|
249
|
+
ctx.card(title: "loop-guard stopped the turn",
|
|
250
|
+
body: "The model's thinking kept repeating itself:\n\n#{lines.join("\n")}\n\n" \
|
|
251
|
+
"Try: ask for a smaller step, or change the model's sampling (`sampling:` in config.yml).",
|
|
252
|
+
level: :warn)
|
|
253
|
+
end
|
|
254
|
+
|
|
45
255
|
def reset
|
|
46
256
|
@last_result = {} # key => result hash
|
|
47
257
|
@results = Hash.new(0) # [key, result hash] => times it ran with that result
|
|
@@ -50,6 +260,8 @@ class Plugin
|
|
|
50
260
|
@warned = {} # key => true once notified this turn
|
|
51
261
|
@denials = 0
|
|
52
262
|
@pending = nil
|
|
263
|
+
@thinking_loops = 0
|
|
264
|
+
@watch = nil
|
|
53
265
|
end
|
|
54
266
|
|
|
55
267
|
def before(event, ctx)
|
|
@@ -147,8 +359,8 @@ class Plugin
|
|
|
147
359
|
text.empty? ? "no output" : cut(text)
|
|
148
360
|
end
|
|
149
361
|
|
|
150
|
-
def cut(text)
|
|
151
|
-
text.length >
|
|
362
|
+
def cut(text, max = SHORT_CHARS)
|
|
363
|
+
text.length > max ? "#{text[0, max - 1]}…" : text
|
|
152
364
|
end
|
|
153
365
|
|
|
154
366
|
def positive(value)
|
|
@@ -92,6 +92,9 @@ bundles:
|
|
|
92
92
|
deny_after: 2 # same call + same result N times in a turn -> deny the next
|
|
93
93
|
stop_after: 4 # stop the turn at this many denies
|
|
94
94
|
mode: deny # deny | notify (warn only); ignore_tools: [task_wait, ...]
|
|
95
|
+
thinking: # the watch on thinking that repeats itself
|
|
96
|
+
watch: true # false: the tool-call guard only
|
|
97
|
+
action: retry # retry (cut, ask again; then stop) | stop | notify
|
|
95
98
|
check-in:
|
|
96
99
|
after: 50 # tool calls in one turn with no answer before the first check
|
|
97
100
|
every: 50 # then again every N more
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.6.0
|
|
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:
|
|
7
7
|
identity.md: sha256:b7d59a1e824f0918c28a38d40105e8abd1ff68ebab1810fd1dcf52cd3b72e7c0
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
8
|
+
self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
|
|
9
|
+
config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
|
|
10
10
|
memory_guide.md: sha256:43be77156562f1eac369c248045e35ccd89e87d1f7f95a9286be72521b058e87
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
|
|
33
33
|
`mcp` (MCP server tools; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
34
34
|
(turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
|
|
35
|
-
(denies a repeated tool call with the same result, stops the turn after a few
|
|
35
|
+
(denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
|
|
36
|
+
repeats itself, asking me again), `check-in` (after N tool
|
|
36
37
|
calls with no answer, a card asks the user to nudge me, let me go on or stop; `/checkin`), `skills` (`/skill save|list|show|diff`,
|
|
37
38
|
keeps older versions of `skill_*` memories). `chi bundle list`
|
|
38
39
|
shows installed + available; `chi bundle install <name>`. Settings: config.yml `bundles: <name>:`
|
|
@@ -5,22 +5,31 @@ module Samagotchi
|
|
|
5
5
|
# calls every listener registered with #on_cancel (a listener added after
|
|
6
6
|
# the cancel runs at once). The HTTP layer listens to abort an in-flight
|
|
7
7
|
# request; the loops poll #cancelled? between steps.
|
|
8
|
+
#
|
|
9
|
+
# #generation gives one model request a child controller: cancelled with
|
|
10
|
+
# the turn, or alone by #cancel_generation! (a plugin cutting a looping
|
|
11
|
+
# generation while the turn goes on).
|
|
8
12
|
class CancellationController
|
|
9
13
|
def initialize
|
|
10
14
|
@mutex = Mutex.new
|
|
11
15
|
@cancelled = false
|
|
12
16
|
@reason = nil
|
|
17
|
+
@detail = nil
|
|
13
18
|
@listeners = {}
|
|
14
19
|
@next_listener_id = 0
|
|
20
|
+
@generation = nil
|
|
15
21
|
end
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
# @param detail [Object, nil] what the canceller says about it (a cut
|
|
24
|
+
# generation's {by:, reason:})
|
|
25
|
+
def cancel!(reason = :manual, detail = nil)
|
|
18
26
|
listeners = []
|
|
19
27
|
@mutex.synchronize do
|
|
20
28
|
return false if @cancelled
|
|
21
29
|
|
|
22
30
|
@cancelled = true
|
|
23
31
|
@reason = reason
|
|
32
|
+
@detail = detail
|
|
24
33
|
listeners = @listeners.values
|
|
25
34
|
@listeners = {}
|
|
26
35
|
end
|
|
@@ -41,6 +50,38 @@ module Samagotchi
|
|
|
41
50
|
@mutex.synchronize { @reason }
|
|
42
51
|
end
|
|
43
52
|
|
|
53
|
+
def detail
|
|
54
|
+
@mutex.synchronize { @detail }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Yields a child controller for one generation: it is cancelled when
|
|
58
|
+
# this one is, and #cancel_generation! cancels it alone. Gone after the
|
|
59
|
+
# block.
|
|
60
|
+
def generation
|
|
61
|
+
child = CancellationController.new
|
|
62
|
+
listener_id = on_cancel { |reason| child.cancel!(reason) }
|
|
63
|
+
@mutex.synchronize { @generation = child }
|
|
64
|
+
yield child
|
|
65
|
+
ensure
|
|
66
|
+
@mutex.synchronize { @generation = nil if @generation.equal?(child) }
|
|
67
|
+
remove_listener(listener_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The running generation was cancelled (cut, or with the turn).
|
|
71
|
+
def generation_cancelled?
|
|
72
|
+
child = @mutex.synchronize { @generation }
|
|
73
|
+
child ? child.cancelled? : false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Cancel the running generation only. False with none, or when it was
|
|
77
|
+
# already cancelled (cut before, or the turn was cancelled).
|
|
78
|
+
def cancel_generation!(reason = :hook, detail = nil)
|
|
79
|
+
child = @mutex.synchronize { @generation }
|
|
80
|
+
return false unless child
|
|
81
|
+
|
|
82
|
+
child.cancel!(reason, detail)
|
|
83
|
+
end
|
|
84
|
+
|
|
44
85
|
def on_cancel(&block)
|
|
45
86
|
raise ArgumentError, "block required" unless block
|
|
46
87
|
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -148,8 +148,8 @@ module Samagotchi
|
|
|
148
148
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
149
149
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
150
150
|
Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli]),
|
|
151
|
-
# The page's view of a turn:
|
|
152
|
-
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "
|
|
151
|
+
# The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
|
|
152
|
+
Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn]),
|
|
153
153
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
154
154
|
Entry.new(key: "web.annotate_presets", yaml_path: %w[web annotate_presets], type: :string, default: "Agreed|Could you please elaborate?", expose: %i[env config cli]),
|
|
155
155
|
|
|
@@ -83,20 +83,23 @@ final class ChiRunner {
|
|
|
83
83
|
static let launchPath = supportDir + "/launch.json"
|
|
84
84
|
|
|
85
85
|
let timeout: TimeInterval
|
|
86
|
+
let launchPath: String
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
/// @param launchPath another launch file (a test harness's), not the app's
|
|
89
|
+
init(timeout: TimeInterval = 10, launchPath: String = ChiRunner.launchPath) {
|
|
88
90
|
self.timeout = timeout
|
|
91
|
+
self.launchPath = launchPath
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
func loadLaunch() throws -> LaunchConfig {
|
|
92
|
-
guard let data = FileManager.default.contents(atPath:
|
|
93
|
-
throw ChiError.noLaunchFile(
|
|
95
|
+
guard let data = FileManager.default.contents(atPath: launchPath) else {
|
|
96
|
+
throw ChiError.noLaunchFile(launchPath)
|
|
94
97
|
}
|
|
95
98
|
let config: LaunchConfig
|
|
96
99
|
do { config = try JSONDecoder().decode(LaunchConfig.self, from: data) } catch {
|
|
97
|
-
throw ChiError.failed("Unreadable launch file \(
|
|
100
|
+
throw ChiError.failed("Unreadable launch file \(launchPath): \(error.localizedDescription)")
|
|
98
101
|
}
|
|
99
|
-
guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(
|
|
102
|
+
guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(launchPath)") }
|
|
100
103
|
for path in config.argv where path.hasPrefix("/") && !FileManager.default.fileExists(atPath: path) {
|
|
101
104
|
throw ChiError.missing(path)
|
|
102
105
|
}
|
|
@@ -188,6 +191,18 @@ final class ChiRunner {
|
|
|
188
191
|
}
|
|
189
192
|
}
|
|
190
193
|
|
|
194
|
+
/// The model a new session starts on (`chi self --model`: the config's
|
|
195
|
+
/// default, as chi resolves it), or nil when none is configured or chi
|
|
196
|
+
/// can't say. Only a single line counts: nothing that looks like a whole
|
|
197
|
+
/// `chi self` report ends up as the hint.
|
|
198
|
+
func defaultModel(completion: @escaping (String?) -> Void) {
|
|
199
|
+
run(["self", "--model"]) { result in
|
|
200
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else { completion(nil); return }
|
|
201
|
+
let model = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
202
|
+
completion(model.isEmpty || model.contains("\n") ? nil : model)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
191
206
|
private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
192
207
|
// Every project's sessions: the helper isn't in any one project (an
|
|
193
208
|
// older chi ignores the flag).
|
|
@@ -18,10 +18,17 @@ 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).
|
|
24
28
|
@Published var newSelected = false
|
|
29
|
+
/// The model a new session starts on (`chi self --model`), for the new
|
|
30
|
+
/// row's hint; nil while unknown.
|
|
31
|
+
@Published var newModel: String?
|
|
25
32
|
@Published var phase: Phase = .loading
|
|
26
33
|
@Published var message = ""
|
|
27
34
|
|
|
@@ -48,8 +55,13 @@ final class PanelModel: ObservableObject {
|
|
|
48
55
|
|
|
49
56
|
func remove(_ image: PanelImage) {
|
|
50
57
|
images.removeAll { $0 == image }
|
|
58
|
+
if hoveredImage == image.id { hoveredImage = nil }
|
|
51
59
|
ImageIntake.delete([image])
|
|
52
60
|
}
|
|
61
|
+
/// Leaving one thumbnail may come after entering the next.
|
|
62
|
+
func hover(_ image: PanelImage, _ inside: Bool) {
|
|
63
|
+
if inside { hoveredImage = image.id } else if hoveredImage == image.id { hoveredImage = nil }
|
|
64
|
+
}
|
|
53
65
|
var recent: [LiveSession] { sessions.filter(\.recent) }
|
|
54
66
|
|
|
55
67
|
/// The last choice, where still live; else the only live session; else,
|
|
@@ -145,7 +157,10 @@ struct PanelView: View {
|
|
|
145
157
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
146
158
|
HStack(spacing: 8) {
|
|
147
159
|
ForEach(model.images) { image in
|
|
148
|
-
Thumbnail(image: image, removable: model.phase != .sending
|
|
160
|
+
Thumbnail(image: image, removable: model.phase != .sending, hover: model.hoveredImage == image.id,
|
|
161
|
+
onHover: { model.hover(image, $0) }) {
|
|
162
|
+
model.remove(image)
|
|
163
|
+
}
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
.padding(.horizontal, 19).padding(.vertical, 2)
|
|
@@ -193,6 +208,12 @@ struct PanelView: View {
|
|
|
193
208
|
Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
194
209
|
}
|
|
195
210
|
Spacer()
|
|
211
|
+
if let name = model.newModel {
|
|
212
|
+
Text(name).font(.system(size: 11)).foregroundColor(.secondary)
|
|
213
|
+
.lineLimit(1).truncationMode(.middle)
|
|
214
|
+
.frame(maxWidth: 240, alignment: .trailing)
|
|
215
|
+
.help("the model a new session starts on: default.model in config.yml")
|
|
216
|
+
}
|
|
196
217
|
Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
197
218
|
}
|
|
198
219
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
@@ -280,8 +301,9 @@ struct PanelView: View {
|
|
|
280
301
|
struct Thumbnail: View {
|
|
281
302
|
let image: PanelImage
|
|
282
303
|
let removable: Bool
|
|
304
|
+
let hover: Bool
|
|
305
|
+
let onHover: (Bool) -> Void
|
|
283
306
|
let remove: () -> Void
|
|
284
|
-
@State private var hover = false
|
|
285
307
|
|
|
286
308
|
var body: some View {
|
|
287
309
|
Group {
|
|
@@ -308,7 +330,7 @@ struct Thumbnail: View {
|
|
|
308
330
|
.help("remove")
|
|
309
331
|
}
|
|
310
332
|
}
|
|
311
|
-
.onHover
|
|
333
|
+
.onHover(perform: onHover)
|
|
312
334
|
.help(image.name)
|
|
313
335
|
}
|
|
314
336
|
}
|
|
@@ -419,6 +441,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
419
441
|
placeOnActiveScreen()
|
|
420
442
|
panel.makeKeyAndOrderFront(nil)
|
|
421
443
|
loadSessions()
|
|
444
|
+
loadModel()
|
|
422
445
|
}
|
|
423
446
|
|
|
424
447
|
private func placeOnActiveScreen() {
|
|
@@ -446,6 +469,11 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
446
469
|
}
|
|
447
470
|
}
|
|
448
471
|
|
|
472
|
+
/// Kept from the last look while it runs; config rarely changes.
|
|
473
|
+
private func loadModel() {
|
|
474
|
+
runner.defaultModel { [weak self] model in self?.model.newModel = model }
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
func send(_ kind: SendKind) {
|
|
450
478
|
let ids = model.selectedIds
|
|
451
479
|
let new = model.newSelected
|