samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +11 -0
- metadata +20 -1
|
@@ -12,6 +12,7 @@ require_relative "reline_seam"
|
|
|
12
12
|
require_relative "../bridge_client"
|
|
13
13
|
require_relative "../log"
|
|
14
14
|
require_relative "../context_note"
|
|
15
|
+
require_relative "../steer"
|
|
15
16
|
require_relative "../model_profile"
|
|
16
17
|
require_relative "../output_formatter"
|
|
17
18
|
require_relative "../session_commands"
|
|
@@ -41,6 +42,8 @@ module Samagotchi
|
|
|
41
42
|
DELETE_FLAG = "--delete"
|
|
42
43
|
# How long /exit --delete waits for the worker to let go.
|
|
43
44
|
DELETE_WAIT = 10
|
|
45
|
+
# Leave, and archive the session (hidden from the lists, kept for good).
|
|
46
|
+
ARCHIVE_COMMAND = "/archive"
|
|
44
47
|
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
45
48
|
DETACH_WINDOW = 2.0
|
|
46
49
|
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
@@ -82,11 +85,15 @@ module Samagotchi
|
|
|
82
85
|
# the first read (a new session with no -p, as the REPL)
|
|
83
86
|
# @param clock [#call] monotonic seconds (the Ctrl-C detach window)
|
|
84
87
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
88
|
+
# @param archive_session [#call] session id -> archives it (/archive);
|
|
89
|
+
# SessionManager.archive_session's result
|
|
85
90
|
def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
86
91
|
default_input: false, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
87
|
-
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) }
|
|
92
|
+
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
93
|
+
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
|
|
88
94
|
@client = client
|
|
89
95
|
@delete_session = delete_session
|
|
96
|
+
@archive_session = archive_session
|
|
90
97
|
@screen = screen
|
|
91
98
|
@client_id = client_id
|
|
92
99
|
@view = AttachedView.new(screen)
|
|
@@ -198,7 +205,7 @@ module Samagotchi
|
|
|
198
205
|
# Another client's anytime command: its line now, before the cards
|
|
199
206
|
# it shows (its command_ran comes when it's done).
|
|
200
207
|
when :command_queued
|
|
201
|
-
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id])
|
|
208
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
|
|
202
209
|
when :command_ran
|
|
203
210
|
command_ran(event)
|
|
204
211
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
@@ -266,6 +273,7 @@ module Samagotchi
|
|
|
266
273
|
return submit(nil) if DETACH_COMMANDS.include?(text.downcase)
|
|
267
274
|
return exit_worker if exit_command?(text)
|
|
268
275
|
return exit_and_delete if exit_command?(text.delete_suffix(DELETE_FLAG).rstrip) && text.end_with?(" #{DELETE_FLAG}")
|
|
276
|
+
return exit_and_archive if text.casecmp?(ARCHIVE_COMMAND)
|
|
269
277
|
command = text.split(/\s+/, 2).first
|
|
270
278
|
if @continue_offer
|
|
271
279
|
return answer_continue(text) unless command_registry.command?(text) || [STATS_COMMAND, RECAP_COMMAND].include?(command)
|
|
@@ -329,6 +337,27 @@ module Samagotchi
|
|
|
329
337
|
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
330
338
|
end
|
|
331
339
|
|
|
340
|
+
# /archive: the worker is asked to exit as for /exit, then the session
|
|
341
|
+
# is archived. A worker staying up for another UI (or reminders, a
|
|
342
|
+
# queued prompt) is stopped by the archive, as the web's archive
|
|
343
|
+
# stops it; a turn running refuses it. An empty session: the worker
|
|
344
|
+
# deletes it as it leaves.
|
|
345
|
+
def exit_and_archive
|
|
346
|
+
reply = @client.request_exit(client_id: @client_id)
|
|
347
|
+
id = @client.session_id
|
|
348
|
+
return detach("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
|
|
349
|
+
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
350
|
+
|
|
351
|
+
result = @archive_session.call(id)
|
|
352
|
+
return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
|
|
353
|
+
|
|
354
|
+
detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
|
|
355
|
+
rescue SystemCallError, IOError => e
|
|
356
|
+
detach("#{exit_failed_line(e.message)} Not archived.")
|
|
357
|
+
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
358
|
+
detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
|
|
359
|
+
end
|
|
360
|
+
|
|
332
361
|
def exit_line(reply)
|
|
333
362
|
id = @client.session_id
|
|
334
363
|
error = reply.json&.fetch("error", nil)
|
|
@@ -401,7 +430,7 @@ module Samagotchi
|
|
|
401
430
|
end
|
|
402
431
|
|
|
403
432
|
def command_ran(event)
|
|
404
|
-
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime]
|
|
433
|
+
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
|
|
405
434
|
output = event[:output].to_s
|
|
406
435
|
if output.empty?
|
|
407
436
|
nil
|
|
@@ -816,12 +845,15 @@ module Samagotchi
|
|
|
816
845
|
# The id stays findable: the detach line and chi sessions list show it.
|
|
817
846
|
Log.session_id = @client.session_id
|
|
818
847
|
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size)
|
|
819
|
-
last_user = exchange.rindex { |m| m
|
|
848
|
+
last_user = exchange.rindex { |m| Steer.prompt?(m) }
|
|
820
849
|
render_join_notes(messages)
|
|
821
850
|
return unless last_user
|
|
822
851
|
|
|
823
852
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
824
853
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
854
|
+
exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
|
|
855
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
856
|
+
end
|
|
825
857
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
826
858
|
# be only a tool call.
|
|
827
859
|
answer = exchange[(last_user + 1)..].reverse_each
|
|
@@ -834,7 +866,7 @@ module Samagotchi
|
|
|
834
866
|
# The context notes that came since the last prompt (all of them in a
|
|
835
867
|
# session with none), after the exchange the header shows.
|
|
836
868
|
def render_join_notes(messages, after_exchange: false)
|
|
837
|
-
last_user = messages.rindex { |m| m
|
|
869
|
+
last_user = messages.rindex { |m| Steer.prompt?(m) }
|
|
838
870
|
return if after_exchange != !last_user.nil?
|
|
839
871
|
|
|
840
872
|
messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
@@ -894,6 +926,7 @@ module Samagotchi
|
|
|
894
926
|
@screen.commit(snapshot_tool_line(part))
|
|
895
927
|
end
|
|
896
928
|
when "input" then @screen.commit("input> #{part[:text]}")
|
|
929
|
+
when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
|
|
897
930
|
when "reminder" then @screen.commit(reminder_line(part[:reminders]))
|
|
898
931
|
when "hook_notice" then @screen.commit(EventRenderer.hook_notice_line(part))
|
|
899
932
|
end
|
|
@@ -1029,7 +1062,7 @@ module Samagotchi
|
|
|
1029
1062
|
params = part[:params].to_s.strip
|
|
1030
1063
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
1031
1064
|
status = part[:status].to_s
|
|
1032
|
-
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status
|
|
1065
|
+
"#{paint('tool>', 36)} #{part[:tool]}#{params_suffix}: #{paint(status, status_color(status))}" \
|
|
1033
1066
|
"#{format_tool_image_suffix(part[:images])}"
|
|
1034
1067
|
end
|
|
1035
1068
|
end
|
|
@@ -63,6 +63,9 @@ module Samagotchi
|
|
|
63
63
|
@view.generation_feedback_finished
|
|
64
64
|
when :pending_input_merged
|
|
65
65
|
render_merge(event)
|
|
66
|
+
when :empty_answer_retry
|
|
67
|
+
@view.finish_thinking_spinner
|
|
68
|
+
@view.print_line(@view.format_empty_retry_line(event))
|
|
66
69
|
when :turn_completed
|
|
67
70
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
68
71
|
when :guardrail_warning
|
|
@@ -146,14 +149,16 @@ module Samagotchi
|
|
|
146
149
|
@view.print_line("iteration limit reached") if summary[:resumable]
|
|
147
150
|
end
|
|
148
151
|
|
|
149
|
-
# Steering merged into the running turn: the answer it follows first
|
|
150
|
-
# (the turn summary shows only the last one), then the note
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
# Steering merged into the running turn: the answer it follows first
|
|
153
|
+
# (the turn summary shows only the last one), then the note, then a
|
|
154
|
+
# line per plugin steer (none for a steer-only merge).
|
|
155
|
+
def render_merge(event)
|
|
156
|
+
@view.finish_thinking_spinner
|
|
157
|
+
@view.print_line(event[:answer]) unless event[:answer].to_s.strip.empty?
|
|
158
|
+
count = event[:count].to_i
|
|
159
|
+
@view.print_line("(#{count} message#{"s" unless count == 1} merged into the running turn)") if count.positive?
|
|
160
|
+
Array(event[:steers]).each { |steer| @view.print_line(@view.format_steer_line(source: steer[:source], text: steer[:text])) }
|
|
161
|
+
end
|
|
157
162
|
|
|
158
163
|
private
|
|
159
164
|
|
|
@@ -13,9 +13,13 @@ module Samagotchi
|
|
|
13
13
|
params = activity[:params].to_s.strip
|
|
14
14
|
params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
|
|
15
15
|
status = activity[:status].to_s
|
|
16
|
-
status_color = status == "ok" ? 32 : 31
|
|
17
16
|
elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
|
|
18
|
-
"#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
|
|
17
|
+
"#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color(status))}#{elapsed_suffix}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
21
|
+
def status_color(status)
|
|
22
|
+
{ "ok" => 32, "stopped" => 33 }.fetch(status, 31)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
# "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
|
|
@@ -23,6 +27,21 @@ module Samagotchi
|
|
|
23
27
|
paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
STEER_PREVIEW = 80
|
|
31
|
+
|
|
32
|
+
# "check-in> nudged: <text>", dim, one line cut: a plugin's steer
|
|
33
|
+
# (Steer) in the running turn or the join's last exchange.
|
|
34
|
+
def format_steer_line(source:, text:)
|
|
35
|
+
first = text.to_s.strip.split("\n").first.to_s
|
|
36
|
+
first = "#{first[0, STEER_PREVIEW - 1]}…" if first.length > STEER_PREVIEW || text.to_s.strip.include?("\n")
|
|
37
|
+
paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer.
|
|
41
|
+
def format_empty_retry_line(event)
|
|
42
|
+
paint("↻ empty answer, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
43
|
+
end
|
|
44
|
+
|
|
26
45
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
27
46
|
def format_tool_image_suffix(images)
|
|
28
47
|
refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
|
|
@@ -229,11 +248,11 @@ module Samagotchi
|
|
|
229
248
|
def format_session_metrics(snapshot)
|
|
230
249
|
return "(no metrics yet)" unless snapshot.is_a?(Hash)
|
|
231
250
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
token_src_label = case token_src
|
|
251
|
+
tokens = snapshot[:tokens] || {}
|
|
252
|
+
token_src_label = case tokens[:source]
|
|
235
253
|
when "server" then "server-reported"
|
|
236
254
|
when "estimate" then "estimated (chars/4)"
|
|
255
|
+
when "mixed" then "server-reported, some estimated"
|
|
237
256
|
else "n/a"
|
|
238
257
|
end
|
|
239
258
|
|
|
@@ -245,13 +264,17 @@ module Samagotchi
|
|
|
245
264
|
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
246
265
|
end
|
|
247
266
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
248
|
-
|
|
267
|
+
# Summed over every request: each prompt is sent in full again.
|
|
268
|
+
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
|
|
249
269
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
250
270
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
251
271
|
lines << "retries: #{snapshot[:retries]}"
|
|
252
|
-
|
|
253
|
-
|
|
272
|
+
context = snapshot[:context] || {}
|
|
273
|
+
if context[:used_tokens]
|
|
274
|
+
pct = context[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
|
|
275
|
+
lines << "context used: #{context[:used_tokens]} tokens#{pct}"
|
|
254
276
|
end
|
|
277
|
+
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
255
278
|
lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
|
|
256
279
|
if snapshot[:served_model]
|
|
257
280
|
asked = snapshot[:served_model_for]
|
|
@@ -240,7 +240,10 @@ module Samagotchi
|
|
|
240
240
|
nil
|
|
241
241
|
end
|
|
242
242
|
|
|
243
|
+
# A scratch session keeps nothing, its typed lines neither.
|
|
243
244
|
def persist_recent_history(input)
|
|
245
|
+
return if @scratch
|
|
246
|
+
|
|
244
247
|
entries = normalize_history_entries(load_history_entries_from_disk)
|
|
245
248
|
entries << input
|
|
246
249
|
trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
|
|
@@ -52,6 +52,9 @@ module Samagotchi
|
|
|
52
52
|
STATS_COMMAND = "/stats"
|
|
53
53
|
# /exit --delete: delete the session on the way out.
|
|
54
54
|
EXIT_DELETE_FLAG = "--delete"
|
|
55
|
+
# Leave, then archive the session (hidden from the lists, kept for good).
|
|
56
|
+
ARCHIVE_COMMAND = "/archive"
|
|
57
|
+
SCRATCH_ARCHIVE_REFUSED = "a scratch session is deleted when you leave; nothing to archive"
|
|
55
58
|
RECAP_COMMAND = "/recap"
|
|
56
59
|
# The prompt while a question waits for its answer (its choices are in
|
|
57
60
|
# the notes slot).
|
|
@@ -103,9 +106,12 @@ module Samagotchi
|
|
|
103
106
|
Engine.system_prompt_for(profile)
|
|
104
107
|
end
|
|
105
108
|
|
|
109
|
+
# @param scratch [Boolean] `chi scratch`: a new session that is deleted
|
|
110
|
+
# however the REPL ends, saves no memories and writes no recap
|
|
106
111
|
def initialize(mode: :assist, prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
107
|
-
spinner_tick_interval: THINKING_TICK_INTERVAL)
|
|
112
|
+
spinner_tick_interval: THINKING_TICK_INTERVAL, scratch: false)
|
|
108
113
|
@mode = mode.to_sym
|
|
114
|
+
@scratch = scratch
|
|
109
115
|
# nil: no ticker thread (specs that compare exact frames).
|
|
110
116
|
@spinner_tick_interval = spinner_tick_interval
|
|
111
117
|
@spinner_lock = Monitor.new
|
|
@@ -176,7 +182,8 @@ module Samagotchi
|
|
|
176
182
|
memories: @requested_memories,
|
|
177
183
|
muted_memories: @muted_memory_names,
|
|
178
184
|
kernel: @kernel,
|
|
179
|
-
recap: recap_config,
|
|
185
|
+
recap: scratch ? nil : recap_config,
|
|
186
|
+
scratch: scratch,
|
|
180
187
|
reminders: {
|
|
181
188
|
callback: lambda { |due_names|
|
|
182
189
|
# When a reminder is due, queue a synthetic turn that
|
|
@@ -242,15 +249,20 @@ module Samagotchi
|
|
|
242
249
|
model_name: @effective_model_name,
|
|
243
250
|
working_directory: Dir.pwd,
|
|
244
251
|
preloaded_memory_names: @requested_memories,
|
|
245
|
-
muted_memory_names: @muted_memory_names
|
|
252
|
+
muted_memory_names: @muted_memory_names,
|
|
253
|
+
scratch: @scratch
|
|
246
254
|
)
|
|
247
255
|
claim_session!(session.id) unless @owner_lock
|
|
256
|
+
# Saved at once, so a process killed before its first turn leaves a
|
|
257
|
+
# file the sweep knows to delete.
|
|
258
|
+
session.save if @scratch
|
|
248
259
|
# Attach before building the prompt so it can name the session id.
|
|
249
260
|
@engine.session = session
|
|
250
261
|
messages = messages_for(session)
|
|
251
262
|
|
|
252
263
|
if @prompt && @non_interactive
|
|
253
264
|
# Headless / CI mode: run directly without TTY rendering.
|
|
265
|
+
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
254
266
|
result = @engine.run_turn(
|
|
255
267
|
session,
|
|
256
268
|
@prompt,
|
|
@@ -259,6 +271,13 @@ module Samagotchi
|
|
|
259
271
|
cancel_controller: nil,
|
|
260
272
|
images: ImageInput.extract(@prompt)
|
|
261
273
|
)
|
|
274
|
+
if empty_answer?(result)
|
|
275
|
+
# Nothing to print: say so on stderr and fail, so a script that
|
|
276
|
+
# pipes the answer doesn't take the silence for one.
|
|
277
|
+
session.save
|
|
278
|
+
warn EMPTY_ANSWER_ERROR
|
|
279
|
+
exit 1
|
|
280
|
+
end
|
|
262
281
|
@surface.commit(result.output)
|
|
263
282
|
session.save
|
|
264
283
|
return
|
|
@@ -266,10 +285,14 @@ module Samagotchi
|
|
|
266
285
|
|
|
267
286
|
assist_loop(session: session, messages: messages)
|
|
268
287
|
# After the idle layer has stopped: nothing writes the session now.
|
|
269
|
-
if @
|
|
288
|
+
if @scratch
|
|
289
|
+
nil # deleted below, however the REPL ended
|
|
290
|
+
elsif @delete_on_exit
|
|
270
291
|
delete_after_exit(session)
|
|
271
292
|
elsif @discard_on_exit
|
|
272
293
|
discard_after_exit(session)
|
|
294
|
+
elsif @archive_on_exit
|
|
295
|
+
archive_after_exit(session)
|
|
273
296
|
else
|
|
274
297
|
keep_after_exit(session)
|
|
275
298
|
end
|
|
@@ -278,6 +301,32 @@ module Samagotchi
|
|
|
278
301
|
# #initialize): the anytime commands finish, the plugins' services
|
|
279
302
|
# stop (a server process).
|
|
280
303
|
@engine&.shutdown
|
|
304
|
+
# A scratch session goes on every way out: /exit, Ctrl-D, an error,
|
|
305
|
+
# SIGTERM or SIGHUP (Ruby raises those here). Only kill -9 leaves it
|
|
306
|
+
# to the sweep.
|
|
307
|
+
delete_scratch(session, quiet: @non_interactive || !$!.nil?) if @scratch && session
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
311
|
+
|
|
312
|
+
# A -p --non-interactive turn that ended with no answer (the empty-answer
|
|
313
|
+
# retries used up): no text, or the chat loop's placeholder.
|
|
314
|
+
def empty_answer?(result)
|
|
315
|
+
return false if result.respond_to?(:canceled?) && result.canceled?
|
|
316
|
+
return false if result.respond_to?(:resumable?) && result.resumable?
|
|
317
|
+
|
|
318
|
+
result.output.to_s.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
322
|
+
# a one-shot or on the way out of an error or a signal.
|
|
323
|
+
def delete_scratch(session, quiet:)
|
|
324
|
+
@owner_lock&.release
|
|
325
|
+
@owner_lock = nil
|
|
326
|
+
SessionManager.delete_session(session.id)
|
|
327
|
+
@surface.commit("Scratch session deleted.") unless quiet
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
warn "Scratch session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}"
|
|
281
330
|
end
|
|
282
331
|
|
|
283
332
|
# The session stays: the recap first, then the resume line, last.
|
|
@@ -330,6 +379,17 @@ module Samagotchi
|
|
|
330
379
|
@surface.commit("Session #{session.id} was not deleted (#{e.message}): chi sessions delete #{session.id}")
|
|
331
380
|
end
|
|
332
381
|
|
|
382
|
+
# /archive: give up the session, then archive it (hidden from the lists,
|
|
383
|
+
# kept for good). An empty one is discarded instead (#discard_on_exit?).
|
|
384
|
+
def archive_after_exit(session)
|
|
385
|
+
@owner_lock&.release
|
|
386
|
+
@owner_lock = nil
|
|
387
|
+
SessionManager.archive_session(session.id)
|
|
388
|
+
@surface.commit("Archived session #{session.id}. chi sessions list --archived finds it.")
|
|
389
|
+
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError, SystemCallError => e
|
|
390
|
+
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
391
|
+
end
|
|
392
|
+
|
|
333
393
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
334
394
|
# its own Engine, so no worker may run the session meanwhile.
|
|
335
395
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -356,6 +416,9 @@ module Samagotchi
|
|
|
356
416
|
@surface.commit("Resumed session: #{session.id}")
|
|
357
417
|
saved = @engine.saved_recap
|
|
358
418
|
@surface.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
|
|
419
|
+
elsif @scratch
|
|
420
|
+
messages = [system_message]
|
|
421
|
+
@surface.commit("Scratch session: nothing is kept, it is deleted when you leave.")
|
|
359
422
|
else
|
|
360
423
|
messages = [system_message]
|
|
361
424
|
@surface.commit("Session: #{session.id}")
|
|
@@ -500,6 +563,12 @@ module Samagotchi
|
|
|
500
563
|
@delete_on_exit ||= delete_on_exit?(input)
|
|
501
564
|
break
|
|
502
565
|
end
|
|
566
|
+
if archive_command?(input)
|
|
567
|
+
next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
|
|
568
|
+
|
|
569
|
+
@archive_on_exit = true
|
|
570
|
+
break
|
|
571
|
+
end
|
|
503
572
|
# Not an answer to a continue offer either.
|
|
504
573
|
next detach_note if detach_command?(input)
|
|
505
574
|
|
|
@@ -1001,6 +1070,8 @@ module Samagotchi
|
|
|
1001
1070
|
|
|
1002
1071
|
def detach_command?(input) = input.to_s.strip.casecmp?("/detach")
|
|
1003
1072
|
|
|
1073
|
+
def archive_command?(input) = input.to_s.strip.casecmp?(ARCHIVE_COMMAND)
|
|
1074
|
+
|
|
1004
1075
|
def clone_messages(messages)
|
|
1005
1076
|
Array(messages).map(&:dup)
|
|
1006
1077
|
end
|
|
@@ -1024,6 +1095,8 @@ module Samagotchi
|
|
|
1024
1095
|
# Build the (memoized) prompt now so --memory activations show in this
|
|
1025
1096
|
# turn's status lines, including after /model rebuilt it.
|
|
1026
1097
|
seed_system_prompt
|
|
1098
|
+
# A prompt the user typed brings an archived session back to the lists.
|
|
1099
|
+
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
|
|
1027
1100
|
result = with_steering do
|
|
1028
1101
|
@engine.run_turn(
|
|
1029
1102
|
session,
|
|
@@ -65,11 +65,13 @@ module Samagotchi
|
|
|
65
65
|
|
|
66
66
|
# "error" for an "Error:" result, and for an execute whose command
|
|
67
67
|
# exited non-zero (its last "exit: N" line; no number: killed by a
|
|
68
|
-
# signal), so the turn tally's "(N failed)" counts it.
|
|
68
|
+
# signal), so the turn tally's "(N failed)" counts it. "stopped" for a
|
|
69
|
+
# task_wait the user's Stop ended: the task itself runs on.
|
|
69
70
|
def tool_activity_status(result, tool_name = nil)
|
|
70
71
|
text = result.to_s
|
|
71
72
|
return "error" if text.start_with?("Error:")
|
|
72
73
|
return "error" if tool_name == Tools::Execute::NAME && execute_failed?(text)
|
|
74
|
+
return "stopped" if tool_name == Tools::TaskWait::NAME && text.match?(/^wait_result: canceled$/)
|
|
73
75
|
|
|
74
76
|
"ok"
|
|
75
77
|
end
|
|
@@ -68,12 +68,16 @@ module Samagotchi
|
|
|
68
68
|
Edit::NAME => lambda do |call, _kctx|
|
|
69
69
|
Edit.call(call[:content], path: call[:path], start_line: call[:start_line], end_line: call[:end_line])
|
|
70
70
|
end,
|
|
71
|
-
TaskCreate::NAME =>
|
|
72
|
-
|
|
71
|
+
TaskCreate::NAME => lambda do |call, kctx|
|
|
72
|
+
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|
|
73
|
+
|
|
74
|
+
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env])
|
|
75
|
+
end,
|
|
76
|
+
TaskWait::NAME => lambda do |call, kctx|
|
|
73
77
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
74
|
-
done_pattern: call[:done_pattern])
|
|
78
|
+
done_pattern: call[:done_pattern], cancelled: cancelled_proc(kctx))
|
|
75
79
|
end,
|
|
76
|
-
Execute::NAME => ->(call,
|
|
80
|
+
Execute::NAME => ->(call, kctx) { Execute.call(call[:content], cwd: call[:cwd], cancelled: cancelled_proc(kctx)) },
|
|
77
81
|
RegisterReminder::NAME => lambda do |call, kctx|
|
|
78
82
|
RegisterReminder.call(call[:content], reminder_store: kctx.reminder_store, description: call[:description],
|
|
79
83
|
interval_minutes: call[:interval_minutes])
|
|
@@ -94,6 +98,13 @@ module Samagotchi
|
|
|
94
98
|
|
|
95
99
|
module_function
|
|
96
100
|
|
|
101
|
+
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
102
|
+
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
103
|
+
def cancelled_proc(kctx)
|
|
104
|
+
peers = kctx.peers
|
|
105
|
+
-> { peers.respond_to?(:cancelled?) && peers.cancelled? }
|
|
106
|
+
end
|
|
107
|
+
|
|
97
108
|
# @return [Registry] a new registry with the built-ins, in
|
|
98
109
|
# TOOL_SCHEMAS order (an Engine's own, which bundles add to)
|
|
99
110
|
def registry
|
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../session"
|
|
4
|
+
require_relative "../reply_wait"
|
|
4
5
|
require_relative "output_guardrails"
|
|
5
6
|
require_relative "peers"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
|
-
# Loaded on first use: session_manager requires terminal_ui, which
|
|
9
|
-
# requires KernelLoop and so these tools (a require cycle otherwise).
|
|
10
|
-
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
11
|
-
|
|
12
9
|
module Tools
|
|
13
10
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
14
|
-
# delegate_result
|
|
15
|
-
# (the
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# worker respawn loses them, which only repeats the newest reply once.
|
|
11
|
+
# delegate_result: ReplyWait in the tools' words, with a cursor per
|
|
12
|
+
# child (the newest reply this parent was already given). The cursors
|
|
13
|
+
# live in this process (a worker runs one session), keyed by parent and
|
|
14
|
+
# child; a worker respawn loses them, which only repeats the newest reply
|
|
15
|
+
# once.
|
|
20
16
|
module DelegateWait
|
|
21
17
|
TIMEOUT_DEFAULT = 600
|
|
22
18
|
POLL_INTERVAL = 0.5
|
|
@@ -40,70 +36,34 @@ module Samagotchi
|
|
|
40
36
|
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
|
|
41
37
|
sd = peers.state_dir || Session.default_state_dir
|
|
42
38
|
key = [peers.session_id, child_id]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (pending = session.pending_question)
|
|
63
|
-
return result(child_id, "waiting_for_answer", waiting_text(child_id, pending))
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
if peers.respond_to?(:cancelled?) && peers.cancelled?
|
|
67
|
-
return result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
if session.status == Session::STATUS_RUNNING
|
|
71
|
-
seen_running = true
|
|
72
|
-
elsif seen_running
|
|
73
|
-
# It ran and is idle again with no new reply: canceled, failed or
|
|
74
|
-
# empty. Before it was ever seen running, idle means a
|
|
75
|
-
# file-delivered follow-up its worker has not picked up yet.
|
|
76
|
-
return result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
return timeout_result(child_id, timeout) if monotonic > deadline
|
|
80
|
-
|
|
81
|
-
sleep(poll_interval)
|
|
39
|
+
cancelled = -> { peers.respond_to?(:cancelled?) && peers.cancelled? }
|
|
40
|
+
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
|
|
41
|
+
poll_interval: poll_interval, cancelled: cancelled)
|
|
42
|
+
case wait.status
|
|
43
|
+
when :done
|
|
44
|
+
seen[key] = wait.file
|
|
45
|
+
reply_result(child_id, wait.text)
|
|
46
|
+
when :error
|
|
47
|
+
result(child_id, "error", "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
48
|
+
when :stopped
|
|
49
|
+
result(child_id, "stopped", "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
50
|
+
when :waiting_for_answer
|
|
51
|
+
result(child_id, "waiting_for_answer", waiting_text(child_id, wait.question))
|
|
52
|
+
when :canceled
|
|
53
|
+
result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
54
|
+
when :no_reply
|
|
55
|
+
result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
56
|
+
else
|
|
57
|
+
timeout_result(child_id, timeout)
|
|
82
58
|
end
|
|
83
59
|
rescue ArgumentError => e
|
|
84
60
|
"Error: #{e.message}"
|
|
85
61
|
end
|
|
86
62
|
|
|
87
|
-
# @return [String, nil] the newest reply filename (sortable timestamps)
|
|
88
|
-
def newest_reply(child_id, state_dir:)
|
|
89
|
-
dir = reply_dir(child_id, state_dir: state_dir)
|
|
90
|
-
return nil unless Dir.exist?(dir)
|
|
91
|
-
|
|
92
|
-
Dir.children(dir).select { |f| f.end_with?(".txt") }.max
|
|
93
|
-
end
|
|
94
|
-
|
|
95
63
|
# Point the cursor at the newest reply now, so only a later one counts
|
|
96
64
|
# (a follow-up sent to a child that already answered).
|
|
97
65
|
def mark_seen(parent_id, child_id, state_dir:)
|
|
98
|
-
seen[[parent_id, child_id]] = newest_reply(child_id, state_dir: state_dir)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def reply_dir(child_id, state_dir:)
|
|
102
|
-
File.join(Session.session_dir(child_id, state_dir: state_dir), SessionManager::OUTPUT_DIR)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def newer?(file, cursor)
|
|
106
|
-
cursor.nil? || file > cursor
|
|
66
|
+
seen[[parent_id, child_id]] = ReplyWait.newest_reply(child_id, state_dir: state_dir)
|
|
107
67
|
end
|
|
108
68
|
|
|
109
69
|
def reply_result(child_id, text)
|
|
@@ -145,9 +105,6 @@ module Samagotchi
|
|
|
145
105
|
].join("\n")
|
|
146
106
|
end
|
|
147
107
|
|
|
148
|
-
def monotonic
|
|
149
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
150
|
-
end
|
|
151
108
|
end
|
|
152
109
|
end
|
|
153
110
|
end
|