samagotchi 0.5.1 → 0.7.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 +77 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +24 -1
- data/docs/desktop.md +52 -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 +7 -2
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +23 -8
- data/lib/samagotchi/desktop_command.rb +1 -1
- 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/update_command.rb +1 -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 +11 -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.7.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
|
@@ -135,6 +135,11 @@ module Samagotchi
|
|
|
135
135
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
136
136
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
137
137
|
Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
|
|
138
|
+
# The desktop helper's kitty targets (Desktop::MacOS.kitty_settings): listen_on copied from kitty.conf (unset = none),
|
|
139
|
+
# the kitty binary, and the foreground programs listed, "|"-separated (a YAML list works too; "*" = every window).
|
|
140
|
+
Entry.new(key: "kitty.listen_on", yaml_path: %w[kitty listen_on], type: :string, default: nil, expose: %i[env config]),
|
|
141
|
+
Entry.new(key: "kitty.binary", yaml_path: %w[kitty binary], type: :string, default: "/Applications/kitty.app/Contents/MacOS/kitty", expose: %i[env config]),
|
|
142
|
+
Entry.new(key: "kitty.agents", yaml_path: %w[kitty agents], type: :string, default: "claude|codex|gemini|aider|opencode|cursor-agent|amp|goose", expose: %i[env config]),
|
|
138
143
|
|
|
139
144
|
Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
140
145
|
Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
@@ -148,8 +153,8 @@ module Samagotchi
|
|
|
148
153
|
Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli]),
|
|
149
154
|
Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli]),
|
|
150
155
|
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: "
|
|
156
|
+
# 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.
|
|
157
|
+
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
158
|
# Quick replies next to Annotate in the page's selection bubble, "|"-separated (a YAML list works too); "" leaves only Annotate.
|
|
154
159
|
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
160
|
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// (text, or images: files in Finder, a picture in Preview), the hotkey with
|
|
3
3
|
// the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
|
|
4
4
|
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
5
|
-
// directly by chi desktop) manages the login item and exits
|
|
5
|
+
// directly by chi desktop) manages the login item and exits;
|
|
6
|
+
// `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift).
|
|
6
7
|
import AppKit
|
|
7
8
|
import ServiceManagement
|
|
8
9
|
|
|
@@ -97,6 +98,9 @@ struct ChiHelperMain {
|
|
|
97
98
|
if args.count >= 2, args[1] == "--login" {
|
|
98
99
|
exit(loginCommand(args.count >= 3 ? args[2] : ""))
|
|
99
100
|
}
|
|
101
|
+
if args.count >= 2, args[1] == "--kitty" {
|
|
102
|
+
exit(kittyCommand(Array(args.dropFirst(2))))
|
|
103
|
+
}
|
|
100
104
|
let app = NSApplication.shared
|
|
101
105
|
let delegate = AppDelegate()
|
|
102
106
|
app.delegate = delegate
|
|
@@ -7,13 +7,8 @@ struct LaunchConfig: Decodable {
|
|
|
7
7
|
let version: String?
|
|
8
8
|
let argv: [String]
|
|
9
9
|
let env: [String: String]?
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
struct ChiResult {
|
|
13
|
-
let status: Int32
|
|
14
|
-
let stdout: String
|
|
15
|
-
let stderr: String
|
|
16
|
-
let timedOut: Bool
|
|
10
|
+
/// Agents in kitty windows (Kitty.swift); nil = none.
|
|
11
|
+
let kitty: KittySettings?
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
enum ChiError: Error, CustomStringConvertible {
|
|
@@ -83,29 +78,31 @@ final class ChiRunner {
|
|
|
83
78
|
static let launchPath = supportDir + "/launch.json"
|
|
84
79
|
|
|
85
80
|
let timeout: TimeInterval
|
|
81
|
+
let launchPath: String
|
|
86
82
|
|
|
87
|
-
|
|
83
|
+
/// @param launchPath another launch file (a test harness's), not the app's
|
|
84
|
+
init(timeout: TimeInterval = 10, launchPath: String = ChiRunner.launchPath) {
|
|
88
85
|
self.timeout = timeout
|
|
86
|
+
self.launchPath = launchPath
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
func loadLaunch() throws -> LaunchConfig {
|
|
92
|
-
guard let data = FileManager.default.contents(atPath:
|
|
93
|
-
throw ChiError.noLaunchFile(
|
|
90
|
+
guard let data = FileManager.default.contents(atPath: launchPath) else {
|
|
91
|
+
throw ChiError.noLaunchFile(launchPath)
|
|
94
92
|
}
|
|
95
93
|
let config: LaunchConfig
|
|
96
94
|
do { config = try JSONDecoder().decode(LaunchConfig.self, from: data) } catch {
|
|
97
|
-
throw ChiError.failed("Unreadable launch file \(
|
|
95
|
+
throw ChiError.failed("Unreadable launch file \(launchPath): \(error.localizedDescription)")
|
|
98
96
|
}
|
|
99
|
-
guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(
|
|
97
|
+
guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(launchPath)") }
|
|
100
98
|
for path in config.argv where path.hasPrefix("/") && !FileManager.default.fileExists(atPath: path) {
|
|
101
99
|
throw ChiError.missing(path)
|
|
102
100
|
}
|
|
103
101
|
return config
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
/// Runs `chi <args
|
|
107
|
-
///
|
|
108
|
-
/// note cap). Calls back on the main queue.
|
|
104
|
+
/// Runs `chi <args>` through ProcessRunner (no pipe deadlock near the
|
|
105
|
+
/// 16 KiB note cap). Calls back on the main queue.
|
|
109
106
|
/// @param timeout this call's limit instead of the runner's
|
|
110
107
|
func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
|
|
111
108
|
completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
@@ -116,59 +113,9 @@ final class ChiRunner {
|
|
|
116
113
|
completion(.failure(.failed("\(error)"))); return
|
|
117
114
|
}
|
|
118
115
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
process.environment = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
|
|
123
|
-
process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
|
|
124
|
-
let out = Pipe(), err = Pipe(), input = Pipe()
|
|
125
|
-
process.standardOutput = out
|
|
126
|
-
process.standardError = err
|
|
127
|
-
process.standardInput = stdin == nil ? FileHandle.nullDevice : input
|
|
128
|
-
|
|
129
|
-
let lock = NSLock()
|
|
130
|
-
var outData = Data(), errData = Data()
|
|
131
|
-
out.fileHandleForReading.readabilityHandler = { handle in
|
|
132
|
-
let chunk = handle.availableData
|
|
133
|
-
lock.lock(); outData.append(chunk); lock.unlock()
|
|
134
|
-
}
|
|
135
|
-
err.fileHandleForReading.readabilityHandler = { handle in
|
|
136
|
-
let chunk = handle.availableData
|
|
137
|
-
lock.lock(); errData.append(chunk); lock.unlock()
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
var timedOut = false
|
|
141
|
-
process.terminationHandler = { proc in
|
|
142
|
-
out.fileHandleForReading.readabilityHandler = nil
|
|
143
|
-
err.fileHandleForReading.readabilityHandler = nil
|
|
144
|
-
let restOut = out.fileHandleForReading.readDataToEndOfFile()
|
|
145
|
-
let restErr = err.fileHandleForReading.readDataToEndOfFile()
|
|
146
|
-
lock.lock()
|
|
147
|
-
outData.append(restOut); errData.append(restErr)
|
|
148
|
-
let result = ChiResult(status: proc.terminationStatus,
|
|
149
|
-
stdout: String(decoding: outData, as: UTF8.self),
|
|
150
|
-
stderr: String(decoding: errData, as: UTF8.self),
|
|
151
|
-
timedOut: timedOut)
|
|
152
|
-
lock.unlock()
|
|
153
|
-
DispatchQueue.main.async { completion(.success(result)) }
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
do { try process.run() } catch {
|
|
157
|
-
completion(.failure(.failed("Could not start \(config.argv[0]): \(error.localizedDescription)")))
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
if let stdin {
|
|
161
|
-
DispatchQueue.global().async {
|
|
162
|
-
try? input.fileHandleForWriting.write(contentsOf: stdin)
|
|
163
|
-
try? input.fileHandleForWriting.close()
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
DispatchQueue.global().asyncAfter(deadline: .now() + (timeout ?? self.timeout)) {
|
|
167
|
-
if process.isRunning {
|
|
168
|
-
lock.lock(); timedOut = true; lock.unlock()
|
|
169
|
-
process.terminate()
|
|
170
|
-
}
|
|
171
|
-
}
|
|
116
|
+
ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
|
|
117
|
+
env: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
|
|
118
|
+
stdin: stdin, timeout: timeout ?? self.timeout, completion: completion)
|
|
172
119
|
}
|
|
173
120
|
|
|
174
121
|
/// The live sessions, then up to +recentCount+ stopped ones, newest
|
|
@@ -188,6 +135,18 @@ final class ChiRunner {
|
|
|
188
135
|
}
|
|
189
136
|
}
|
|
190
137
|
|
|
138
|
+
/// The model a new session starts on (`chi self --model`: the config's
|
|
139
|
+
/// default, as chi resolves it), or nil when none is configured or chi
|
|
140
|
+
/// can't say. Only a single line counts: nothing that looks like a whole
|
|
141
|
+
/// `chi self` report ends up as the hint.
|
|
142
|
+
func defaultModel(completion: @escaping (String?) -> Void) {
|
|
143
|
+
run(["self", "--model"]) { result in
|
|
144
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else { completion(nil); return }
|
|
145
|
+
let model = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
146
|
+
completion(model.isEmpty || model.contains("\n") ? nil : model)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
191
150
|
private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
192
151
|
// Every project's sessions: the helper isn't in any one project (an
|
|
193
152
|
// older chi ignores the flag).
|
|
@@ -27,7 +27,11 @@ struct PanelImage: Identifiable, Equatable {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
enum ImageIntake {
|
|
30
|
-
|
|
30
|
+
/// $TMPDIR when set (launchd sets it; a spec points it elsewhere), else
|
|
31
|
+
/// the user's temp folder.
|
|
32
|
+
static let tempRoot = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TMPDIR"].flatMap { $0.isEmpty ? nil : $0 }
|
|
33
|
+
?? NSTemporaryDirectory(), isDirectory: true)
|
|
34
|
+
static let tempDir = tempRoot.appendingPathComponent("chi-helper", isDirectory: true)
|
|
31
35
|
|
|
32
36
|
/// A pasteboard's content for the panel, in this order: image files
|
|
33
37
|
/// (Finder ⌘C, a Service on files) with no text; else its text, as before
|
|
@@ -78,9 +82,40 @@ enum ImageIntake {
|
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
///
|
|
82
|
-
|
|
85
|
+
/// Images pasted into kitty windows, kept for the agent to read after
|
|
86
|
+
/// the send: never deleted by it, swept at launch after a week.
|
|
87
|
+
static let sentDir = tempRoot.appendingPathComponent("chi-helper-sent", isDirectory: true)
|
|
88
|
+
static let sentMaxAge: TimeInterval = 7 * 86_400
|
|
89
|
+
|
|
90
|
+
/// The paths a kitty paste names: an image the helper wrote is copied to
|
|
91
|
+
/// sentDir first (the send deletes the original), with no spaces in its
|
|
92
|
+
/// name; a file of the user's is named as it is. Call before any send
|
|
93
|
+
/// job starts. An image that can't be copied is left out.
|
|
94
|
+
static func keepForPaste(_ images: [PanelImage]) -> [String] {
|
|
95
|
+
images.compactMap { image in
|
|
96
|
+
guard image.temp else { return image.url.path }
|
|
97
|
+
let ext = image.url.pathExtension.isEmpty ? "png" : image.url.pathExtension
|
|
98
|
+
let copy = sentDir.appendingPathComponent("\(UUID().uuidString).\(ext)")
|
|
99
|
+
do {
|
|
100
|
+
try FileManager.default.createDirectory(at: sentDir, withIntermediateDirectories: true)
|
|
101
|
+
try FileManager.default.copyItem(at: image.url, to: copy)
|
|
102
|
+
return copy.path
|
|
103
|
+
} catch {
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// At launch: what a helper that quit mid-send left, and pasted images
|
|
110
|
+
/// older than a week.
|
|
111
|
+
static func sweep(now: Date = Date()) {
|
|
83
112
|
try? FileManager.default.removeItem(at: tempDir)
|
|
113
|
+
let fm = FileManager.default
|
|
114
|
+
for name in (try? fm.contentsOfDirectory(atPath: sentDir.path)) ?? [] {
|
|
115
|
+
let url = sentDir.appendingPathComponent(name)
|
|
116
|
+
let modified = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? now
|
|
117
|
+
if now.timeIntervalSince(modified) > sentMaxAge { try? fm.removeItem(at: url) }
|
|
118
|
+
}
|
|
84
119
|
}
|
|
85
120
|
}
|
|
86
121
|
|