samagotchi 0.21.0 → 0.23.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 +62 -1
- data/bin/chi +9 -4
- data/docs/architecture.md +3 -2
- data/docs/configuration.md +15 -4
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +11 -5
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +11 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -2
- data/lib/samagotchi/bridge.rb +12 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +2 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +38 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +44 -12
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +49 -19
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +2 -2
- data/lib/samagotchi/guardrails/shell_paths.rb +2 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +18 -15
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +31 -31
- data/lib/samagotchi/llm/chat_loop.rb +9 -6
- data/lib/samagotchi/llm/errors.rb +33 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +53 -30
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +68 -12
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -13
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/reply_wait.rb +12 -2
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +9 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +6 -3
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/turn_state.rb +22 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +10 -2
- metadata +21 -20
|
@@ -32,10 +32,7 @@ module Samagotchi
|
|
|
32
32
|
|
|
33
33
|
key = model_name.to_s
|
|
34
34
|
.downcase
|
|
35
|
-
.gsub(/[^a-z0-9]+/, "-")
|
|
36
|
-
.gsub(/-+/, "-")
|
|
37
|
-
.sub(/\A-/, "")
|
|
38
|
-
.sub(/-\z/, "")
|
|
35
|
+
.gsub(/[^a-z0-9]+/, "-").squeeze("-").delete_prefix("-").delete_suffix("-")
|
|
39
36
|
key.empty? ? nil : key
|
|
40
37
|
end
|
|
41
38
|
|
|
@@ -43,12 +40,13 @@ module Samagotchi
|
|
|
43
40
|
# Respects MemoryBundle overrides (delegates to Tools::MemoryRead.memories_dir).
|
|
44
41
|
def self.overlay_path_for(name, key, scope)
|
|
45
42
|
return nil unless key && !key.to_s.strip.empty?
|
|
43
|
+
|
|
46
44
|
File.join(Tools::MemoryRead.memories_dir(scope), "#{name}.#{key.to_s.strip}.md")
|
|
47
45
|
end
|
|
48
46
|
|
|
49
47
|
# Regex matching overlay file names: /<something>.<lowercase-dashed-key>.md\z
|
|
50
48
|
# Useful for tooling, tests, and future index suppression.
|
|
51
|
-
OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9
|
|
49
|
+
OVERLAY_SUFFIX_PATTERN = /\.([a-z0-9-]+)\.md\z/
|
|
52
50
|
|
|
53
51
|
# The base file name (`tips.md`) an overlay-looking name
|
|
54
52
|
# (`tips.qwen3.md`) would belong to, or nil when it doesn't match the
|
|
@@ -17,10 +17,13 @@ module Samagotchi
|
|
|
17
17
|
MODEL_FROM_CLI_ENV = "SAMAGOTCHI_DEFAULT_MODEL_FROM_CLI"
|
|
18
18
|
# No model anywhere (--model, default.model in config.yml, the env): a
|
|
19
19
|
# first run before any config. An ArgumentError, as before.
|
|
20
|
-
MissingModel
|
|
20
|
+
class MissingModel < ArgumentError
|
|
21
|
+
end
|
|
22
|
+
|
|
21
23
|
# A host-qualified model whose host isn't configured (a MissingModel,
|
|
22
24
|
# so every surface that reports a missing model reports it the same way).
|
|
23
|
-
UnknownHost
|
|
25
|
+
class UnknownHost < MissingModel
|
|
26
|
+
end
|
|
24
27
|
|
|
25
28
|
# Gemma 4 thinks in a channel: `<|channel>thought` … `<channel|>`. The
|
|
26
29
|
# one source for the parser, the stream splitter, the literal guard and
|
|
@@ -74,7 +77,7 @@ module Samagotchi
|
|
|
74
77
|
new(
|
|
75
78
|
name: "gemma4",
|
|
76
79
|
turn_start: "<|turn>",
|
|
77
|
-
turn_end: "<
|
|
80
|
+
turn_end: "<turn|>",
|
|
78
81
|
tool_call_open: "<|tool_call>",
|
|
79
82
|
tool_call_close: "<tool_call|>",
|
|
80
83
|
tool_response_open: "<|tool_response>",
|
|
@@ -88,7 +91,7 @@ module Samagotchi
|
|
|
88
91
|
user_prefix: "",
|
|
89
92
|
assistant_prefix: "",
|
|
90
93
|
model_prefix: "",
|
|
91
|
-
stop_sequences: ["<
|
|
94
|
+
stop_sequences: ["<turn|>", "<|tool_response>"],
|
|
92
95
|
tool_decl_format: :gemma4
|
|
93
96
|
)
|
|
94
97
|
end
|
|
@@ -165,14 +168,15 @@ module Samagotchi
|
|
|
165
168
|
if !parsed.host_name && (disabled = disabled_host_prefix(ref, hosts, env))
|
|
166
169
|
raise UnknownHost, "host '#{disabled}' is disabled (enabled: false in config.yml)"
|
|
167
170
|
end
|
|
171
|
+
|
|
168
172
|
warn_host_slash(ref, hosts) unless parsed.host_name
|
|
169
173
|
host = Samagotchi::ConfigFile.unknown_host_prefix(ref, hosts: hosts)
|
|
170
174
|
return model_name unless host
|
|
171
175
|
|
|
172
176
|
names = hosts.keys.map { |k| k.to_s.downcase }.sort
|
|
173
177
|
near = Samagotchi::Config.near_names(host, names).first(3)
|
|
174
|
-
hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(
|
|
175
|
-
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(
|
|
178
|
+
hint = near.empty? ? "" : " (did you mean #{near.map { |n| "'#{n}'" }.join(" or ")}?)"
|
|
179
|
+
raise UnknownHost, "unknown host '#{host}' in model '#{ref}'#{hint}; the configured hosts are #{names.join(", ")}"
|
|
176
180
|
end
|
|
177
181
|
|
|
178
182
|
# The prefix of "box:x" when box is a host config.yml has with
|
|
@@ -275,7 +279,7 @@ module Samagotchi
|
|
|
275
279
|
FINGERPRINTS.each do |name, markers|
|
|
276
280
|
return [name, markers.join(" + ")] if markers.all? { |marker| template.include?(marker) }
|
|
277
281
|
end
|
|
278
|
-
return [
|
|
282
|
+
return %w[qwen36 ChatML] if template.include?("<|im_start|>")
|
|
279
283
|
|
|
280
284
|
nil
|
|
281
285
|
end
|
|
@@ -341,7 +345,7 @@ module Samagotchi
|
|
|
341
345
|
|
|
342
346
|
def self.configured(value, where)
|
|
343
347
|
profile = named(value)
|
|
344
|
-
Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(
|
|
348
|
+
Log.warn(:config, "unknown_profile", echo: "Warning: unknown profile #{value.to_s.inspect} in #{where} (allowed: #{NAMES.join(", ")}) — ignored", profile: value.to_s, where: where) unless profile
|
|
345
349
|
profile
|
|
346
350
|
end
|
|
347
351
|
private_class_method :configured
|
|
@@ -92,6 +92,7 @@ module Samagotchi
|
|
|
92
92
|
# passes down may never close: waiting on either would hang a script.
|
|
93
93
|
def read_stdin
|
|
94
94
|
return nil if @stdin.respond_to?(:tty?) && @stdin.tty?
|
|
95
|
+
|
|
95
96
|
if @stdin.respond_to?(:stat)
|
|
96
97
|
stat = @stdin.stat
|
|
97
98
|
return nil unless stat.pipe? || stat.file?
|
|
@@ -135,7 +136,7 @@ module Samagotchi
|
|
|
135
136
|
if owner
|
|
136
137
|
@stdout.puts("#{short} queued: its worker adds it within a few seconds")
|
|
137
138
|
else
|
|
138
|
-
queued = SessionInbox.find_new_note_files(File.dirname(
|
|
139
|
+
queued = SessionInbox.find_new_note_files(File.dirname(path, 2)).size
|
|
139
140
|
@stdout.puts("#{short} waits for the session's next start (#{queued} #{queued == 1 ? "note" : "notes"} queued)")
|
|
140
141
|
end
|
|
141
142
|
true
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
module Samagotchi
|
|
3
4
|
# OutputFormatter strips model wire-format tokens from raw engine output at
|
|
4
5
|
# render time, so downstream renderers (web UI, terminal UI) never show the
|
|
@@ -23,7 +24,6 @@ module Samagotchi
|
|
|
23
24
|
# wrapper forms; bare <name> content (no pipe) is deliberately preserved.
|
|
24
25
|
CONTROL_NAMES = %w[
|
|
25
26
|
turn
|
|
26
|
-
end_of_turn
|
|
27
27
|
tool_call
|
|
28
28
|
tool_response
|
|
29
29
|
tool
|
|
@@ -40,31 +40,32 @@ module Samagotchi
|
|
|
40
40
|
# <word> in the surrounding content is NOT stripped:
|
|
41
41
|
# <|name> / <name|> / <|name|>
|
|
42
42
|
INDIVIDUAL_RE = Regexp.new(
|
|
43
|
-
'<\\|(?:' + CONTROL_NAMES.join(
|
|
44
|
-
|
|
43
|
+
'<\\|(?:' + CONTROL_NAMES.join("|") + ')\\|?>' \
|
|
44
|
+
"|<(?:" + CONTROL_NAMES.join("|") + ')\\|>'
|
|
45
45
|
)
|
|
46
46
|
INDIVIDUAL_RE.freeze
|
|
47
47
|
|
|
48
48
|
# Angle-bracketed literal tokens stripped wholesale (including the thought
|
|
49
49
|
# channel's trailing word). Applied before INDIVIDUAL_RE so multi-part tokens
|
|
50
50
|
# are removed as a unit. Pipes are escaped; angle brackets are literal.
|
|
51
|
-
LITERALS_RE = /<\|"
|
|
51
|
+
LITERALS_RE = /<\|"\|>/
|
|
52
52
|
|
|
53
53
|
# Gemma 4's thought channel, as a whole block: `<|channel>thought` to its
|
|
54
54
|
# `<channel|>` close, or to the end of the text when the close is missing
|
|
55
55
|
# (ToolCallParser::Gemma strips the same pair). Removing only the marker
|
|
56
56
|
# would leave the model's private reasoning in the answer; the channel is
|
|
57
57
|
# the profile's thinking lane, not output.
|
|
58
|
-
GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m
|
|
58
|
+
GEMMA_THOUGHT_RE = /<\|\w*channel>thought.*?(?:<channel\|>|\z)/m
|
|
59
59
|
|
|
60
60
|
# Qwen prompt-literal placeholders, e.g. </think>.
|
|
61
|
-
PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]
|
|
61
|
+
PROMPT_LITERALS = /\[\[SAMAGOTCHI_LITERAL_[A-Z_]+\]\]/
|
|
62
62
|
|
|
63
63
|
# Qwen3.6 thinking blocks: <think>...</think> (including empty)
|
|
64
|
-
QWEN_THINK_RE =
|
|
64
|
+
QWEN_THINK_RE = %r{<think>.*?</think>}m
|
|
65
65
|
# Qwen tool_call XML blocks that survive when a tool call is rendered as text
|
|
66
66
|
# e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
|
|
67
|
-
QWEN_TOOL_CALL_RE =
|
|
67
|
+
QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
|
|
68
|
+
|
|
68
69
|
module_function
|
|
69
70
|
|
|
70
71
|
# Remove both control-token and prompt-literal token families from +text+.
|
|
@@ -79,7 +80,7 @@ module Samagotchi
|
|
|
79
80
|
.gsub(/[ \t]+/, " ")
|
|
80
81
|
.gsub(/ *\n */, "\n")
|
|
81
82
|
.strip
|
|
82
|
-
cleaned.empty? ?
|
|
83
|
+
cleaned.empty? ? "" : cleaned
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
# The same tokens as #strip, but the text keeps its layout (indentation,
|
|
@@ -91,15 +92,15 @@ module Samagotchi
|
|
|
91
92
|
|
|
92
93
|
def remove_tokens(text)
|
|
93
94
|
text.to_s
|
|
94
|
-
.gsub(QWEN_THINK_RE,
|
|
95
|
-
.gsub(GEMMA_THOUGHT_RE,
|
|
96
|
-
.gsub(QWEN_TOOL_CALL_RE,
|
|
97
|
-
.gsub(LITERALS_RE,
|
|
98
|
-
.gsub(INDIVIDUAL_RE,
|
|
99
|
-
.gsub(PROMPT_LITERALS,
|
|
95
|
+
.gsub(QWEN_THINK_RE, "")
|
|
96
|
+
.gsub(GEMMA_THOUGHT_RE, "")
|
|
97
|
+
.gsub(QWEN_TOOL_CALL_RE, "")
|
|
98
|
+
.gsub(LITERALS_RE, "")
|
|
99
|
+
.gsub(INDIVIDUAL_RE, "")
|
|
100
|
+
.gsub(PROMPT_LITERALS, "")
|
|
100
101
|
# orphaned closing tags that may remain after block removal
|
|
101
|
-
.gsub(
|
|
102
|
-
.gsub(
|
|
102
|
+
.gsub(%r{</?think>}, "")
|
|
103
|
+
.gsub(%r{</?tool_call>}, "")
|
|
103
104
|
end
|
|
104
105
|
end
|
|
105
106
|
end
|
|
@@ -40,7 +40,8 @@ module Samagotchi
|
|
|
40
40
|
# @return [OwnerLock, nil] nil when another owner holds it
|
|
41
41
|
def self.acquire(session_dir, kind:, wait: DEFAULT_WAIT)
|
|
42
42
|
FileUtils.mkdir_p(session_dir)
|
|
43
|
-
|
|
43
|
+
# Held open on purpose: the flock lives as long as the OwnerLock (#release closes it).
|
|
44
|
+
file = File.open(path(session_dir), File::RDWR | File::CREAT, 0o644) # rubocop:disable Style/FileOpen
|
|
44
45
|
deadline = monotonic_now + wait.to_f
|
|
45
46
|
until file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
46
47
|
if monotonic_now >= deadline
|
|
@@ -70,8 +70,10 @@ module Samagotchi
|
|
|
70
70
|
|
|
71
71
|
# @param session_id [String]
|
|
72
72
|
# @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
|
|
73
|
+
# @param extra [Hash] more keys for the object (chi answer's
|
|
74
|
+
# answered_here: false)
|
|
73
75
|
# @return [Hash] the --format json object
|
|
74
|
-
def json(result, session_id:, timeout: nil)
|
|
76
|
+
def json(result, session_id:, timeout: nil, extra: {})
|
|
75
77
|
report = { status: status(result), session_id: session_id }
|
|
76
78
|
case report[:status]
|
|
77
79
|
when "answered" then report[:text] = result.text.to_s
|
|
@@ -79,24 +81,39 @@ module Samagotchi
|
|
|
79
81
|
question = question(result.question)
|
|
80
82
|
report[:question] = question
|
|
81
83
|
report[:answer_with] = answer_with(session_id, question)
|
|
82
|
-
else
|
|
84
|
+
else
|
|
85
|
+
report.merge!(stop_facts(result))
|
|
86
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
87
|
+
end
|
|
88
|
+
report.merge(extra)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Why a failed or canceled turn stopped, as far as last_turn knows
|
|
92
|
+
# (ReplyWait::Result): error_kind and retryable, cancel_reason and
|
|
93
|
+
# stopped_by; absent when unknown.
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def stop_facts(result)
|
|
96
|
+
case status(result)
|
|
97
|
+
when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
|
|
98
|
+
when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
|
|
99
|
+
else {}
|
|
83
100
|
end
|
|
84
|
-
report
|
|
85
101
|
end
|
|
86
102
|
|
|
87
103
|
# @return [String] the JSON object as one line
|
|
88
|
-
def json_line(result, session_id:, timeout: nil)
|
|
89
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout))
|
|
104
|
+
def json_line(result, session_id:, timeout: nil, extra: {})
|
|
105
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
90
106
|
end
|
|
91
107
|
|
|
92
108
|
# Print a wait's end the way the commands do: the JSON object on
|
|
93
109
|
# stdout, or the reply on stdout and anything else on stderr (a
|
|
94
110
|
# question in full) after "chi send: ".
|
|
95
111
|
# @param command [String] "chi send", "chi answer"
|
|
112
|
+
# @param extra [Hash] more keys for the JSON object (#json)
|
|
96
113
|
# @return [Integer] the exit status
|
|
97
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
|
|
114
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
|
|
98
115
|
if json
|
|
99
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
|
|
116
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
100
117
|
elsif result.status == :done
|
|
101
118
|
stdout.puts(result.text)
|
|
102
119
|
else
|
|
@@ -51,6 +51,7 @@ module Samagotchi
|
|
|
51
51
|
# it runs as other commands do)
|
|
52
52
|
def command(name, description, anytime: false, &block)
|
|
53
53
|
raise ArgumentError, "command #{name.inspect} needs a block" unless block
|
|
54
|
+
|
|
54
55
|
name = name.to_s
|
|
55
56
|
raise ArgumentError, "command name #{name.inspect} must look like /name (a-z, 0-9, _ and -)" unless name.match?(COMMAND_NAME)
|
|
56
57
|
if (taken = @registries.commands.entries.find { |entry| entry.name == name })
|
|
@@ -78,9 +79,9 @@ module Samagotchi
|
|
|
78
79
|
# @param targets [#call, nil] args → what guardrail rules match: a Hash
|
|
79
80
|
# with paths: (absolute or relative to the cwd), command: (a shell
|
|
80
81
|
# command) and cwd:, each optional
|
|
81
|
-
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &
|
|
82
|
+
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
|
|
82
83
|
spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
|
|
83
|
-
targets: targets, &
|
|
84
|
+
targets: targets, &)
|
|
84
85
|
if (taken = @registries.tools[spec[:name]])
|
|
85
86
|
raise ArgumentError, "tool #{spec[:name]} is already registered (#{taken.source})"
|
|
86
87
|
end
|
|
@@ -124,9 +125,9 @@ module Samagotchi
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
# As Api#tool.
|
|
127
|
-
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &
|
|
128
|
+
def tool(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &)
|
|
128
129
|
spec = Api.tool_spec(name, description, params: params, schema: schema, label: label, preview: preview,
|
|
129
|
-
targets: targets, &
|
|
130
|
+
targets: targets, &)
|
|
130
131
|
raise ArgumentError, "tool #{spec[:name]} is registered twice" if @specs.any? { |s| s[:name] == spec[:name] }
|
|
131
132
|
|
|
132
133
|
@specs << spec
|
|
@@ -159,6 +160,7 @@ module Samagotchi
|
|
|
159
160
|
# @return [Service]
|
|
160
161
|
def service(name, eager: false, &block)
|
|
161
162
|
raise ArgumentError, "service #{name.inspect} needs a block" unless block
|
|
163
|
+
|
|
162
164
|
name = "#{@bundle}:#{name}"
|
|
163
165
|
raise ArgumentError, "service #{name} is registered twice" if @services.any? { |svc| svc.name == name }
|
|
164
166
|
raise ArgumentError, "this chi has no services (plugins: false)" unless @registries.services
|
|
@@ -249,6 +251,7 @@ module Samagotchi
|
|
|
249
251
|
# @return [Hash] {name:, schema:, label:, preview:, targets:, block:}
|
|
250
252
|
def self.tool_spec(name, description, params: {}, schema: nil, label: nil, preview: nil, targets: nil, &block)
|
|
251
253
|
raise ArgumentError, "tool #{name.inspect} needs a block" unless block
|
|
254
|
+
|
|
252
255
|
name = name.to_s
|
|
253
256
|
raise ArgumentError, "tool name #{name.inspect} must be a-z, 0-9 and _ (at most 48)" unless name.match?(TOOL_NAME)
|
|
254
257
|
raise ArgumentError, "tool #{name}: preview must respond to #call" if preview && !preview.respond_to?(:call)
|
|
@@ -323,7 +326,7 @@ module Samagotchi
|
|
|
323
326
|
# (a call built without one, in a spec say: the call without its
|
|
324
327
|
# name), string keys, typed by +parameters+, frozen.
|
|
325
328
|
def self.args_of(call, parameters = nil)
|
|
326
|
-
given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _|
|
|
329
|
+
given = call[:args].is_a?(Hash) ? call[:args] : call.reject { |key, _| %i[name args].include?(key) }
|
|
327
330
|
Tools::Args.coerce(given, parameters).freeze
|
|
328
331
|
end
|
|
329
332
|
|
|
@@ -107,9 +107,7 @@ module Samagotchi
|
|
|
107
107
|
# $XDG_STATE_HOME/samagotchi/plugins/<bundle>/, created on first use.
|
|
108
108
|
# @return [String]
|
|
109
109
|
def data_dir
|
|
110
|
-
@data_dir ||=
|
|
111
|
-
File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
112
|
-
end
|
|
110
|
+
@data_dir ||= File.join(Paths.state_dir(env: @env), "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
113
111
|
end
|
|
114
112
|
|
|
115
113
|
# The conversation so far, a frozen copy, without the system prompt.
|
|
@@ -73,7 +73,7 @@ module Samagotchi
|
|
|
73
73
|
true
|
|
74
74
|
rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
|
|
75
75
|
api&.abort!
|
|
76
|
-
raise if e.is_a?(
|
|
76
|
+
raise if e.is_a?(SignalException) # Ctrl-C and kill signals are the user's
|
|
77
77
|
|
|
78
78
|
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
|
|
79
79
|
end
|
|
@@ -62,7 +62,7 @@ module Samagotchi
|
|
|
62
62
|
def running? = @state == :running
|
|
63
63
|
|
|
64
64
|
# @return [Symbol] :idle, :running or :stopped
|
|
65
|
-
|
|
65
|
+
attr_reader :state
|
|
66
66
|
|
|
67
67
|
# Run the stop callbacks (newest first) once; a raise is logged, the
|
|
68
68
|
# rest still run. It never starts again after this.
|
|
@@ -38,6 +38,7 @@ module Samagotchi
|
|
|
38
38
|
def fork(messages:, title: nil, prompt: nil)
|
|
39
39
|
parent_id = @host.session_id.call or raise Error, "this session has no id yet"
|
|
40
40
|
raise Error, "a scratch session starts no other sessions: they would outlive it" if @host.scratch&.call
|
|
41
|
+
|
|
41
42
|
state_dir = self.state_dir
|
|
42
43
|
prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
|
|
43
44
|
check_children(parent_id, state_dir) if prompt
|
data/lib/samagotchi/prompt.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "model_profile"
|
|
4
4
|
require_relative "prompt_literal_guard"
|
|
5
5
|
require_relative "vision_context"
|
|
6
|
+
require_relative "tool_response"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# Formats a message list into a prompt string ready for the /completion endpoint.
|
|
@@ -10,10 +11,13 @@ module Samagotchi
|
|
|
10
11
|
# Supports multiple model profiles (Gemma 4, Qwen 3.6, etc.) with different
|
|
11
12
|
# token formats, role prefixing strategies, and tool response handling.
|
|
12
13
|
#
|
|
13
|
-
# Gemma 4:
|
|
14
|
-
# - Turn markers: <|turn>ROLE\n...<
|
|
15
|
-
# - Tool
|
|
16
|
-
#
|
|
14
|
+
# Gemma 4 (as its chat template renders a conversation):
|
|
15
|
+
# - Turn markers: <|turn>ROLE\n...<turn|>\n, the text trimmed
|
|
16
|
+
# - Tool calls and their responses stay inside the model's turn:
|
|
17
|
+
# ...<tool_call|><|tool_response>response:NAME{value:<|"|>...<|"|>}<tool_response|>
|
|
18
|
+
# and the model goes on in the same turn
|
|
19
|
+
# - Generation cue: <|turn>model\n, or nothing after a tool response
|
|
20
|
+
# - An answer's thought is dropped once a later user turn starts
|
|
17
21
|
#
|
|
18
22
|
# Qwen 3.6:
|
|
19
23
|
# - Role framing: <|im_start|>ROLE\n...<|im_end|>
|
|
@@ -51,11 +55,9 @@ module Samagotchi
|
|
|
51
55
|
else
|
|
52
56
|
format_with_turn_markers(messages, profile, suffixes)
|
|
53
57
|
end
|
|
54
|
-
[text + prefill
|
|
58
|
+
[text + prefill_for(messages, profile, prefill), images]
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
private
|
|
58
|
-
|
|
59
61
|
def self.image_lines(items, profile, images)
|
|
60
62
|
return "" if items.empty?
|
|
61
63
|
|
|
@@ -71,19 +73,70 @@ module Samagotchi
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
def self.format_with_turn_markers(messages, profile, suffixes)
|
|
74
|
-
|
|
76
|
+
last_user = messages.rindex { |m| m[:role] == "user" } || -1
|
|
75
77
|
parts = messages.each_with_index.map do |m, index|
|
|
76
78
|
content = prompt_content_for(m, profile) + suffixes[index]
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
previous = index.positive? ? messages[index - 1][:role] : nil
|
|
80
|
+
following = messages[index + 1]&.dig(:role)
|
|
81
|
+
case m[:role]
|
|
82
|
+
when "tool_response"
|
|
83
|
+
# Inside the model's turn; closed when another role follows.
|
|
84
|
+
tool_response_blocks(content, profile) +
|
|
85
|
+
(following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
|
|
86
|
+
when "model"
|
|
87
|
+
content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
|
|
88
|
+
opener = previous == "tool_response" ? "" : "#{profile.turn_start}model\n"
|
|
89
|
+
closer = following == "tool_response" ? "" : "#{profile.turn_end}\n"
|
|
90
|
+
"#{opener}#{content.strip}#{closer}"
|
|
79
91
|
else
|
|
80
|
-
"#{profile.turn_start}#{m[:role]}\n#{content}#{profile.turn_end}\n"
|
|
92
|
+
"#{profile.turn_start}#{m[:role]}\n#{content.strip}#{profile.turn_end}\n"
|
|
81
93
|
end
|
|
82
94
|
end
|
|
83
|
-
parts << "#{profile.turn_start}model\n"
|
|
95
|
+
parts << "#{profile.turn_start}model\n" unless ends_in_tool_response?(messages)
|
|
84
96
|
parts.join
|
|
85
97
|
end
|
|
86
98
|
|
|
99
|
+
def self.ends_in_tool_response?(messages)
|
|
100
|
+
messages.last&.dig(:role) == "tool_response"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The text after the generation cue: +prefill+, except where Gemma's
|
|
104
|
+
# turn goes on after a tool response (no cue, so nothing to fill).
|
|
105
|
+
# The prompt and the model message the kernel keeps both use it.
|
|
106
|
+
def self.prefill_for(messages, profile, prefill)
|
|
107
|
+
return "" if !profile.uses_role_prefixes? && ends_in_tool_response?(messages)
|
|
108
|
+
|
|
109
|
+
prefill.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A native tool_response entry (ToolResponse.joined: "[name]" outputs
|
|
113
|
+
# joined by its SEPARATOR) as Gemma's template writes the results: one
|
|
114
|
+
# response:NAME{value:<|"|>…<|"|>} block per call. A part that doesn't
|
|
115
|
+
# open with a "[name]" is the previous output's own text.
|
|
116
|
+
def self.tool_response_blocks(content, profile)
|
|
117
|
+
runs = content.split(ToolResponse::SEPARATOR).each_with_object([]) do |part, acc|
|
|
118
|
+
if (match = part.match(/\A\[([^\]\s]+)\](?:\n| |\z)/))
|
|
119
|
+
acc << [match[1], match.post_match]
|
|
120
|
+
elsif acc.empty?
|
|
121
|
+
acc << ["unknown", part]
|
|
122
|
+
else
|
|
123
|
+
acc.last[1] = "#{acc.last[1]}#{ToolResponse::SEPARATOR}#{part}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
runs = [["unknown", ""]] if runs.empty?
|
|
127
|
+
q = profile.string_delim
|
|
128
|
+
runs.map do |name, body|
|
|
129
|
+
"#{profile.tool_response_open}response:#{name}{value:#{q}#{body}#{q}}#{profile.tool_response_close}"
|
|
130
|
+
end.join
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
GEMMA_THOUGHT_BLOCK = /#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN.delete_suffix("thought"))}.*?#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}/m
|
|
134
|
+
private_constant :GEMMA_THOUGHT_BLOCK
|
|
135
|
+
|
|
136
|
+
def self.strip_gemma_thought(content)
|
|
137
|
+
content.gsub(GEMMA_THOUGHT_BLOCK, "")
|
|
138
|
+
end
|
|
139
|
+
|
|
87
140
|
def self.format_with_prefixes(messages, profile, suffixes)
|
|
88
141
|
# Qwen 3.6 style: <|im_start|>ROLE\n...<|im_end|>
|
|
89
142
|
parts = []
|
|
@@ -117,5 +170,8 @@ module Samagotchi
|
|
|
117
170
|
role = ESCAPED_KINDS.include?(message[:kind].to_s) ? "user" : message[:role]
|
|
118
171
|
PromptLiteralGuard.escape(message[:content], profile: profile, role: role)
|
|
119
172
|
end
|
|
173
|
+
|
|
174
|
+
private_class_method :image_lines, :format_with_turn_markers, :ends_in_tool_response?, :tool_response_blocks,
|
|
175
|
+
:strip_gemma_thought, :format_with_prefixes, :prompt_content_for
|
|
120
176
|
end
|
|
121
177
|
end
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
module PromptLiteralGuard
|
|
9
9
|
GEMMA_TOKEN_PAIRS = [
|
|
10
10
|
["[[SAMAGOTCHI_LITERAL_TURN_START]]", "<|turn>"],
|
|
11
|
-
["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<
|
|
11
|
+
["[[SAMAGOTCHI_LITERAL_TURN_END]]", "<turn|>"],
|
|
12
12
|
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_OPEN]]", "<|tool_call>"],
|
|
13
13
|
["[[SAMAGOTCHI_LITERAL_TOOL_CALL_CLOSE]]", "<tool_call|>"],
|
|
14
14
|
["[[SAMAGOTCHI_LITERAL_TOOL_RESPONSE_OPEN]]", "<|tool_response>"],
|
|
@@ -101,8 +101,8 @@ module Samagotchi
|
|
|
101
101
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
102
102
|
# @return [String] normalized answer JSON
|
|
103
103
|
def request(payload)
|
|
104
|
-
result = open_question(
|
|
105
|
-
|
|
104
|
+
result = open_question(payload.slice(:question, :options, :header)
|
|
105
|
+
.merge(multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform]))
|
|
106
106
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
107
107
|
# tool failure the model learns to avoid the tool from.
|
|
108
108
|
result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
@@ -288,6 +288,7 @@ module Samagotchi
|
|
|
288
288
|
if parent_agent && (reason = Guardrails::ParentContinue.refusal(pending, sel, allowed: parent_continue))
|
|
289
289
|
raise Refused, reason
|
|
290
290
|
end
|
|
291
|
+
|
|
291
292
|
if @standing
|
|
292
293
|
# No waiter: cleared here, so a second answer finds none.
|
|
293
294
|
standing = @standing
|
|
@@ -332,9 +333,7 @@ module Samagotchi
|
|
|
332
333
|
true
|
|
333
334
|
end
|
|
334
335
|
|
|
335
|
-
|
|
336
|
-
@sync_handler = block
|
|
337
|
-
end
|
|
336
|
+
attr_writer :sync_handler
|
|
338
337
|
|
|
339
338
|
# @return [Boolean] a REPL answers inline (no wait loop)
|
|
340
339
|
def sync_handler? = !@sync_handler.nil?
|
|
@@ -355,7 +354,7 @@ module Samagotchi
|
|
|
355
354
|
next if id && pending[:id].to_s != id.to_s
|
|
356
355
|
next if @answer || pending[:status].to_s != "pending"
|
|
357
356
|
# Its poster closes it; nothing waits on a cancel.
|
|
358
|
-
raise NotDismissable, "answer #{Array(pending[:options]).join(
|
|
357
|
+
raise NotDismissable, "answer #{Array(pending[:options]).join(" or ")}" if @standing && id
|
|
359
358
|
next if @standing
|
|
360
359
|
|
|
361
360
|
pending[:status] = "cancelled"
|
|
@@ -455,7 +454,7 @@ module Samagotchi
|
|
|
455
454
|
def settle_standing(standing, answer, client_id)
|
|
456
455
|
clear_saved_question
|
|
457
456
|
emit({ type: :question_answered, id: answer[:id], answer: answer })
|
|
458
|
-
standing[:on_answer].call(answer
|
|
457
|
+
standing[:on_answer].call(answer, client_id: client_id)
|
|
459
458
|
rescue StandardError => e
|
|
460
459
|
Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
|
|
461
460
|
end
|
|
@@ -469,14 +468,9 @@ module Samagotchi
|
|
|
469
468
|
def validated_answer(pending, sel, fm, parent_agent:, parent_setting:)
|
|
470
469
|
opts = Array(pending[:options])
|
|
471
470
|
invalid = sel.reject { |v| opts.include?(v) }
|
|
472
|
-
raise ArgumentError, "invalid selection: #{invalid.join(
|
|
471
|
+
raise ArgumentError, "invalid selection: #{invalid.join(", ")} (valid: #{opts.join(", ")})" unless invalid.empty?
|
|
473
472
|
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
|
|
474
473
|
raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
475
|
-
# A step-limit question: Continue resumes the turn as it was; a text
|
|
476
|
-
# goes with Stop (steering a continue is not a thing).
|
|
477
|
-
if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
|
|
478
|
-
raise ArgumentError, "Continue takes no text (a text goes with Stop)"
|
|
479
|
-
end
|
|
480
474
|
|
|
481
475
|
indices = sel.map { |v| opts.index(v) }
|
|
482
476
|
# The one whose answer settles the approval (Approval.settle, by index).
|
|
@@ -114,7 +114,7 @@ module Samagotchi
|
|
|
114
114
|
return range if range
|
|
115
115
|
|
|
116
116
|
default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
|
|
117
|
-
Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join(
|
|
117
|
+
Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join("-")}",
|
|
118
118
|
value: value.to_s)
|
|
119
119
|
default
|
|
120
120
|
end
|
|
@@ -35,7 +35,7 @@ module Samagotchi
|
|
|
35
35
|
# The parent's user (or a parent agent) answered the relay card.
|
|
36
36
|
# @param by [String] "user" or "parent_agent"
|
|
37
37
|
# @return [Boolean] false when the relay isn't open (first answer wins)
|
|
38
|
-
def record(id, selected_indices: [], freeform: nil, dismissed: false
|
|
38
|
+
def record(id, by:, selected_indices: [], freeform: nil, dismissed: false)
|
|
39
39
|
@mutex.synchronize do
|
|
40
40
|
relay = @relays[id.to_s]
|
|
41
41
|
next false unless relay && relay[:state] == "open"
|
|
@@ -81,7 +81,7 @@ module Samagotchi
|
|
|
81
81
|
@mutex.synchronize do
|
|
82
82
|
return "No active reminders." if @reminders.empty?
|
|
83
83
|
|
|
84
|
-
lines = @reminders.map do |
|
|
84
|
+
lines = @reminders.map do |_name, r|
|
|
85
85
|
" #{r[:name]} (interval: #{r[:interval_minutes]}m, id: #{r[:id]})"
|
|
86
86
|
end
|
|
87
87
|
"Active reminders:\n" + lines.join("\n")
|
|
@@ -98,6 +98,7 @@ module Samagotchi
|
|
|
98
98
|
@mutex.synchronize do
|
|
99
99
|
@reminders.each_value.filter_map do |r|
|
|
100
100
|
next unless r[:next_fire_at] && now >= r[:next_fire_at]
|
|
101
|
+
|
|
101
102
|
{ name: r[:name], description: r[:description], interval_minutes: r[:interval_minutes] }
|
|
102
103
|
end.freeze
|
|
103
104
|
end
|
|
@@ -33,7 +33,16 @@ module Samagotchi
|
|
|
33
33
|
# "failed", text is the failure's summary when the turn note has one.
|
|
34
34
|
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
35
35
|
# turn ran out at
|
|
36
|
-
|
|
36
|
+
# @!attribute error_kind [String, nil] a "failed" turn's provider error
|
|
37
|
+
# kind (credits, server, auth, …), from last_turn
|
|
38
|
+
# @!attribute retryable [Boolean, nil] whether that error may pass on a
|
|
39
|
+
# retry
|
|
40
|
+
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
41
|
+
# (user, hook, ctrl_c, manual)
|
|
42
|
+
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
43
|
+
# (loop-guard)
|
|
44
|
+
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
45
|
+
:stopped_by, keyword_init: true)
|
|
37
46
|
|
|
38
47
|
module_function
|
|
39
48
|
|
|
@@ -151,7 +160,8 @@ module Samagotchi
|
|
|
151
160
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
|
|
152
161
|
|
|
153
162
|
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
154
|
-
|
|
163
|
+
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
|
|
164
|
+
Result.new(status: :no_reply, outcome: outcome, text: text, **why)
|
|
155
165
|
end
|
|
156
166
|
|
|
157
167
|
# The summary in the failed-turn note at the conversation's tail, nil
|