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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. 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 > SHORT_CHARS ? "#{text[0, SHORT_CHARS - 1]}…" : text
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.5.1
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:59ca83f92d11bc6bdc69f3111b3b8e1189280499ae955b10043ff993387b4550
9
- config_modification_protocol.md: sha256:71ff60549574c4be7d4c70801582a19d5d9ec6d5be87f5867f9aa113e5484f21
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), `check-in` (after N tool
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
- def cancel!(reason = :manual)
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
 
@@ -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: turn (one block of generations, the live one at the bottom), stage (the running turn pinned above the composer) or chat (a row of bubbles); ?view= overrides it per page load.
152
- Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "turn", expose: %i[env config cli], enum_values: %w[turn stage chat]),
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
- init(timeout: TimeInterval = 10) {
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: Self.launchPath) else {
93
- throw ChiError.noLaunchFile(Self.launchPath)
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 \(Self.launchPath): \(error.localizedDescription)")
95
+ throw ChiError.failed("Unreadable launch file \(launchPath): \(error.localizedDescription)")
98
96
  }
99
- guard !config.argv.isEmpty else { throw ChiError.failed("Empty argv in \(Self.launchPath)") }
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>`, with stdin written from a background queue and
107
- /// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
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
- let process = Process()
120
- process.executableURL = URL(fileURLWithPath: config.argv[0])
121
- process.arguments = Array(config.argv.dropFirst()) + args
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
- static let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("chi-helper", isDirectory: true)
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
- /// At launch: what a helper that quit mid-send left.
82
- static func sweep() {
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