samagotchi 0.85.0 → 0.86.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 +60 -1
- data/docs/architecture.md +34 -5
- data/docs/bundle-authoring.md +125 -0
- data/docs/cli.md +38 -3
- data/docs/configuration.md +6 -5
- data/docs/guardrails.md +63 -35
- 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 +6 -6
- data/docs/sub-agent.md +35 -24
- 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/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/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 +258 -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_report.rb +87 -14
- 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 +37 -24
- 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 +34 -8
- data/lib/samagotchi/session_commands.rb +14 -3
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -11
- data/lib/samagotchi/terminal_ui/thinking_line.rb +5 -9
- data/lib/samagotchi/terminal_ui.rb +12 -9
- 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_wait.rb +6 -1
- 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 +19 -5
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +11 -2
- data/lib/samagotchi/guardrails/parent_continue.rb +0 -43
|
@@ -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,9 @@ 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) { @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command)) }
|
|
798
|
+
end
|
|
791
799
|
end
|
|
792
800
|
|
|
793
801
|
# Whether +command+ asks before a costly /thinking or /reasoning-history
|
|
@@ -795,6 +803,11 @@ module Samagotchi
|
|
|
795
803
|
# question; chi send, a delegate, a plugin and a card's action apply it.
|
|
796
804
|
def confirm?(command) = ClientId.interactive?(command.client_id) && !command.card
|
|
797
805
|
|
|
806
|
+
# Whether +command+ is a parent agent's (chi answer's marker, which chi
|
|
807
|
+
# send and a parent's attach give a /continue yes too): held to
|
|
808
|
+
# guardrails.parent_answers (SessionCommands#continue_answer).
|
|
809
|
+
def as_parent?(command) = command.client_id.to_s == ClientId::CLI_ANSWER
|
|
810
|
+
|
|
798
811
|
# The block's Result for +command+; one that raised, or found no
|
|
799
812
|
# command for the line, is an error Result saying so.
|
|
800
813
|
# @return [SessionCommands::Result]
|
|
@@ -866,6 +879,7 @@ module Samagotchi
|
|
|
866
879
|
# @param confirm [CacheConfirm::Confirm, nil] the change asks first: the
|
|
867
880
|
# line that applies it (the TUI's prefill, the web's Apply button)
|
|
868
881
|
def announce_command(command, status:, output:, changed:, anytime: false, confirm: nil)
|
|
882
|
+
TurnCheckpoint.record_command_outcome(@session_dir, command.enqueued_id)
|
|
869
883
|
text = output.to_s
|
|
870
884
|
event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
|
|
871
885
|
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.86.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
|
|
@@ -519,6 +527,7 @@ files:
|
|
|
519
527
|
- lib/samagotchi/testing/fake_http.rb
|
|
520
528
|
- lib/samagotchi/testing/helpers.rb
|
|
521
529
|
- lib/samagotchi/testing/host.rb
|
|
530
|
+
- lib/samagotchi/testing/install.rb
|
|
522
531
|
- lib/samagotchi/testing/minitest.rb
|
|
523
532
|
- lib/samagotchi/testing/recorder.rb
|
|
524
533
|
- lib/samagotchi/testing/recording_api.rb
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../config"
|
|
4
|
-
|
|
5
|
-
module Samagotchi
|
|
6
|
-
module Guardrails
|
|
7
|
-
# Whether a parent agent may answer Continue to a session's step-limit
|
|
8
|
-
# question (kind "continue", ContinueOffer): turn.parent_continue,
|
|
9
|
-
# config.yml only, default true. Continue grants no permission (every
|
|
10
|
-
# tool call still meets the guardrails), it spends time and tokens;
|
|
11
|
-
# false makes parents stop-only. Checked by `chi answer` before it posts
|
|
12
|
-
# and by the worker's question desk for an answer marked as chi
|
|
13
|
-
# answer's, the way ParentApprovals is.
|
|
14
|
-
module ParentContinue
|
|
15
|
-
KEY = "turn.parent_continue"
|
|
16
|
-
KIND = "continue"
|
|
17
|
-
CONTINUE = "Continue"
|
|
18
|
-
|
|
19
|
-
module_function
|
|
20
|
-
|
|
21
|
-
# @return [Boolean] this process's setting
|
|
22
|
-
def allowed?
|
|
23
|
-
Config.get(KEY) != false
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Why a parent may not give this answer, or nil.
|
|
27
|
-
# @param pending [PendingQuestion] the pending question
|
|
28
|
-
# @param selected [Array<String>] the selected labels
|
|
29
|
-
# @return [Symbol, nil] :stop_only
|
|
30
|
-
def refusal(pending, selected, allowed: allowed?)
|
|
31
|
-
return nil if allowed
|
|
32
|
-
return nil unless pending.kind.to_s == KIND
|
|
33
|
-
|
|
34
|
-
Array(selected).map(&:to_s).include?(CONTINUE) ? :stop_only : nil
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def message
|
|
38
|
-
"a parent agent may only stop this turn here (#{KEY}: false): answer Stop " \
|
|
39
|
-
"(--option Stop --text WHY), or tell your user it waits"
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|