samagotchi 0.5.1 → 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 +64 -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 +15 -0
- 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 +4 -2
- 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/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 +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- 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 +68 -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).
|
|
@@ -26,6 +26,9 @@ final class PanelModel: ObservableObject {
|
|
|
26
26
|
@Published var selected: Set<String> = []
|
|
27
27
|
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
28
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?
|
|
29
32
|
@Published var phase: Phase = .loading
|
|
30
33
|
@Published var message = ""
|
|
31
34
|
|
|
@@ -205,6 +208,12 @@ struct PanelView: View {
|
|
|
205
208
|
Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
206
209
|
}
|
|
207
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
|
+
}
|
|
208
217
|
Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
209
218
|
}
|
|
210
219
|
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
@@ -432,6 +441,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
432
441
|
placeOnActiveScreen()
|
|
433
442
|
panel.makeKeyAndOrderFront(nil)
|
|
434
443
|
loadSessions()
|
|
444
|
+
loadModel()
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
private func placeOnActiveScreen() {
|
|
@@ -459,6 +469,11 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
459
469
|
}
|
|
460
470
|
}
|
|
461
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
|
+
|
|
462
477
|
func send(_ kind: SendKind) {
|
|
463
478
|
let ids = model.selectedIds
|
|
464
479
|
let new = model.newSelected
|