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
|
@@ -18,16 +18,22 @@ module Samagotchi
|
|
|
18
18
|
TIMEOUT_SEC = 30
|
|
19
19
|
STOP_GRACE_SEC = 1.0
|
|
20
20
|
STOP_POLL_INTERVAL_SEC = 0.05
|
|
21
|
+
WAIT_SLICE_SEC = 0.2
|
|
22
|
+
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
21
23
|
|
|
22
24
|
def self.name = NAME
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
# @param cancelled [#call] true once the user stopped the turn: the
|
|
27
|
+
# command is killed (or not started) and its output so far returned
|
|
28
|
+
def self.call(command, cwd: nil, cancelled: -> { false })
|
|
29
|
+
return NOT_RUN_ON_STOP if cancelled.call
|
|
30
|
+
|
|
25
31
|
command = command.strip
|
|
26
32
|
resolved_cwd = resolve_cwd(cwd)
|
|
27
33
|
return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
|
|
28
34
|
|
|
29
35
|
timeout_sec = timeout_seconds
|
|
30
|
-
stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd)
|
|
36
|
+
stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled)
|
|
31
37
|
|
|
32
38
|
stdout_block = output_block("stdout", stdout)
|
|
33
39
|
stderr_block = output_block("stderr", stderr)
|
|
@@ -45,19 +51,29 @@ module Samagotchi
|
|
|
45
51
|
rescue CommandTimedOut => e
|
|
46
52
|
# Keep the "Error:" first line (callers classify on it) and return
|
|
47
53
|
# whatever the command printed before it was killed.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
parts.compact.join("\n")
|
|
54
|
+
stopped_result("Error: command timed out after #{timeout_sec}s", e)
|
|
55
|
+
rescue CommandCancelled => e
|
|
56
|
+
stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
52
57
|
rescue => e
|
|
53
58
|
"Error: #{e.message}"
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
def self.
|
|
61
|
+
def self.stopped_result(first_line, error)
|
|
62
|
+
parts = [first_line]
|
|
63
|
+
parts << output_block("stdout", error.stdout)
|
|
64
|
+
parts << output_block("stderr", error.stderr)
|
|
65
|
+
parts.compact.join("\n")
|
|
66
|
+
end
|
|
67
|
+
private_class_method :stopped_result
|
|
68
|
+
|
|
69
|
+
def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
|
|
57
70
|
stdout_text = ""
|
|
58
71
|
stderr_text = ""
|
|
59
72
|
status = nil
|
|
60
73
|
timed_out = false
|
|
74
|
+
was_cancelled = false
|
|
75
|
+
started = monotonic_time
|
|
76
|
+
deadline = started + timeout_sec
|
|
61
77
|
|
|
62
78
|
Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
63
79
|
stdin.close
|
|
@@ -65,14 +81,24 @@ module Samagotchi
|
|
|
65
81
|
stderr_reader = reader_thread_for(stderr)
|
|
66
82
|
|
|
67
83
|
begin
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
until wait_thr.join(WAIT_SLICE_SEC)
|
|
85
|
+
if cancelled.call
|
|
86
|
+
was_cancelled = true
|
|
87
|
+
break
|
|
88
|
+
elsif monotonic_time > deadline
|
|
89
|
+
timed_out = true
|
|
90
|
+
break
|
|
91
|
+
end
|
|
75
92
|
end
|
|
93
|
+
terminate_process_tree(wait_thr.pid) if timed_out || was_cancelled
|
|
94
|
+
wait_thr.join
|
|
95
|
+
status = wait_thr.value
|
|
96
|
+
rescue Exception # rubocop:disable Lint/RescueException -- kill the child, then re-raise
|
|
97
|
+
# An Interrupt (or anything else) mid-wait: the child is in its own
|
|
98
|
+
# process group, so it never saw the SIGINT. Kill it, or the reads
|
|
99
|
+
# below block until it closes stdout.
|
|
100
|
+
terminate_process_tree(wait_thr.pid)
|
|
101
|
+
raise
|
|
76
102
|
ensure
|
|
77
103
|
# Read the buffered output before closing the pipes. For a process
|
|
78
104
|
# that exits almost instantly (e.g. `echo hello`), the background
|
|
@@ -86,6 +112,7 @@ module Samagotchi
|
|
|
86
112
|
end
|
|
87
113
|
|
|
88
114
|
raise CommandTimedOut.new(stdout_text, stderr_text) if timed_out
|
|
115
|
+
raise CommandCancelled.new(stdout_text, stderr_text, monotonic_time - started) if was_cancelled
|
|
89
116
|
|
|
90
117
|
[stdout_text, stderr_text, status]
|
|
91
118
|
end
|
|
@@ -209,6 +236,17 @@ module Samagotchi
|
|
|
209
236
|
super("command timed out")
|
|
210
237
|
end
|
|
211
238
|
end
|
|
239
|
+
|
|
240
|
+
class CommandCancelled < StandardError
|
|
241
|
+
attr_reader :stdout, :stderr, :elapsed
|
|
242
|
+
|
|
243
|
+
def initialize(stdout, stderr, elapsed)
|
|
244
|
+
@stdout = stdout
|
|
245
|
+
@stderr = stderr
|
|
246
|
+
@elapsed = elapsed
|
|
247
|
+
super("command stopped by the user")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
212
250
|
end
|
|
213
251
|
end
|
|
214
252
|
end
|
|
@@ -94,6 +94,25 @@ module Samagotchi
|
|
|
94
94
|
end.compact.sort_by { |record| record["created_at"].to_s }.reverse
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# The tasks this conversation's task_create calls started that are
|
|
98
|
+
# still running. Only task_create results count: records are shared by
|
|
99
|
+
# every session in the worker's cwd, and task_list shows them all.
|
|
100
|
+
# A native tool_response joins its calls' results with "---".
|
|
101
|
+
# @return [Array<Hash>] {id:, command:}, oldest first
|
|
102
|
+
def running_created_in(messages)
|
|
103
|
+
ids = Array(messages).flat_map do |message|
|
|
104
|
+
next [] unless (message[:role] || message["role"]).to_s == "tool_response"
|
|
105
|
+
|
|
106
|
+
(message[:content] || message["content"]).to_s.split("\n\n---\n\n").filter_map do |block|
|
|
107
|
+
block[/\A\[task_create\]\ntask_id: (\S+)/, 1]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
ids.uniq.filter_map do |id|
|
|
111
|
+
record, _error = get_record(id)
|
|
112
|
+
{ id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
97
116
|
def get_record(task_id)
|
|
98
117
|
record = load_record(task_id)
|
|
99
118
|
return [nil, "Error: task not found: #{task_id}"] unless record
|
|
@@ -14,7 +14,10 @@ module Samagotchi
|
|
|
14
14
|
|
|
15
15
|
def self.name = NAME
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# @param cancelled [#call] true once the user stopped the turn: the
|
|
18
|
+
# wait returns then, and the task keeps running
|
|
19
|
+
def self.call(task_id, timeout: TIMEOUT_DEFAULT, tail_lines: TAIL_LINES_DEFAULT, done_pattern: nil,
|
|
20
|
+
cancelled: -> { false })
|
|
18
21
|
timeout = TIMEOUT_DEFAULT if blank?(timeout)
|
|
19
22
|
tail_lines = TAIL_LINES_DEFAULT if blank?(tail_lines)
|
|
20
23
|
timeout = timeout.to_i
|
|
@@ -27,7 +30,8 @@ module Samagotchi
|
|
|
27
30
|
return completion_pattern if completion_pattern.is_a?(String)
|
|
28
31
|
|
|
29
32
|
task_id = task_id.to_s.strip
|
|
30
|
-
|
|
33
|
+
started = monotonic_time
|
|
34
|
+
deadline = started + timeout
|
|
31
35
|
|
|
32
36
|
loop do
|
|
33
37
|
record, error = TaskRuntime.get_record(task_id)
|
|
@@ -41,6 +45,8 @@ module Samagotchi
|
|
|
41
45
|
return format_response(record, wait_result: "pattern_matched", output_tail: output_tail) if completion_pattern.match?(output_tail)
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
return cancelled_response(task_id, tail_lines, started) if cancelled.call
|
|
49
|
+
|
|
44
50
|
break if monotonic_time > deadline
|
|
45
51
|
sleep(POLL_INTERVAL)
|
|
46
52
|
end
|
|
@@ -55,7 +61,24 @@ module Samagotchi
|
|
|
55
61
|
"Error: #{e.message}"
|
|
56
62
|
end
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
# The task may have finished between the poll and the Stop: then the
|
|
65
|
+
# normal finished result. Only a task still running says so.
|
|
66
|
+
def self.cancelled_response(task_id, tail_lines, started)
|
|
67
|
+
record, error = TaskRuntime.get_record(task_id)
|
|
68
|
+
return "Error: #{error}" if error
|
|
69
|
+
return format_response(record) unless record.fetch("status") == "running"
|
|
70
|
+
|
|
71
|
+
output_tail = TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
|
|
72
|
+
note = [
|
|
73
|
+
"note: the user stopped the turn while waiting; the task is STILL RUNNING.",
|
|
74
|
+
" To keep waiting: task_wait #{task_id}. If it is stuck or no longer needed: task_stop #{task_id}.",
|
|
75
|
+
"waited: #{(monotonic_time - started).round}s"
|
|
76
|
+
]
|
|
77
|
+
format_response(record, wait_result: "canceled", output_tail: output_tail, extra: note)
|
|
78
|
+
end
|
|
79
|
+
private_class_method :cancelled_response
|
|
80
|
+
|
|
81
|
+
def self.format_response(record, wait_result: nil, output_tail: nil, extra: [])
|
|
59
82
|
lines = [
|
|
60
83
|
"task_id: #{record.fetch("id")}",
|
|
61
84
|
"status: #{record.fetch("status")}",
|
|
@@ -66,6 +89,7 @@ module Samagotchi
|
|
|
66
89
|
return lines.join("\n") unless wait_result
|
|
67
90
|
|
|
68
91
|
lines << "wait_result: #{wait_result}"
|
|
92
|
+
lines.concat(extra)
|
|
69
93
|
lines << "output_tail:"
|
|
70
94
|
lines << output_tail.rstrip unless output_tail.empty?
|
|
71
95
|
lines.join("\n")
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Samagotchi
|
|
|
13
13
|
KIND = "turn_note"
|
|
14
14
|
OPEN = "[SYSTEM: "
|
|
15
15
|
CLOSE = "]"
|
|
16
|
+
FAILED = "the previous turn failed before any answer: "
|
|
17
|
+
RESTORED = "The message went back to the user, who may send it again."
|
|
18
|
+
TASKS_LISTED = 5
|
|
19
|
+
TASK_COMMAND_CHARS = 60
|
|
16
20
|
|
|
17
21
|
module_function
|
|
18
22
|
|
|
@@ -21,28 +25,48 @@ module Samagotchi
|
|
|
21
25
|
# failed user message is not in the conversation any more)
|
|
22
26
|
# @param continued [Boolean] it was a continue turn (no user message)
|
|
23
27
|
def failed(summary, restored: false, continued: false)
|
|
24
|
-
tail = if restored then
|
|
28
|
+
tail = if restored then RESTORED
|
|
25
29
|
elsif continued then "The continued turn stopped there."
|
|
26
30
|
else "The user's last message was not answered."
|
|
27
31
|
end
|
|
28
|
-
message("
|
|
32
|
+
message("#{FAILED}#{one_line(summary)}. #{tail}")
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
# @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
|
|
32
36
|
# @param seconds [Numeric, nil] how long the turn had run
|
|
33
37
|
# @param shown [Boolean] visible text had streamed (an `[interrupted]`
|
|
34
38
|
# model message precedes this note)
|
|
35
|
-
|
|
39
|
+
# @param running_tasks [Array<Hash>] {id:, command:} of this session's
|
|
40
|
+
# background tasks still running: a Stop doesn't end them
|
|
41
|
+
def cancelled(reason, seconds: nil, shown: false, running_tasks: [])
|
|
36
42
|
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
|
|
37
43
|
after = seconds ? " after #{seconds.round}s" : ""
|
|
38
44
|
what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
|
|
39
|
-
message("the previous turn was cancelled#{why}#{after}; #{what}")
|
|
45
|
+
message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def still_running(tasks)
|
|
49
|
+
return "" if tasks.empty?
|
|
50
|
+
|
|
51
|
+
listed = tasks.first(TASKS_LISTED).map do |task|
|
|
52
|
+
command = one_line(task[:command])
|
|
53
|
+
command = "#{command[0, TASK_COMMAND_CHARS - 1]}…" if command.length > TASK_COMMAND_CHARS
|
|
54
|
+
"task #{task[:id]} (#{command})"
|
|
55
|
+
end
|
|
56
|
+
listed << "#{tasks.size - TASKS_LISTED} more (task_list)" if tasks.size > TASKS_LISTED
|
|
57
|
+
" Still running: #{listed.join(", ")}. Continue with task_wait <id> or stop with task_stop <id>."
|
|
40
58
|
end
|
|
41
59
|
|
|
42
60
|
def empty
|
|
43
61
|
message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
|
|
44
62
|
end
|
|
45
63
|
|
|
64
|
+
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|
|
65
|
+
# the model never sees its empty generation, so it is told what happened.
|
|
66
|
+
def empty_retry
|
|
67
|
+
message("your last reply had no visible answer. Answer the user's last message now, briefly.")
|
|
68
|
+
end
|
|
69
|
+
|
|
46
70
|
def note?(entry)
|
|
47
71
|
return false unless entry.respond_to?(:[])
|
|
48
72
|
|
|
@@ -56,11 +80,41 @@ module Samagotchi
|
|
|
56
80
|
# +messages+ plus +note+, in place of a note already at the tail (behind
|
|
57
81
|
# context notes at most): failed retries leave one note, not a pile.
|
|
58
82
|
def replace_trailing(messages, note)
|
|
83
|
+
without_trailing(messages) << note
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A copy of +messages+ without the note at its tail (behind context
|
|
87
|
+
# notes at most).
|
|
88
|
+
def without_trailing(messages)
|
|
59
89
|
list = Array(messages).dup
|
|
90
|
+
index = trailing_index(list)
|
|
91
|
+
list.delete_at(index) if index
|
|
92
|
+
list
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The index of the note at the tail of +list+ (behind context notes at
|
|
96
|
+
# most), or nil.
|
|
97
|
+
def trailing_index(list)
|
|
60
98
|
index = list.length - 1
|
|
61
99
|
index -= 1 while index >= 0 && (list[index][:role] || list[index]["role"]).to_s == "system" && !note?(list[index])
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
index >= 0 && note?(list[index]) ? index : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The failure summary of the note at the tail of +messages+ (behind
|
|
104
|
+
# context notes at most) when it says a failed turn's prompt went back
|
|
105
|
+
# to the user, else nil: the prompt is not in the conversation any more
|
|
106
|
+
# (the session's last_prompt holds it), so a UI shows it from here.
|
|
107
|
+
def restored_failure(messages)
|
|
108
|
+
list = Array(messages)
|
|
109
|
+
index = trailing_index(list)
|
|
110
|
+
return nil unless index
|
|
111
|
+
|
|
112
|
+
text = (list[index][:content] || list[index]["content"]).to_s
|
|
113
|
+
prefix = "#{OPEN}#{FAILED}"
|
|
114
|
+
suffix = ". #{RESTORED}#{CLOSE}"
|
|
115
|
+
return nil unless text.start_with?(prefix) && text.end_with?(suffix)
|
|
116
|
+
|
|
117
|
+
text[prefix.length...-suffix.length]
|
|
64
118
|
end
|
|
65
119
|
|
|
66
120
|
# The last message is a reply cut short (`[interrupted]`).
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -50,12 +50,8 @@ module Samagotchi
|
|
|
50
50
|
rescue StandardError
|
|
51
51
|
{}
|
|
52
52
|
end
|
|
53
|
-
[target.model, target.bare_model]
|
|
54
|
-
|
|
55
|
-
next if value.nil?
|
|
56
|
-
|
|
57
|
-
return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false")
|
|
58
|
-
end
|
|
53
|
+
key, value = ConfigFile.model_setting([target.model, target.bare_model], :vision, models: models)
|
|
54
|
+
return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false") if key
|
|
59
55
|
return nil if entry.vision.nil?
|
|
60
56
|
|
|
61
57
|
Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -13,10 +13,12 @@ require_relative "../bridge/bounded_queue"
|
|
|
13
13
|
require_relative "../session"
|
|
14
14
|
require_relative "../session_manager"
|
|
15
15
|
require_relative "../session_commands"
|
|
16
|
+
require_relative "../steer"
|
|
16
17
|
require_relative "../host_registry"
|
|
17
18
|
require_relative "../model_profile"
|
|
18
19
|
require_relative "../project_scope"
|
|
19
20
|
require_relative "../version"
|
|
21
|
+
require_relative "../config"
|
|
20
22
|
require_relative "../output_formatter"
|
|
21
23
|
require_relative "../image_store"
|
|
22
24
|
require_relative "../context_note"
|
|
@@ -71,6 +73,9 @@ module Samagotchi
|
|
|
71
73
|
# @param turn_view [Boolean] the page's per-turn view (web.turn_view,
|
|
72
74
|
# the default); false is the classic row of bubbles; ?view=turn|chat
|
|
73
75
|
# overrides it for one page load
|
|
76
|
+
# @param annotate_presets [String, Array] the quick replies next to
|
|
77
|
+
# Annotate (web.annotate_presets, "|"-separated); "" shows none. The
|
|
78
|
+
# page parses them (annotate_presets.js).
|
|
74
79
|
# @param hub [SessionHub, nil] the session projection GET /api/events
|
|
75
80
|
# streams from; without one the route answers 503
|
|
76
81
|
# @param registry [HostRegistry, nil] the hosts GET /api/models lists
|
|
@@ -79,6 +84,7 @@ module Samagotchi
|
|
|
79
84
|
# waits for the hosts' lists
|
|
80
85
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
81
86
|
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, turn_view: true, hub: nil,
|
|
87
|
+
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
82
88
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
83
89
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT)
|
|
84
90
|
@manager = manager || SessionManager
|
|
@@ -92,6 +98,7 @@ module Samagotchi
|
|
|
92
98
|
@bridge_wait_timeout = bridge_wait_timeout
|
|
93
99
|
@markdown_renderer = MarkdownRenderer.new(enabled: markdown)
|
|
94
100
|
@turn_view = turn_view
|
|
101
|
+
@annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
|
|
95
102
|
@hub = hub
|
|
96
103
|
@events_heartbeat = events_heartbeat
|
|
97
104
|
@events_queue = events_queue
|
|
@@ -138,6 +145,9 @@ module Samagotchi
|
|
|
138
145
|
if (m = %r{\A/api/sessions/([^/]+)/stop\z}.match(req.path_info)) && req.post?
|
|
139
146
|
return handle_stop(req, m[1])
|
|
140
147
|
end
|
|
148
|
+
if (m = %r{\A/api/sessions/([^/]+)/(archive|unarchive)\z}.match(req.path_info)) && req.post?
|
|
149
|
+
return m[2] == "archive" ? handle_archive(req, m[1]) : handle_unarchive(req, m[1])
|
|
150
|
+
end
|
|
141
151
|
if (m = %r{\A/api/sessions/([^/]+)/turn\z}.match(req.path_info)) && req.post?
|
|
142
152
|
return handle_turn(req, m[1])
|
|
143
153
|
end
|
|
@@ -224,6 +234,8 @@ module Samagotchi
|
|
|
224
234
|
offset = sanitize_offset(req.params["offset"])
|
|
225
235
|
return list_from_hub(root, sort: sort, order: order, limit: limit, offset: offset) if @hub
|
|
226
236
|
|
|
237
|
+
# Archived ones too, as the hub's: the page filters them at render.
|
|
238
|
+
scope[:include_archived] = true
|
|
227
239
|
sessions = if @state_dir
|
|
228
240
|
@manager.list_sessions(state_dir: @state_dir, sort: sort, order: order, limit: limit, offset: offset,
|
|
229
241
|
**scope)
|
|
@@ -396,6 +408,11 @@ module Samagotchi
|
|
|
396
408
|
# spelled as GET /api/models lists it); blank means the default.
|
|
397
409
|
model = (body["model"] || body["model_name"]).to_s.strip
|
|
398
410
|
folder[:model_name] = model unless model.empty?
|
|
411
|
+
# preview: an idle session's first message, sent as its first turn
|
|
412
|
+
# next; the list and the notifications name the session by it
|
|
413
|
+
# until that turn is saved.
|
|
414
|
+
preview = idle ? body["preview"].to_s.strip : ""
|
|
415
|
+
folder[:title] = preview unless preview.empty?
|
|
399
416
|
begin
|
|
400
417
|
session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
|
|
401
418
|
rescue ArgumentError => e
|
|
@@ -464,6 +481,7 @@ module Samagotchi
|
|
|
464
481
|
session: session_json,
|
|
465
482
|
history: read_history(id),
|
|
466
483
|
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1"),
|
|
484
|
+
failed_turn: failed_turn_for(session, raw_messages),
|
|
467
485
|
current_turn: current_turn,
|
|
468
486
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
469
487
|
recap: turn_snapshot && turn_snapshot["recap"],
|
|
@@ -597,7 +615,8 @@ module Samagotchi
|
|
|
597
615
|
client = live_bridge_client(id)
|
|
598
616
|
return error_response(503, "not_live", "no live bridge for session #{id}") unless client
|
|
599
617
|
|
|
600
|
-
|
|
618
|
+
# card: a card's action; its command events say so (no echo).
|
|
619
|
+
reply = client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true)
|
|
601
620
|
case reply.status
|
|
602
621
|
when 202 then json_response(202, reply.json || { status: "accepted" })
|
|
603
622
|
when 400 then error_response(400, reply.json&.dig("error") || "unknown_command", reply.json&.dig("detail") || "not a session command")
|
|
@@ -805,6 +824,32 @@ module Samagotchi
|
|
|
805
824
|
error_response(404, "not_found", e.message)
|
|
806
825
|
end
|
|
807
826
|
|
|
827
|
+
# POST /api/sessions/:id/archive: hide it (and its delegates) from the
|
|
828
|
+
# lists, keep it for good. A live idle worker is stopped first; a turn
|
|
829
|
+
# running in it or a delegate, a chi REPL's session and a scratch one
|
|
830
|
+
# are refused (409).
|
|
831
|
+
def handle_archive(_req, id)
|
|
832
|
+
result = @manager.archive_session(id, state_dir: @state_dir, wait: STOP_WAIT_SECONDS)
|
|
833
|
+
(result[:archived] + result[:discarded]).each { |sid| @hub&.touch(sid) }
|
|
834
|
+
json_response(200, { status: "archived", session_id: result[:id], archived: result[:archived],
|
|
835
|
+
stopped: result[:stopped], discarded: result[:discarded] })
|
|
836
|
+
rescue SessionManager::OwnedByTUI
|
|
837
|
+
error_response(409, "owned_by_tui", "session #{id} is open in a chi REPL; close it there first")
|
|
838
|
+
rescue SessionManager::ArchiveRefused => e
|
|
839
|
+
error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)
|
|
840
|
+
rescue ArgumentError => e
|
|
841
|
+
error_response(404, "not_found", e.message)
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# POST /api/sessions/:id/unarchive: back to the lists, delegates too.
|
|
845
|
+
def handle_unarchive(_req, id)
|
|
846
|
+
result = @manager.unarchive_session(id, state_dir: @state_dir)
|
|
847
|
+
result[:unarchived].each { |sid| @hub&.touch(sid) }
|
|
848
|
+
json_response(200, { status: "unarchived", session_id: result[:id], unarchived: result[:unarchived] })
|
|
849
|
+
rescue ArgumentError => e
|
|
850
|
+
error_response(404, "not_found", e.message)
|
|
851
|
+
end
|
|
852
|
+
|
|
808
853
|
# DELETE /api/sessions/:id: the session for good. A live worker is
|
|
809
854
|
# stopped first (the page's confirm says so); a chi REPL's session is
|
|
810
855
|
# refused.
|
|
@@ -1038,7 +1083,11 @@ module Samagotchi
|
|
|
1038
1083
|
turn_records: Array(source["turn_records"]),
|
|
1039
1084
|
tool_records: Array(source["tool_records"]),
|
|
1040
1085
|
active_turn: source["active_turn"],
|
|
1041
|
-
active_tools: Array(source["active_tools"])
|
|
1086
|
+
active_tools: Array(source["active_tools"]),
|
|
1087
|
+
# The context last seen and the token sums, for the ctx meter
|
|
1088
|
+
# before the next turn streams (live over saved).
|
|
1089
|
+
context: source["context"],
|
|
1090
|
+
tokens: source["tokens"]
|
|
1042
1091
|
}
|
|
1043
1092
|
end
|
|
1044
1093
|
|
|
@@ -1084,12 +1133,19 @@ module Samagotchi
|
|
|
1084
1133
|
return nil unless saved
|
|
1085
1134
|
|
|
1086
1135
|
messages = Array(session.messages)
|
|
1087
|
-
since = messages.drop(saved[:covered].to_i).count { |m| (m
|
|
1136
|
+
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.prompt?(m) }
|
|
1088
1137
|
{ text: saved[:text], turns_since: since }
|
|
1089
1138
|
rescue StandardError
|
|
1090
1139
|
nil
|
|
1091
1140
|
end
|
|
1092
1141
|
|
|
1142
|
+
# The step (iteration) that answered the steer at +index+: one past
|
|
1143
|
+
# the model messages between it and the prompt before it.
|
|
1144
|
+
def steer_step(list, index)
|
|
1145
|
+
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.prompt?(m) }
|
|
1146
|
+
.count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1093
1149
|
def read_history(id)
|
|
1094
1150
|
raw = @manager.read_responses(id, since_time: nil, state_dir: @state_dir)
|
|
1095
1151
|
raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
|
|
@@ -1113,6 +1169,12 @@ module Samagotchi
|
|
|
1113
1169
|
filtered << { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
|
|
1114
1170
|
next
|
|
1115
1171
|
end
|
|
1172
|
+
# A plugin's steer: a row of the step that answered it, which the
|
|
1173
|
+
# page finds by `step`.
|
|
1174
|
+
if Samagotchi::Steer.steer?(m)
|
|
1175
|
+
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
|
|
1176
|
+
next
|
|
1177
|
+
end
|
|
1116
1178
|
next if role == "system"
|
|
1117
1179
|
next if role == "tool_response"
|
|
1118
1180
|
|
|
@@ -1129,7 +1191,16 @@ module Samagotchi
|
|
|
1129
1191
|
message = { role: norm_role, content: stripped }
|
|
1130
1192
|
images = m[:images] || m["images"]
|
|
1131
1193
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1132
|
-
|
|
1194
|
+
if norm_role == "assistant"
|
|
1195
|
+
# What an after_turn hook presented (AnswerDisplay): rendered and
|
|
1196
|
+
# copied in place of the answer, sanitised the same way; content
|
|
1197
|
+
# stays the model's text, which the page finds the bubble by.
|
|
1198
|
+
display = m[:display] || m["display"]
|
|
1199
|
+
shown = display.is_a?(String) ? Samagotchi::OutputFormatter.strip_markup(display) : ""
|
|
1200
|
+
message[:display] = shown unless shown.strip.empty?
|
|
1201
|
+
rendered = message[:display] || stripped
|
|
1202
|
+
message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
|
|
1203
|
+
end
|
|
1133
1204
|
message[:parts] = did if did
|
|
1134
1205
|
filtered << message
|
|
1135
1206
|
end
|
|
@@ -1138,6 +1209,17 @@ module Samagotchi
|
|
|
1138
1209
|
[]
|
|
1139
1210
|
end
|
|
1140
1211
|
|
|
1212
|
+
# The last turn failed and its prompt went back to the user: the
|
|
1213
|
+
# prompt (session.last_prompt) and why, for the page to show after the
|
|
1214
|
+
# conversation as it did live. nil otherwise.
|
|
1215
|
+
def failed_turn_for(session, msgs)
|
|
1216
|
+
summary = Samagotchi::TurnNote.restored_failure(msgs)
|
|
1217
|
+
prompt = session.last_prompt.to_s
|
|
1218
|
+
return nil if summary.nil? || prompt.strip.empty?
|
|
1219
|
+
|
|
1220
|
+
{ prompt: prompt, summary: summary }
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1141
1223
|
# The parts of the assistant message at +index+, with the tool_response
|
|
1142
1224
|
# messages right after it.
|
|
1143
1225
|
def message_parts(list, index)
|
|
@@ -1199,6 +1281,8 @@ module Samagotchi
|
|
|
1199
1281
|
def index_data_attributes(req)
|
|
1200
1282
|
attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd) }
|
|
1201
1283
|
attrs["turn-view"] = "1" if turn_view?(req.params["view"])
|
|
1284
|
+
# Always there: an empty one means no presets, not the default.
|
|
1285
|
+
attrs["annotate-presets"] = @annotate_presets
|
|
1202
1286
|
dir, = scope_dir(req.params["dir"])
|
|
1203
1287
|
root = dir && ProjectScope.root_for(dir)
|
|
1204
1288
|
if root
|
|
@@ -10,6 +10,8 @@ export const ROW_STATUS = Object.freeze({
|
|
|
10
10
|
RUNNING: "running",
|
|
11
11
|
OK: "ok",
|
|
12
12
|
ERROR: "error",
|
|
13
|
+
// A task_wait the user's Stop ended (the task itself runs on).
|
|
14
|
+
STOPPED: "stopped",
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
// What a row shows for its tool: a plugin tool's label ("chrome:
|
|
@@ -79,7 +81,7 @@ export function addCompleted(model, event = {}) {
|
|
|
79
81
|
});
|
|
80
82
|
row = synthesized.row;
|
|
81
83
|
}
|
|
82
|
-
row.status = event.activity?.status
|
|
84
|
+
row.status = completedStatus(event.activity?.status);
|
|
83
85
|
row.output = event.output || "";
|
|
84
86
|
row.output_truncated = !!event.output_truncated;
|
|
85
87
|
if (event.activity?.params) row.params = event.activity.params;
|
|
@@ -89,6 +91,13 @@ export function addCompleted(model, event = {}) {
|
|
|
89
91
|
return row;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
// A completed call's row status from the server's: error, stopped, else ok.
|
|
95
|
+
export function completedStatus(status) {
|
|
96
|
+
if (status === "error") return ROW_STATUS.ERROR;
|
|
97
|
+
if (status === "stopped") return ROW_STATUS.STOPPED;
|
|
98
|
+
return ROW_STATUS.OK;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
// Mark a turn's activity model as finalized (rows stay; rendering collapses).
|
|
93
102
|
export function finalizeActivity(model) {
|
|
94
103
|
if (model) model.finalized = true;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Annotate presets: quick replies next to "Annotate" in the selection bubble
|
|
2
|
+
// (web.annotate_presets, "Agreed|Could you please elaborate?"). A preset
|
|
3
|
+
// fills the composer with the quote and its text as the note; it never
|
|
4
|
+
// sends. Its own module, not more exports on annotations.js: static files
|
|
5
|
+
// are cached at unversioned URLs, and a fresh app.js importing a missing
|
|
6
|
+
// export from a cached annotations.js would fail to link.
|
|
7
|
+
|
|
8
|
+
export const MAX_PRESETS = 5;
|
|
9
|
+
|
|
10
|
+
// The presets in +raw+ ("a|b|c"): trimmed, blanks and repeats dropped, at
|
|
11
|
+
// most MAX_PRESETS. A preset can't hold "|".
|
|
12
|
+
export function parsePresets(raw) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const part of String(raw ?? "").split("|")) {
|
|
15
|
+
const text = part.trim();
|
|
16
|
+
if (text && !out.includes(text)) out.push(text);
|
|
17
|
+
if (out.length === MAX_PRESETS) break;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The quote +block+ (from quoteBlock, ending in a blank line) with +text+
|
|
23
|
+
// as its note.
|
|
24
|
+
export function presetNote(block, text) {
|
|
25
|
+
return `${block}${text}`;
|
|
26
|
+
}
|
|
@@ -65,3 +65,16 @@ export function appendQuote(value, block) {
|
|
|
65
65
|
const before = String(value ?? "").replace(/\s+$/, "");
|
|
66
66
|
return before ? `${before}\n\n${block}` : block;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
// The Annotate bar's left edge (fixed position, px) under the selection's
|
|
70
|
+
// last line (+rect+: its {left, right}): right-aligned to where the
|
|
71
|
+
// selection ends; where that would clamp to the page gutter (a selection
|
|
72
|
+
// ending near the bubble's left edge), left-aligned to where the selection
|
|
73
|
+
// starts on that line instead, so the bar stays by the text.
|
|
74
|
+
// @param width the bar's width; @param viewport the window's inner width
|
|
75
|
+
export function annotateBarLeft(rect, width, viewport, gutter = 8) {
|
|
76
|
+
const max = viewport - width - gutter;
|
|
77
|
+
const right = rect.right - width;
|
|
78
|
+
const left = right < gutter ? rect.left : right;
|
|
79
|
+
return Math.max(Math.min(left, max), gutter);
|
|
80
|
+
}
|