samagotchi 0.85.0 → 0.87.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 +76 -1
- data/bin/chi +39 -2
- data/docs/architecture.md +36 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +51 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +112 -39
- data/docs/hooks.md +19 -3
- data/docs/internals/anthropic.md +2 -2
- data/docs/internals/bundles.md +11 -2
- data/docs/internals/context-telemetry.md +19 -1
- data/docs/internals/engine.md +1 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +59 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +19 -7
- data/docs/sub-agent.md +39 -25
- data/docs/testing.md +7 -2
- data/lib/samagotchi/answer_command.rb +21 -25
- data/lib/samagotchi/bridge/client_event.rb +50 -0
- data/lib/samagotchi/bridge/sse_writer.rb +6 -2
- data/lib/samagotchi/bridge.rb +22 -11
- data/lib/samagotchi/bridge_client.rb +2 -2
- data/lib/samagotchi/bundle_command/check.rb +348 -0
- data/lib/samagotchi/bundle_command/scaffold.rb +135 -0
- data/lib/samagotchi/bundle_command/test_run.rb +89 -0
- data/lib/samagotchi/bundle_command.rb +125 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
- data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -3
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/ruby/manifest.yml +11 -0
- data/lib/samagotchi/bundles/ruby/plugin.rb +74 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cli/parent_wait.rb +3 -1
- data/lib/samagotchi/client_id.rb +1 -1
- data/lib/samagotchi/config.rb +195 -115
- data/lib/samagotchi/context_status.rb +1 -1
- data/lib/samagotchi/continue_offer.rb +2 -1
- data/lib/samagotchi/engine.rb +4 -4
- data/lib/samagotchi/guardrail_wiring.rb +4 -0
- data/lib/samagotchi/guardrails/approval.rb +12 -2
- data/lib/samagotchi/guardrails/load_failures.rb +17 -3
- data/lib/samagotchi/guardrails/parent_approvals.rb +14 -70
- data/lib/samagotchi/guardrails/parent_policy.rb +327 -0
- data/lib/samagotchi/guardrails/verdict.rb +6 -3
- data/lib/samagotchi/hook_routes.rb +5 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +3 -1
- data/lib/samagotchi/hooks/registry.rb +13 -6
- data/lib/samagotchi/hooks.rb +5 -0
- data/lib/samagotchi/kernel_loop.rb +5 -2
- data/lib/samagotchi/llm/anthropic_assembly.rb +1 -1
- data/lib/samagotchi/memory_bundle/shas.rb +127 -0
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/parent_answers_command.rb +80 -0
- data/lib/samagotchi/parent_report.rb +122 -30
- data/lib/samagotchi/pending_question.rb +8 -4
- data/lib/samagotchi/plugin/api.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +11 -5
- data/lib/samagotchi/plugin/loader.rb +5 -3
- data/lib/samagotchi/question_desk.rb +39 -25
- data/lib/samagotchi/queued_command.rb +5 -4
- data/lib/samagotchi/relay.rb +5 -1
- data/lib/samagotchi/relay_verifier.rb +4 -5
- data/lib/samagotchi/reply_wait.rb +14 -5
- data/lib/samagotchi/send_command.rb +103 -12
- data/lib/samagotchi/session.rb +26 -2
- data/lib/samagotchi/session_commands.rb +45 -8
- data/lib/samagotchi/session_setup.rb +10 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -8
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -12
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +20 -11
- data/lib/samagotchi/testing/boundary.rb +0 -3
- data/lib/samagotchi/testing/bundle.rb +7 -0
- data/lib/samagotchi/testing/host.rb +6 -4
- data/lib/samagotchi/testing/install.rb +119 -0
- data/lib/samagotchi/testing/recorder.rb +13 -4
- data/lib/samagotchi/testing.rb +1 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/tools/delegate_wait.rb +16 -7
- data/lib/samagotchi/tools/task_runtime.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +64 -2
- data/lib/samagotchi/turn_flow.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -10
- data/lib/samagotchi/web/public/chunk_router.js +8 -19
- data/lib/samagotchi/web/public/ctx.js +12 -26
- data/lib/samagotchi/web/public/stream_handlers.js +1 -1
- data/lib/samagotchi/web/server.rb +4 -0
- data/lib/samagotchi/worker.rb +27 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +12 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
data/lib/samagotchi/testing.rb
CHANGED
|
@@ -22,5 +22,6 @@ require_relative "testing/recording_api"
|
|
|
22
22
|
require_relative "testing/waiting"
|
|
23
23
|
require_relative "testing/fake_executable"
|
|
24
24
|
require_relative "testing/bundle"
|
|
25
|
+
require_relative "testing/install"
|
|
25
26
|
require_relative "testing/helpers"
|
|
26
27
|
require_relative "testing/boundary"
|
|
@@ -118,7 +118,8 @@ module Samagotchi
|
|
|
118
118
|
|
|
119
119
|
# What execute and task_create export into the commands they run: a
|
|
120
120
|
# chi started there answers as a parent agent
|
|
121
|
-
# (Guardrails::ParentApprovals.parent_process
|
|
121
|
+
# (Guardrails::ParentApprovals.parent_process?, held to
|
|
122
|
+
# Guardrails::ParentPolicy), even under a PTY
|
|
122
123
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
123
124
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
124
125
|
# /model), which chi self reports; unset when unknown, so a value
|
|
@@ -134,7 +134,10 @@ module Samagotchi
|
|
|
134
134
|
"(the most is #{max}, #{MAX_CHILDREN_KEY}): #{ids}. #{full_hint(peers)}"
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# The parent's own parent_answers, as it is now (a snapshot): the
|
|
138
|
+
# child's answers are held at least as tight.
|
|
139
|
+
setup = SessionSetup.new(toolkit: toolkit.empty? ? nil : Toolkit.new(name: toolkit),
|
|
140
|
+
parent_answers: parent.parent_answers)
|
|
138
141
|
child = SessionManager.spawn_session(prompt: task, working_directory: folder,
|
|
139
142
|
model_name: child_model(model, parent), memories: CHILD_MEMORIES,
|
|
140
143
|
parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
|
|
@@ -213,6 +213,10 @@ module Samagotchi
|
|
|
213
213
|
# @return [String]
|
|
214
214
|
def finish(wait, child_id, timeout:, report: false, reports: "off")
|
|
215
215
|
status = ParentReport.status(wait)
|
|
216
|
+
# What the parent may pick on the child's question: config.yml
|
|
217
|
+
# tightened by the child session's own parent_answers (the session
|
|
218
|
+
# the wait read; none: config.yml's).
|
|
219
|
+
strategy = Guardrails::ParentPolicy.for_session(wait.session)
|
|
216
220
|
case wait.status
|
|
217
221
|
when :done
|
|
218
222
|
reply_result(child_id, status, wait.text)
|
|
@@ -221,11 +225,11 @@ module Samagotchi
|
|
|
221
225
|
when :stopped
|
|
222
226
|
result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
223
227
|
when :waiting_for_answer
|
|
224
|
-
result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports))
|
|
228
|
+
result(child_id, status, waiting_text(child_id, wait.question, report: report, reports: reports, strategy: strategy))
|
|
225
229
|
when :canceled
|
|
226
230
|
canceled_result(child_id, reports: reports)
|
|
227
231
|
when :no_reply
|
|
228
|
-
result(child_id, status, "#{no_reply_text(wait, child_id)}; its session shows what happened")
|
|
232
|
+
result(child_id, status, "#{no_reply_text(wait, child_id, strategy: strategy)}; its session shows what happened")
|
|
229
233
|
when :worker_gone
|
|
230
234
|
result(child_id, status,
|
|
231
235
|
"the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
|
|
@@ -266,8 +270,8 @@ module Samagotchi
|
|
|
266
270
|
end
|
|
267
271
|
|
|
268
272
|
# ParentReport's line for a turn that left no reply, about the child.
|
|
269
|
-
def no_reply_text(wait, child_id)
|
|
270
|
-
ParentReport.no_reply_line(wait, child_id).sub(/\Athe turn/, "the child's turn")
|
|
273
|
+
def no_reply_text(wait, child_id, strategy: nil)
|
|
274
|
+
ParentReport.no_reply_line(wait, child_id, strategy: strategy).sub(/\Athe turn/, "the child's turn")
|
|
271
275
|
end
|
|
272
276
|
|
|
273
277
|
def result(child_id, status, text)
|
|
@@ -303,12 +307,17 @@ module Samagotchi
|
|
|
303
307
|
# A report's last line differs (+report+): "waits again" made models
|
|
304
308
|
# call delegate_result right after answering Continue.
|
|
305
309
|
# The tool's last line follows #reports_mode.
|
|
306
|
-
def waiting_text(child_id, pending, report: false, reports: "off")
|
|
307
|
-
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}"
|
|
308
|
-
if ParentReport.continue?(pending)
|
|
310
|
+
def waiting_text(child_id, pending, report: false, reports: "off", strategy: nil)
|
|
311
|
+
text = "Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id, strategy: strategy)}"
|
|
312
|
+
if ParentReport.continue?(pending) && ParentReport.may_continue?(pending, strategy: strategy)
|
|
309
313
|
text += "The child ran out of steps before it answered. Decide: continue it (run the chi answer command " \
|
|
310
314
|
"above with execute) if it is getting somewhere; send it a narrower follow-up with delegate " \
|
|
311
315
|
"session: #{child_id} (that drops the question); or stop it and report back to your user.\n"
|
|
316
|
+
elsif ParentReport.continue?(pending)
|
|
317
|
+
# guardrails.parent_answers: hard: continuing is the user's.
|
|
318
|
+
text += "The child ran out of steps before it answered. Decide: send it a narrower follow-up with delegate " \
|
|
319
|
+
"session: #{child_id} (that drops the question); or stop it (the chi answer Stop command above, " \
|
|
320
|
+
"with execute) and report back to your user, who may continue it.\n"
|
|
312
321
|
end
|
|
313
322
|
return "#{text}#{REPORT_NEXT_REPLY}" if report
|
|
314
323
|
return "#{text}delegate_result #{child_id} waits again once it is answered." if reports == "off"
|
|
@@ -100,15 +100,15 @@ module Samagotchi
|
|
|
100
100
|
# between (failed, no exit code) is corrected; a stopped task (its
|
|
101
101
|
# stop flag on disk) is left as task_stop records it.
|
|
102
102
|
# The thread is named EXIT_STATUS_THREAD (a spec joins them before it
|
|
103
|
-
# removes the tasks' folder)
|
|
103
|
+
# removes the tasks' folder) here, before it returns: named from
|
|
104
|
+
# inside, a thread not yet scheduled was missed by that join.
|
|
104
105
|
# @return [Thread]
|
|
105
106
|
def record_exit_status(waiter, path, task_id:)
|
|
106
107
|
# Absolute now: safe against a later chdir.
|
|
107
108
|
record_path = File.expand_path(metadata_path(task_id))
|
|
108
109
|
path = File.expand_path(path)
|
|
109
|
-
Thread.new do
|
|
110
|
+
thread = Thread.new do
|
|
110
111
|
Thread.current.report_on_exception = false
|
|
111
|
-
Thread.current.name = EXIT_STATUS_THREAD
|
|
112
112
|
code = status_code(waiter.value)
|
|
113
113
|
next if code.nil? || record_at(record_path)&.stop_requested_by
|
|
114
114
|
|
|
@@ -117,6 +117,8 @@ module Samagotchi
|
|
|
117
117
|
rescue StandardError => e
|
|
118
118
|
Log.debug(:turn, "task_exit_code_failed", id: task_id, error: e.class.name, msg: e.message)
|
|
119
119
|
end
|
|
120
|
+
thread.name = EXIT_STATUS_THREAD
|
|
121
|
+
thread
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
# A Process::Status as a shell reports one: its exit status, or
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
3
5
|
require "time"
|
|
6
|
+
require_relative "atomic_file"
|
|
4
7
|
require_relative "interrupted_turn"
|
|
8
|
+
require_relative "last_turn"
|
|
5
9
|
require_relative "log"
|
|
6
10
|
require_relative "session"
|
|
11
|
+
require_relative "session_inbox"
|
|
7
12
|
require_relative "steer"
|
|
8
13
|
require_relative "turn_note"
|
|
9
14
|
|
|
@@ -39,9 +44,12 @@ module Samagotchi
|
|
|
39
44
|
# last_turn is left as it was: a new last_turn saved idle here, before
|
|
40
45
|
# the turn a `chi send --wait` started this worker for, would end that
|
|
41
46
|
# wait at once as the sent turn's (ReplyWait compares last_turn), with
|
|
42
|
-
# the dead turn's outcome.
|
|
47
|
+
# the dead turn's outcome. A mark with an enqueued_id records the dead
|
|
48
|
+
# turn's end under that id instead (+session_dir+, #settled_turn): only
|
|
49
|
+
# a wait for that very turn (ReplyWait's turn:) reads it.
|
|
50
|
+
# @param session_dir [String, nil] where to record it; nil: nowhere
|
|
43
51
|
# @return [InterruptedTurn, nil] the mark settled, nil when there was none
|
|
44
|
-
def self.settle(session)
|
|
52
|
+
def self.settle(session, session_dir: nil)
|
|
45
53
|
mark = session.interrupted_turn
|
|
46
54
|
return nil unless mark
|
|
47
55
|
|
|
@@ -54,9 +62,63 @@ module Samagotchi
|
|
|
54
62
|
session.messages = messages + [note]
|
|
55
63
|
session.status = Session::STATUS_IDLE if session.status == Session::STATUS_RUNNING
|
|
56
64
|
session.interrupted_turn = nil
|
|
65
|
+
record_settled(session_dir, mark, seconds) if session_dir
|
|
57
66
|
mark
|
|
58
67
|
end
|
|
59
68
|
|
|
69
|
+
# Where a per-input terminal outcome is kept: input/settled/<enqueued_id>,
|
|
70
|
+
# outside the *.json glob like SessionInbox's removed tombstones.
|
|
71
|
+
SETTLED_DIR = "settled"
|
|
72
|
+
WORKER_STOPPED = "worker_stopped"
|
|
73
|
+
|
|
74
|
+
# The terminal outcome recorded for +enqueued_id+.
|
|
75
|
+
# @return [LastTurn, nil] nil when no settle recorded one
|
|
76
|
+
def self.settled_turn(session_dir, enqueued_id)
|
|
77
|
+
id = enqueued_id.to_s
|
|
78
|
+
return nil unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
79
|
+
|
|
80
|
+
raw = File.read(File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR, id))
|
|
81
|
+
LastTurn.from_file(JSON.parse(raw)) || settled_record(id)
|
|
82
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
83
|
+
nil
|
|
84
|
+
rescue JSON::ParserError, SystemCallError
|
|
85
|
+
# The file says the turn was settled even when it can't be read.
|
|
86
|
+
settled_record(id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.settled_record(id, ended_at: nil, seconds: nil)
|
|
90
|
+
LastTurn.new(outcome: "canceled", cancel_reason: WORKER_STOPPED, ended_at: ended_at, seconds: seconds, enqueued_id: id)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :settled_record
|
|
93
|
+
|
|
94
|
+
# A file-delivered command ends its sender's wait without running a
|
|
95
|
+
# turn. Keep a terminal outcome under the same per-input settled path.
|
|
96
|
+
def self.record_command_outcome(session_dir, enqueued_id)
|
|
97
|
+
id = enqueued_id.to_s
|
|
98
|
+
return if id.empty? || !SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
99
|
+
|
|
100
|
+
dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
|
|
101
|
+
FileUtils.mkdir_p(dir)
|
|
102
|
+
record = LastTurn.new(outcome: "completed", ended_at: Time.now.iso8601(3), enqueued_id: id)
|
|
103
|
+
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
104
|
+
rescue SystemCallError, IOError => e
|
|
105
|
+
Log.warn(:worker, "command_outcome_write_failed", error: e.class.name, msg: e.message, enqueued_id: id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def self.record_settled(session_dir, mark, seconds)
|
|
109
|
+
id = mark.enqueued_id.to_s
|
|
110
|
+
return unless SessionInbox::ENQUEUED_ID_NAME.match?(id)
|
|
111
|
+
|
|
112
|
+
dir = File.join(session_dir, SessionInbox::INPUT_DIR, SETTLED_DIR)
|
|
113
|
+
FileUtils.mkdir_p(dir)
|
|
114
|
+
record = settled_record(id, ended_at: Time.now.iso8601(3), seconds: seconds)
|
|
115
|
+
AtomicFile.write(File.join(dir, id), JSON.generate(record.to_file))
|
|
116
|
+
rescue SystemCallError => e
|
|
117
|
+
# The settle goes on; a wait for that turn runs to its timeout.
|
|
118
|
+
Log.warn(:worker, "settled_turn_write_failed", error: e.class.name, msg: e.message)
|
|
119
|
+
end
|
|
120
|
+
private_class_method :record_settled
|
|
121
|
+
|
|
60
122
|
# @param session [Session] the worker's session (saved as a whole)
|
|
61
123
|
# @param clock [#call] monotonic seconds (specs)
|
|
62
124
|
def initialize(session:, state_dir:, interval: INTERVAL, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -23,6 +23,16 @@ module Samagotchi
|
|
|
23
23
|
|
|
24
24
|
# Read an answer to the continue offer.
|
|
25
25
|
# @return [Array(Symbol, String|nil)] [:resume | :abort | :abort_with_reason | :invalid, reason]
|
|
26
|
+
# Whether +line+ is a /continue that continues the turn (/continue,
|
|
27
|
+
# /continue yes): what a parent agent's must be checked for.
|
|
28
|
+
def self.continue_yes?(line)
|
|
29
|
+
text = line.to_s.strip
|
|
30
|
+
return false unless text.match?(/\A#{Regexp.escape(CONTINUE_COMMAND)}(\s|\z)/)
|
|
31
|
+
|
|
32
|
+
answer = text.delete_prefix(CONTINUE_COMMAND).strip
|
|
33
|
+
continue_decision(answer.empty? ? CONTINUE_COMMAND : answer).first == :resume
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def self.continue_decision(input)
|
|
27
37
|
normalized = input.to_s.strip
|
|
28
38
|
return [:resume, nil] if normalized.empty?
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -926,14 +926,10 @@ module Samagotchi
|
|
|
926
926
|
state = nil unless state.is_a?(Hash)
|
|
927
927
|
if state
|
|
928
928
|
status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
# session: refs aren't checked.
|
|
934
|
-
cwd = tail["working_directory"]
|
|
935
|
-
cwd = @hub&.working_directory(id) unless cwd.is_a?(String) && !cwd.empty?
|
|
936
|
-
messages = last_assistant_for_display([tail["answer"]].compact, cwd: cwd)
|
|
929
|
+
answer = tail["answer"]
|
|
930
|
+
cwd = tail_working_directory(id, tail, answer)
|
|
931
|
+
answer = without_file_refs(answer) unless cwd
|
|
932
|
+
messages = last_assistant_for_display([answer].compact, cwd: cwd)
|
|
937
933
|
else
|
|
938
934
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
939
935
|
status = displayed_status(session)
|
|
@@ -956,6 +952,35 @@ module Samagotchi
|
|
|
956
952
|
error_response(404, "not_found", e.message)
|
|
957
953
|
end
|
|
958
954
|
|
|
955
|
+
# The folder the live tail's answer's file refs must stay under: the
|
|
956
|
+
# worker's (its tail's working_directory), else the hub's session list
|
|
957
|
+
# entry's (a worker from before the field, or read through its
|
|
958
|
+
# snapshot), else, only when the answer has refs to check, the session
|
|
959
|
+
# file's, as the full read takes it. nil when none is known: the
|
|
960
|
+
# caller drops the file refs rather than show them unchecked.
|
|
961
|
+
# @return [String, nil]
|
|
962
|
+
def tail_working_directory(id, tail, answer)
|
|
963
|
+
known = ->(dir) { dir.is_a?(String) && !dir.empty? }
|
|
964
|
+
cwd = tail["working_directory"]
|
|
965
|
+
cwd = @hub&.working_directory(id) unless known.call(cwd)
|
|
966
|
+
return cwd if known.call(cwd)
|
|
967
|
+
return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
|
|
968
|
+
|
|
969
|
+
cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
|
|
970
|
+
known.call(cwd) ? cwd : nil
|
|
971
|
+
rescue ArgumentError, KeyError, SystemCallError
|
|
972
|
+
nil
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# +answer+ without its file refs (the rest kept), for a read that
|
|
976
|
+
# knows no folder to check them against.
|
|
977
|
+
def without_file_refs(answer)
|
|
978
|
+
return answer unless answer.is_a?(Hash)
|
|
979
|
+
|
|
980
|
+
key = [AnswerRefs::KEY.to_s, AnswerRefs::KEY].find { |name| answer[name].is_a?(Array) } or return answer
|
|
981
|
+
answer.merge(key => answer[key].reject { |ref| ref.is_a?(Hash) && (ref["kind"] || ref[:kind]).to_s == Ref::FILE })
|
|
982
|
+
end
|
|
983
|
+
|
|
959
984
|
# ?timing=1: the whole timing (every record, analytics.json merged with
|
|
960
985
|
# the live ones) and turn_count, when the page's merge comes up short.
|
|
961
986
|
# The worker's GET state, never its messages.
|
|
@@ -1572,7 +1597,7 @@ module Samagotchi
|
|
|
1572
1597
|
return [200, headers.merge("rack.hijack" => hijack), []]
|
|
1573
1598
|
end
|
|
1574
1599
|
|
|
1575
|
-
query = req
|
|
1600
|
+
query = stream_query(req)
|
|
1576
1601
|
[200, headers, ProxyStreamBody.new(host: DEFAULT_HOST, port: bridge_port, session_id: id, query: query, headers: req.env,
|
|
1577
1602
|
server_running: @server_running)]
|
|
1578
1603
|
end
|
|
@@ -1616,13 +1641,26 @@ module Samagotchi
|
|
|
1616
1641
|
[200, headers, body]
|
|
1617
1642
|
end
|
|
1618
1643
|
|
|
1644
|
+
# The stream's query as the worker's Bridge gets it ("?..." or ""):
|
|
1645
|
+
# every parameter but `raw`. `?raw=1` is the worker port's debugging view
|
|
1646
|
+
# (a chunk's provider payload); a browser never gets it through here.
|
|
1647
|
+
# Keys are compared decoded, as the Bridge reads them.
|
|
1648
|
+
def stream_query(req)
|
|
1649
|
+
pairs = req.query_string.to_s.split("&").reject do |pair|
|
|
1650
|
+
URI.decode_www_form_component(pair.split("=", 2).first.to_s) == "raw"
|
|
1651
|
+
rescue ArgumentError
|
|
1652
|
+
false
|
|
1653
|
+
end
|
|
1654
|
+
pairs.empty? ? "" : "?#{pairs.join("&")}"
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1619
1657
|
# Rack hijack lambda (env["rack.hijack?"] truthy): pipes the bridge's SSE
|
|
1620
1658
|
# frames directly to the client socket until it disconnects. The handler
|
|
1621
1659
|
# (WEBrick via rackup) has already sent the status line + headers, so
|
|
1622
1660
|
# only body bytes go to the socket here.
|
|
1623
1661
|
def sse_hijack(id, req, port)
|
|
1624
1662
|
lambda do |io|
|
|
1625
|
-
query = req
|
|
1663
|
+
query = stream_query(req)
|
|
1626
1664
|
body = ProxyStreamBody.new(host: DEFAULT_HOST, port: port, session_id: id, query: query, headers: req.env,
|
|
1627
1665
|
server_running: @server_running)
|
|
1628
1666
|
body.each { |chunk| io.write(chunk) }
|
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
// Pure routing for one :generation_chunk event into the two render lanes.
|
|
2
2
|
//
|
|
3
|
-
// Every :generation_chunk the server sends carries
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// `content` as text.
|
|
10
|
-
//
|
|
11
|
-
// Presence is checked with `typeof === "string"` (not truthiness) so an empty
|
|
12
|
-
// `text` (e.g. a chunk that is entirely a tool_call body) still selects the new
|
|
13
|
-
// wire instead of accidentally falling back to raw `content`.
|
|
3
|
+
// Every :generation_chunk the server sends carries `text` (visible prose,
|
|
4
|
+
// thinking + tool_call blocks removed) and `thinking` (thinking-only): the
|
|
5
|
+
// native loop splits its stream per generation for every profile (Gemma's
|
|
6
|
+
// thought channel included), the chat loop gets reasoning apart from the
|
|
7
|
+
// answer. The raw stream text and the provider's payload stay in the worker
|
|
8
|
+
// (Bridge::ClientEvent); a missing or non-string field is an empty lane.
|
|
14
9
|
export function routeChunk(data) {
|
|
15
10
|
data = data || {};
|
|
16
|
-
if (typeof data.text === "string" || typeof data.thinking === "string") {
|
|
17
|
-
return {
|
|
18
|
-
text: typeof data.text === "string" ? data.text : "",
|
|
19
|
-
thinking: typeof data.thinking === "string" ? data.thinking : "",
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
11
|
return {
|
|
23
|
-
text: typeof data.
|
|
24
|
-
thinking: "",
|
|
12
|
+
text: typeof data.text === "string" ? data.text : "",
|
|
13
|
+
thinking: typeof data.thinking === "string" ? data.thinking : "",
|
|
25
14
|
};
|
|
26
15
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
// Context-window usage (%) from a stream event
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Context-window usage (%) from a stream event: a :context_status's
|
|
2
|
+
// estimated_pct, or a :generation_chunk's `counts` (chi's own counts, which
|
|
3
|
+
// the worker's Bridge works out from the provider's payload; the payload
|
|
4
|
+
// itself never reaches a client). `windowTokens` is the window the kernel
|
|
5
|
+
// resolved for this generation (:generation_started carries it as
|
|
6
|
+
// context_window_tokens); a chunk's counts carry a window only when the server
|
|
7
|
+
// reported one (llama.cpp's /completion doesn't), and that one wins.
|
|
5
8
|
// `budgetTokens` is the session's LLM context budget (llm_context's
|
|
6
9
|
// budget_tokens): a percentage this file computes counts against it when it
|
|
7
10
|
// is smaller than the window, as the kernel's :context_status does
|
|
@@ -20,32 +23,15 @@ function countedTokens(windowTokens, budgetTokens = null) {
|
|
|
20
23
|
return budget && budget > 0 ? Math.min(budget, window) : window;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
// Tokens in context after this chunk: prompt + generated so far.
|
|
24
|
-
function usedTokens(p) {
|
|
25
|
-
if (!p || typeof p !== "object") return null;
|
|
26
|
-
if (num(p.n_past)) return p.n_past;
|
|
27
|
-
if (num(p.usage?.total_tokens)) return p.usage.total_tokens;
|
|
28
|
-
const prompt = num(p.usage?.prompt_tokens) ?? num(p.tokens_evaluated) ?? num(p.timings?.prompt_n);
|
|
29
|
-
const completion = num(p.usage?.completion_tokens) ?? num(p.tokens_predicted) ?? num(p.timings?.predicted_n);
|
|
30
|
-
if (prompt === null && completion === null) return null;
|
|
31
|
-
return (prompt ?? 0) + (completion ?? 0);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
export function extractCtxPct(data, windowTokens = null, budgetTokens = null) {
|
|
35
27
|
if (!data || typeof data !== "object") return null;
|
|
36
|
-
if (num(data.ctx_pct) !== null) return data.ctx_pct;
|
|
37
|
-
if (num(data.est_pct) !== null) return data.est_pct;
|
|
38
|
-
if (num(data.ctxPct) !== null) return data.ctxPct;
|
|
39
28
|
// The kernel's :context_status event: { usage: { estimated_pct, ... }, bucket }.
|
|
40
29
|
if (num(data.usage?.estimated_pct) !== null) return data.usage.estimated_pct;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
if (num(p.ctx_pct) !== null) return p.ctx_pct;
|
|
47
|
-
const against = countedTokens(num(p.n_ctx) || num(windowTokens), budgetTokens);
|
|
48
|
-
const used = usedTokens(p);
|
|
30
|
+
// A chunk's counts: tokens in context after it (prompt + generated so far).
|
|
31
|
+
const counts = data.counts;
|
|
32
|
+
if (!counts || typeof counts !== "object") return null;
|
|
33
|
+
const used = num(counts.total_tokens);
|
|
34
|
+
const against = countedTokens(num(counts.context_window_tokens) || num(windowTokens), budgetTokens);
|
|
49
35
|
if (!against || used === null) return null;
|
|
50
36
|
return (used / against) * 100;
|
|
51
37
|
}
|
|
@@ -61,7 +61,7 @@ export function createStreamHandlers(deps) {
|
|
|
61
61
|
return {
|
|
62
62
|
generation_chunk: (data) => {
|
|
63
63
|
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
64
|
-
// chunk_router.js).
|
|
64
|
+
// chunk_router.js).
|
|
65
65
|
const { text, thinking } = routeChunk(data);
|
|
66
66
|
liveTurn.timing.setNote("");
|
|
67
67
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
|
|
@@ -120,6 +120,9 @@ module Samagotchi
|
|
|
120
120
|
def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
|
|
121
121
|
view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
122
122
|
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path)
|
|
123
|
+
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
124
|
+
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
125
|
+
end
|
|
123
126
|
port = resolve_port(port)
|
|
124
127
|
host = resolve_host(host)
|
|
125
128
|
url ||= "http://#{url_host(host)}:#{port}/"
|
|
@@ -166,6 +169,7 @@ module Samagotchi
|
|
|
166
169
|
warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
|
|
167
170
|
false
|
|
168
171
|
ensure
|
|
172
|
+
config_restart_unsubscribe&.call
|
|
169
173
|
hub&.stop
|
|
170
174
|
Samagotchi::Log.info(:web, "stop") if app
|
|
171
175
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -229,6 +229,9 @@ module Samagotchi
|
|
|
229
229
|
# The running turn, on disk at its iteration boundaries.
|
|
230
230
|
@checkpoint = TurnCheckpoint.new(session: @session, state_dir: @state_dir)
|
|
231
231
|
@engine = build_engine
|
|
232
|
+
@config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
|
|
233
|
+
@engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
|
|
234
|
+
end
|
|
232
235
|
settle_interrupted_turn
|
|
233
236
|
# Before the Bridge serves anything: a UI joining a resumed worker's
|
|
234
237
|
# stream gets the session's history and status in its snapshot, not
|
|
@@ -278,8 +281,9 @@ module Samagotchi
|
|
|
278
281
|
context_absorber: @context_absorber, wakes: @wakes,
|
|
279
282
|
awaiting_continue: -> { @turn_flow.awaiting_continue? },
|
|
280
283
|
stopped: -> { stopped_on_disk? },
|
|
281
|
-
queue_command: lambda { |line, client_id, after_file|
|
|
282
|
-
@bridge.queue_command(line, client_id: client_id, after_file: after_file
|
|
284
|
+
queue_command: lambda { |line, client_id, after_file, enqueued_id|
|
|
285
|
+
@bridge.queue_command(line, client_id: client_id, after_file: after_file,
|
|
286
|
+
enqueued_id: enqueued_id)
|
|
283
287
|
})
|
|
284
288
|
# /model's default is the config's, as in the REPL; the Engine started
|
|
285
289
|
# on the session's model. nil when there is no default.model.
|
|
@@ -414,6 +418,7 @@ module Samagotchi
|
|
|
414
418
|
# The context poller first: a command it runs goes (its process
|
|
415
419
|
# group) before the Engine does.
|
|
416
420
|
def teardown
|
|
421
|
+
@config_restart_unsubscribe&.call
|
|
417
422
|
@context_poller&.stop
|
|
418
423
|
drop_queued_commands
|
|
419
424
|
@engine&.shutdown
|
|
@@ -453,9 +458,10 @@ module Samagotchi
|
|
|
453
458
|
|
|
454
459
|
# A turn an earlier worker died in (TurnCheckpoint's mark in the file):
|
|
455
460
|
# ended now as canceled, with its note, and saved, before the Bridge
|
|
456
|
-
# serves the session (TurnCheckpoint.settle).
|
|
461
|
+
# serves the session (TurnCheckpoint.settle). Its queued id's wait
|
|
462
|
+
# (chi send --queue --wait) reads the end it records in the session dir.
|
|
457
463
|
def settle_interrupted_turn
|
|
458
|
-
mark = TurnCheckpoint.settle(@session)
|
|
464
|
+
mark = TurnCheckpoint.settle(@session, session_dir: @session_dir)
|
|
459
465
|
return unless mark
|
|
460
466
|
|
|
461
467
|
Log.info(:worker, "interrupted_turn_settled", turn_id: mark.id, iteration: mark.iteration, at: mark.at)
|
|
@@ -787,7 +793,11 @@ module Samagotchi
|
|
|
787
793
|
# the cards and notices it showed, held: they follow its command_ran,
|
|
788
794
|
# as its output
|
|
789
795
|
def run_command_line(command)
|
|
790
|
-
@engine.holding_announcements
|
|
796
|
+
@engine.holding_announcements do
|
|
797
|
+
command_result(command) do
|
|
798
|
+
@commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
|
|
799
|
+
end
|
|
800
|
+
end
|
|
791
801
|
end
|
|
792
802
|
|
|
793
803
|
# Whether +command+ asks before a costly /thinking or /reasoning-history
|
|
@@ -795,6 +805,17 @@ module Samagotchi
|
|
|
795
805
|
# question; chi send, a delegate, a plugin and a card's action apply it.
|
|
796
806
|
def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
|
|
797
807
|
|
|
808
|
+
# Whether +command+ is a parent agent's (chi answer's marker, which chi
|
|
809
|
+
# send and a parent's attach give a /continue yes too): held to
|
|
810
|
+
# guardrails.parent_answers (SessionCommands#continue_answer).
|
|
811
|
+
def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
|
|
812
|
+
|
|
813
|
+
# Whether +command+ is a user's at a UI (a web tab, a chi TUI, a card's
|
|
814
|
+
# click), not a parent agent's: only such a line loosens
|
|
815
|
+
# /parent-answers. chi send, a delegate's follow-up, a plugin and an
|
|
816
|
+
# input with no client never do (ParentAnswersCommand).
|
|
817
|
+
def user_ui?(command) = ClientId.interactive?(command.client_id) && !as_parent?(command)
|
|
818
|
+
|
|
798
819
|
# The block's Result for +command+; one that raised, or found no
|
|
799
820
|
# command for the line, is an error Result saying so.
|
|
800
821
|
# @return [SessionCommands::Result]
|
|
@@ -866,6 +887,7 @@ module Samagotchi
|
|
|
866
887
|
# @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
|
|
867
888
|
# line that applies it (the TUI's prefill, the web's Apply button)
|
|
868
889
|
def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
|
|
890
|
+
TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
|
|
869
891
|
text = output.to_s
|
|
870
892
|
event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
|
|
871
893
|
changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
|
|
@@ -70,9 +70,9 @@ module Samagotchi
|
|
|
70
70
|
# @param wakes [WorkerWakes] the wake budget a context wake asks
|
|
71
71
|
# @param awaiting_continue [#call] whether a continue offer waits
|
|
72
72
|
# @param stopped [#call] whether the session was stopped on disk
|
|
73
|
-
# @param queue_command [#call] (line, client_id, after_file)
|
|
74
|
-
# session command for the loop's next pass; after_file is the
|
|
75
|
-
# file it came in, or nil
|
|
73
|
+
# @param queue_command [#call] (line, client_id, after_file, enqueued_id)
|
|
74
|
+
# queues a session command for the loop's next pass; after_file is the
|
|
75
|
+
# input file it came in, or nil
|
|
76
76
|
def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
|
|
77
77
|
stopped:, queue_command:)
|
|
78
78
|
@session = session
|
|
@@ -228,7 +228,7 @@ module Samagotchi
|
|
|
228
228
|
def queue_as_command(text, origin, after_file = nil)
|
|
229
229
|
return false unless @engine.command_registry.command?(text.to_s)
|
|
230
230
|
|
|
231
|
-
@queue_command.call(text, origin&.dig(:client_id), after_file)
|
|
231
|
+
@queue_command.call(text, origin&.dig(:client_id), after_file, origin&.dig(:enqueued_id))
|
|
232
232
|
true
|
|
233
233
|
end
|
|
234
234
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: samagotchi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.87.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -124,6 +124,7 @@ files:
|
|
|
124
124
|
- bin/chi
|
|
125
125
|
- docs/architecture.md
|
|
126
126
|
- docs/broadcast.md
|
|
127
|
+
- docs/bundle-authoring.md
|
|
127
128
|
- docs/cli.md
|
|
128
129
|
- docs/configuration.md
|
|
129
130
|
- docs/context.md
|
|
@@ -162,6 +163,7 @@ files:
|
|
|
162
163
|
- lib/samagotchi/bridge.rb
|
|
163
164
|
- lib/samagotchi/bridge/bounded_queue.rb
|
|
164
165
|
- lib/samagotchi/bridge/card_store.rb
|
|
166
|
+
- lib/samagotchi/bridge/client_event.rb
|
|
165
167
|
- lib/samagotchi/bridge/event_id.rb
|
|
166
168
|
- lib/samagotchi/bridge/pending_card.rb
|
|
167
169
|
- lib/samagotchi/bridge/ring_buffer.rb
|
|
@@ -179,6 +181,9 @@ files:
|
|
|
179
181
|
- lib/samagotchi/broadcast/triage_model.rb
|
|
180
182
|
- lib/samagotchi/broadcast_command.rb
|
|
181
183
|
- lib/samagotchi/bundle_command.rb
|
|
184
|
+
- lib/samagotchi/bundle_command/check.rb
|
|
185
|
+
- lib/samagotchi/bundle_command/scaffold.rb
|
|
186
|
+
- lib/samagotchi/bundle_command/test_run.rb
|
|
182
187
|
- lib/samagotchi/bundle_needs.rb
|
|
183
188
|
- lib/samagotchi/bundle_session_settings.rb
|
|
184
189
|
- lib/samagotchi/bundles/agents-md/manifest.yml
|
|
@@ -218,6 +223,8 @@ files:
|
|
|
218
223
|
- lib/samagotchi/bundles/loop-guard/plugin.rb
|
|
219
224
|
- lib/samagotchi/bundles/mcp/manifest.yml
|
|
220
225
|
- lib/samagotchi/bundles/mcp/plugin.rb
|
|
226
|
+
- lib/samagotchi/bundles/ruby/manifest.yml
|
|
227
|
+
- lib/samagotchi/bundles/ruby/plugin.rb
|
|
221
228
|
- lib/samagotchi/bundles/skills/manifest.yml
|
|
222
229
|
- lib/samagotchi/bundles/skills/plugin.rb
|
|
223
230
|
- lib/samagotchi/bundles/slow-commands/manifest.yml
|
|
@@ -301,7 +308,7 @@ files:
|
|
|
301
308
|
- lib/samagotchi/guardrails/model_size.rb
|
|
302
309
|
- lib/samagotchi/guardrails/outside.rb
|
|
303
310
|
- lib/samagotchi/guardrails/parent_approvals.rb
|
|
304
|
-
- lib/samagotchi/guardrails/
|
|
311
|
+
- lib/samagotchi/guardrails/parent_policy.rb
|
|
305
312
|
- lib/samagotchi/guardrails/protected_paths.rb
|
|
306
313
|
- lib/samagotchi/guardrails/read_only_shell.rb
|
|
307
314
|
- lib/samagotchi/guardrails/rm_targets.rb
|
|
@@ -391,6 +398,7 @@ files:
|
|
|
391
398
|
- lib/samagotchi/memory_bundle/plugin_ref.rb
|
|
392
399
|
- lib/samagotchi/memory_bundle/profile.rb
|
|
393
400
|
- lib/samagotchi/memory_bundle/provenance.rb
|
|
401
|
+
- lib/samagotchi/memory_bundle/shas.rb
|
|
394
402
|
- lib/samagotchi/memory_bundle/shipped_update.rb
|
|
395
403
|
- lib/samagotchi/memory_bundle/source.rb
|
|
396
404
|
- lib/samagotchi/memory_bundle/status.rb
|
|
@@ -414,6 +422,7 @@ files:
|
|
|
414
422
|
- lib/samagotchi/note_delivery.rb
|
|
415
423
|
- lib/samagotchi/output_formatter.rb
|
|
416
424
|
- lib/samagotchi/owner_lock.rb
|
|
425
|
+
- lib/samagotchi/parent_answers_command.rb
|
|
417
426
|
- lib/samagotchi/parent_report.rb
|
|
418
427
|
- lib/samagotchi/paths.rb
|
|
419
428
|
- lib/samagotchi/peer_sources.rb
|
|
@@ -519,6 +528,7 @@ files:
|
|
|
519
528
|
- lib/samagotchi/testing/fake_http.rb
|
|
520
529
|
- lib/samagotchi/testing/helpers.rb
|
|
521
530
|
- lib/samagotchi/testing/host.rb
|
|
531
|
+
- lib/samagotchi/testing/install.rb
|
|
522
532
|
- lib/samagotchi/testing/minitest.rb
|
|
523
533
|
- lib/samagotchi/testing/recorder.rb
|
|
524
534
|
- lib/samagotchi/testing/recording_api.rb
|