samagotchi 0.17.0 → 0.18.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 +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +42 -14
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- metadata +8 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "question_slot"
|
|
4
|
+
require_relative "../turn_flow"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class TerminalUI
|
|
@@ -12,7 +13,10 @@ module Samagotchi
|
|
|
12
13
|
# Bridge). An approval (kind "approval") takes only a number, an exact
|
|
13
14
|
# label, y (the first option, Allow once) or n (the last, Deny), plus
|
|
14
15
|
# an optional "; reason"; a substring would pick the wrong one ("y" is
|
|
15
|
-
# in "Deny").
|
|
16
|
+
# in "Deny"). The step-limit question (kind "continue") reads the
|
|
17
|
+
# continue words (TurnFlow.continue_decision): Enter, yes, y or
|
|
18
|
+
# /continue continue; no stops; "no, why" stops with the reason; 1 and
|
|
19
|
+
# 2 pick the options.
|
|
16
20
|
class QuestionPrompt
|
|
17
21
|
DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
|
|
18
22
|
|
|
@@ -51,11 +55,13 @@ module Samagotchi
|
|
|
51
55
|
@multi = !!field.(:multi_select)
|
|
52
56
|
@free = !!field.(:allow_freeform)
|
|
53
57
|
@approval = field.(:kind).to_s == "approval"
|
|
58
|
+
@continue = field.(:kind).to_s == "continue"
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def multi? = @multi
|
|
57
62
|
def free? = @free
|
|
58
63
|
def approval? = @approval
|
|
64
|
+
def continue? = @continue
|
|
59
65
|
|
|
60
66
|
# The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
|
|
61
67
|
# short id), or nil when cleared.
|
|
@@ -77,6 +83,7 @@ module Samagotchi
|
|
|
77
83
|
# What a question closed with no answer here says: a relayed approval
|
|
78
84
|
# names where it went; else "(question cancelled)".
|
|
79
85
|
def closed_text(reason)
|
|
86
|
+
return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
|
|
80
87
|
return "(question cancelled)" unless @delegate
|
|
81
88
|
|
|
82
89
|
case reason.to_s
|
|
@@ -98,6 +105,10 @@ module Samagotchi
|
|
|
98
105
|
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
|
|
99
106
|
end
|
|
100
107
|
|
|
108
|
+
# Why a step-limit question closed unanswered here.
|
|
109
|
+
CONTINUE_CLOSED = { "dropped" => "(dropped: a new prompt came)", "answered" => "(answered with /continue)",
|
|
110
|
+
"superseded" => "(set aside for another question)", "replaced" => "(asked again)" }.freeze
|
|
111
|
+
|
|
101
112
|
# The most diff lines #preview_lines prints; the web shows them all.
|
|
102
113
|
PREVIEW_LINES = 40
|
|
103
114
|
|
|
@@ -138,7 +149,7 @@ module Samagotchi
|
|
|
138
149
|
# @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
|
|
139
150
|
def answer_text(answer)
|
|
140
151
|
picked = answer.selected.to_a
|
|
141
|
-
if approval?
|
|
152
|
+
if approval? || continue?
|
|
142
153
|
choice = picked.first || options.last
|
|
143
154
|
return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
|
|
144
155
|
end
|
|
@@ -150,6 +161,7 @@ module Samagotchi
|
|
|
150
161
|
# @return [Answer]
|
|
151
162
|
def parse(raw)
|
|
152
163
|
return parse_approval(raw) if approval?
|
|
164
|
+
return parse_continue(raw) if continue?
|
|
153
165
|
|
|
154
166
|
raw = raw.to_s.strip
|
|
155
167
|
# "1,3; my text": the first ';' separates the selection from freeform text.
|
|
@@ -178,6 +190,9 @@ module Samagotchi
|
|
|
178
190
|
def mark = approval? ? "! " : "? "
|
|
179
191
|
|
|
180
192
|
def slot_hint
|
|
193
|
+
if continue?
|
|
194
|
+
return "Enter or yes = #{options.first}; no = #{options.last}; no, <reason> = #{options.last} and tell the model why"
|
|
195
|
+
end
|
|
181
196
|
if approval?
|
|
182
197
|
return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
|
|
183
198
|
"Enter alone denies"
|
|
@@ -189,6 +204,23 @@ module Samagotchi
|
|
|
189
204
|
hint.join("; ")
|
|
190
205
|
end
|
|
191
206
|
|
|
207
|
+
# The continue words, or an option's number. Empty is Continue (Enter
|
|
208
|
+
# alone, as at the REPL's continue prompt).
|
|
209
|
+
def parse_continue(raw)
|
|
210
|
+
text = raw.to_s.strip
|
|
211
|
+
if text.match?(/\A\d+\z/)
|
|
212
|
+
idx = text.to_i - 1
|
|
213
|
+
return Answer.new(selected: [options[idx]]) if idx.between?(0, options.size - 1)
|
|
214
|
+
end
|
|
215
|
+
decision, reason = TurnFlow.continue_decision(text)
|
|
216
|
+
case decision
|
|
217
|
+
when :resume then Answer.new(selected: [options.first])
|
|
218
|
+
when :abort then Answer.new(selected: [options.last])
|
|
219
|
+
when :abort_with_reason then Answer.new(selected: [options.last], freeform: reason)
|
|
220
|
+
else Answer.new(error: "Answer yes (Enter alone too), no, or no, <reason>.")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
192
224
|
def parse_approval(raw)
|
|
193
225
|
sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
|
|
194
226
|
free_part = nil if free_part.to_s.empty?
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "io/console"
|
|
6
6
|
require "reline"
|
|
7
7
|
|
|
8
|
+
require_relative "iteration_limit"
|
|
8
9
|
require_relative "model_profile"
|
|
9
10
|
require_relative "turn_note"
|
|
10
11
|
require_relative "config"
|
|
@@ -243,7 +244,7 @@ module Samagotchi
|
|
|
243
244
|
session,
|
|
244
245
|
@prompt,
|
|
245
246
|
on_event: nil,
|
|
246
|
-
max_iterations:
|
|
247
|
+
max_iterations: IterationLimit.for(no_interrupt: true),
|
|
247
248
|
cancel_controller: nil,
|
|
248
249
|
images: ImageInput.extract(@prompt)
|
|
249
250
|
)
|
|
@@ -485,7 +486,7 @@ module Samagotchi
|
|
|
485
486
|
# Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
|
|
486
487
|
# Engine#run_turn, rendering via @renderer.
|
|
487
488
|
# @param images [Array<Hash>] the prompt's `@path` images ({path:})
|
|
488
|
-
def run_engine_turn(session, prompt, continue: false, max_iterations:
|
|
489
|
+
def run_engine_turn(session, prompt, continue: false, max_iterations: IterationLimit.for, images: [])
|
|
489
490
|
cancellation_controller = CancellationController.new
|
|
490
491
|
@active_cancel_controller = cancellation_controller
|
|
491
492
|
@renderer.begin_turn
|
|
@@ -655,6 +656,9 @@ module Samagotchi
|
|
|
655
656
|
return
|
|
656
657
|
end
|
|
657
658
|
return if show_local_command(input)
|
|
659
|
+
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
660
|
+
hint = command_registry.unknown_command_hint(input)
|
|
661
|
+
return @surface.commit(hint) if hint
|
|
658
662
|
|
|
659
663
|
@turn_flow.before_prompt_turn
|
|
660
664
|
persist_recent_history(input)
|
|
@@ -941,6 +945,11 @@ module Samagotchi
|
|
|
941
945
|
# A command, never steering text (/archive waits, back in the prompt).
|
|
942
946
|
return command_during_turn(line) if local || command_registry.command?(line)
|
|
943
947
|
return true if line.strip.empty?
|
|
948
|
+
# A typo of a command (/modle) is not steering text: say so, take it.
|
|
949
|
+
if (hint = command_registry.unknown_command_hint(line))
|
|
950
|
+
@surface.commit(hint)
|
|
951
|
+
return true
|
|
952
|
+
end
|
|
944
953
|
# Steering merges text only: a line with images runs as the next turn.
|
|
945
954
|
return image_line_waits if ImageInput.extract(line).any?
|
|
946
955
|
|
|
@@ -46,11 +46,11 @@ module Samagotchi
|
|
|
46
46
|
},
|
|
47
47
|
start_line: {
|
|
48
48
|
type: "integer",
|
|
49
|
-
description: "Optional start line (1-based, inclusive).
|
|
49
|
+
description: "Optional start line (1-based, inclusive). Without end_line, reads to the end of the file."
|
|
50
50
|
},
|
|
51
51
|
end_line: {
|
|
52
52
|
type: "integer",
|
|
53
|
-
description: "Optional end line (1-based, inclusive).
|
|
53
|
+
description: "Optional end line (1-based, inclusive). Without start_line, reads from line 1."
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
required: ["path"]
|
|
@@ -139,7 +139,7 @@ module Samagotchi
|
|
|
139
139
|
when :canceled
|
|
140
140
|
canceled_result(child_id)
|
|
141
141
|
when :no_reply
|
|
142
|
-
result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
|
|
142
|
+
result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
|
|
143
143
|
when :worker_gone
|
|
144
144
|
result(child_id, status,
|
|
145
145
|
"the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
|
|
@@ -168,8 +168,8 @@ module Samagotchi
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
# ParentReport's line for a turn that left no reply, about the child.
|
|
171
|
-
def no_reply_text(wait)
|
|
172
|
-
ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
|
|
171
|
+
def no_reply_text(wait, child_id)
|
|
172
|
+
ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
def result(child_id, status, text)
|
|
@@ -184,9 +184,18 @@ module Samagotchi
|
|
|
184
184
|
|
|
185
185
|
# The whole question, as `chi send --wait` prints it (ParentReport):
|
|
186
186
|
# its text, options and the commands that answer it.
|
|
187
|
+
# A step-limit question (kind continue) is the parent model's to
|
|
188
|
+
# decide (it isn't relayed: Continue grants no permission): continue
|
|
189
|
+
# it with chi answer through execute, send a follow-up instead, or
|
|
190
|
+
# report back.
|
|
187
191
|
def waiting_text(child_id, pending)
|
|
188
|
-
"Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
189
|
-
|
|
192
|
+
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
193
|
+
if ParentReport.continue?(pending)
|
|
194
|
+
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
195
|
+
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
196
|
+
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
197
|
+
end
|
|
198
|
+
"#{text}delegate_result #{child_id} waits again once it is answered."
|
|
190
199
|
end
|
|
191
200
|
|
|
192
201
|
def cut(text)
|
|
@@ -62,6 +62,9 @@ module Samagotchi
|
|
|
62
62
|
"Error: #{e.message}"
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
START_LINE_REQUIRED = "Error: end_line alone isn't enough for a range edit; " \
|
|
66
|
+
"pass start_line too (1-based, the first line to replace)"
|
|
67
|
+
|
|
65
68
|
def self.apply_range_mode(new_text, path:, start_line:, end_line:, read:)
|
|
66
69
|
return "Error: file not found: #{path}" unless File.exist?(path)
|
|
67
70
|
return NEW_TEXT_REQUIRED if new_text.nil?
|
|
@@ -70,7 +73,9 @@ module Samagotchi
|
|
|
70
73
|
|
|
71
74
|
start_num = parse_positive_line_number(start_line, "start_line")
|
|
72
75
|
return start_num if start_num.is_a?(String)
|
|
73
|
-
|
|
76
|
+
# Not defaulted to 1 like read: a range edit replaces the span, so a
|
|
77
|
+
# guessed start would silently overwrite the top of the file.
|
|
78
|
+
return START_LINE_REQUIRED if start_num.nil?
|
|
74
79
|
|
|
75
80
|
source = read.(path)
|
|
76
81
|
lines = source.lines
|
|
@@ -15,11 +15,12 @@ module Samagotchi
|
|
|
15
15
|
# <tool name="execute">ruby path/to/script.rb</tool>
|
|
16
16
|
class Execute
|
|
17
17
|
NAME = "execute"
|
|
18
|
-
TIMEOUT_SEC =
|
|
18
|
+
TIMEOUT_SEC = 120
|
|
19
19
|
STOP_GRACE_SEC = 1.0
|
|
20
20
|
STOP_POLL_INTERVAL_SEC = 0.05
|
|
21
21
|
WAIT_SLICE_SEC = 0.2
|
|
22
22
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
23
|
+
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
23
24
|
|
|
24
25
|
def self.name = NAME
|
|
25
26
|
|
|
@@ -51,9 +52,9 @@ module Samagotchi
|
|
|
51
52
|
parts << (silent ? "exit: #{status.exitstatus} (no output)" : "exit: #{status.exitstatus}")
|
|
52
53
|
parts.join("\n")
|
|
53
54
|
rescue CommandTimedOut => e
|
|
54
|
-
# Keep the "Error:" first line (callers classify on it)
|
|
55
|
-
#
|
|
56
|
-
stopped_result("Error: command timed out after #{timeout_sec}s", e)
|
|
55
|
+
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
56
|
+
# a long command, and return whatever it printed before it was killed.
|
|
57
|
+
stopped_result("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
57
58
|
rescue CommandCancelled => e
|
|
58
59
|
stopped_result("Error: command stopped by the user after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
59
60
|
rescue => e
|
|
@@ -124,7 +124,8 @@ module Samagotchi
|
|
|
124
124
|
|
|
125
125
|
start_num = parse_positive_line_number(start_line, "start_line")
|
|
126
126
|
return start_num if start_num.is_a?(String)
|
|
127
|
-
|
|
127
|
+
# end_line alone reads from the top of the file.
|
|
128
|
+
start_num ||= 1
|
|
128
129
|
|
|
129
130
|
end_provided = !blank?(end_line)
|
|
130
131
|
end_num = end_provided ? parse_positive_line_number(end_line, "end_line") : nil
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -108,12 +108,13 @@ module Samagotchi
|
|
|
108
108
|
@checkpoint = nil
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Answer the offer with no: the interrupted turn
|
|
112
|
-
#
|
|
111
|
+
# Answer the offer with no (Stop): the interrupted turn stays, as
|
|
112
|
+
# after a Ctrl-C or a new prompt (!rollback still erases it), and the
|
|
113
|
+
# model reads that it wasn't continued: a note, or the user's reason
|
|
114
|
+
# (with a summary of that turn).
|
|
113
115
|
def abort_continue!(reason: nil)
|
|
114
|
-
|
|
115
|
-
@
|
|
116
|
-
@engine.append_messages([{ role: "user", content: reason_message(reason) }]) if reason
|
|
116
|
+
message = reason ? { role: "user", content: reason_message(reason) } : TurnNote.not_continued
|
|
117
|
+
@engine.append_messages([message])
|
|
117
118
|
@offer = nil
|
|
118
119
|
end
|
|
119
120
|
|
|
@@ -212,9 +213,9 @@ module Samagotchi
|
|
|
212
213
|
|
|
213
214
|
def reason_message(reason)
|
|
214
215
|
context = @offer && @offer[:context]
|
|
215
|
-
lines = ["I chose not to continue the
|
|
216
|
+
lines = ["I chose not to continue the turn that ran out of steps because: #{reason}"]
|
|
216
217
|
lines << ""
|
|
217
|
-
lines << "Interrupted turn summary:"
|
|
218
|
+
lines << "Its work so far stays. Interrupted turn summary:"
|
|
218
219
|
original_prompt = context && context[:original_prompt]
|
|
219
220
|
lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
|
|
220
221
|
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -131,6 +131,13 @@ module Samagotchi
|
|
|
131
131
|
(entry[:kind] || entry["kind"]).to_s == KIND
|
|
132
132
|
end
|
|
133
133
|
|
|
134
|
+
# The step-limit offer answered Stop (no reason): the partial turn
|
|
135
|
+
# stays, and the model reads that it wasn't continued.
|
|
136
|
+
def not_continued
|
|
137
|
+
message("the previous turn ran out of steps before it answered, and the user chose not to continue it. " \
|
|
138
|
+
"Its work so far (tool calls, file changes) stays; wait for the user's next message.")
|
|
139
|
+
end
|
|
140
|
+
|
|
134
141
|
def message(text)
|
|
135
142
|
{ role: "system", content: "#{OPEN}#{text}#{CLOSE}", kind: KIND }
|
|
136
143
|
end
|