samagotchi 0.121.1 → 0.122.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 +29 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +1 -0
- data/docs/configuration.md +29 -4
- data/docs/guardrails.md +15 -0
- data/docs/internals/engine.md +2 -0
- data/docs/internals/participants.md +13 -7
- data/docs/memory.md +33 -0
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +8 -2
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client/event_stream.rb +5 -1
- data/lib/samagotchi/bridge_client/stream_open.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +13 -0
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/engine.rb +107 -1
- data/lib/samagotchi/event_fanout.rb +0 -1
- data/lib/samagotchi/guardrail_wiring.rb +5 -4
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/memory_orphans.rb +11 -0
- data/lib/samagotchi/participant_mode.rb +11 -0
- data/lib/samagotchi/plugin/api.rb +7 -4
- data/lib/samagotchi/process_group.rb +13 -0
- data/lib/samagotchi/review_start.rb +6 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +14 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/remote.rb +32 -12
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -2
- data/lib/samagotchi/session_manager.rb +21 -7
- data/lib/samagotchi/session_setup.rb +8 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer.rb +7 -6
- data/lib/samagotchi/system_one/client.rb +6 -5
- data/lib/samagotchi/system_one/log.rb +25 -5
- data/lib/samagotchi/terminal_ui.rb +2 -1
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/toolkit.rb +27 -8
- data/lib/samagotchi/toolkit_binding.rb +115 -7
- data/lib/samagotchi/toolkit_command.rb +15 -3
- data/lib/samagotchi/toolkit_report_command.rb +318 -0
- data/lib/samagotchi/toolkit_suggest.rb +211 -0
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -5
- data/lib/samagotchi/web/participant_access.rb +4 -1
- data/lib/samagotchi/web/participant_review.rb +6 -4
- data/lib/samagotchi/web/principal.rb +3 -4
- data/lib/samagotchi/web/public/bubbles.js +3 -2
- data/lib/samagotchi/web/public/participant.js +2 -1
- data/lib/samagotchi/web/public/turn_events.js +2 -1
- data/lib/samagotchi/web/public/turn_html.js +1 -1
- data/lib/samagotchi/web/server.rb +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +4 -2
- data/lib/samagotchi/web/start_dir.rb +10 -5
- data/lib/samagotchi/worker.rb +16 -2
- data/lib/samagotchi/worker_link.rb +1 -0
- metadata +4 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -41,6 +41,8 @@ require_relative "llm/openai_chat"
|
|
|
41
41
|
require_relative "llm/turn_settings"
|
|
42
42
|
require_relative "session"
|
|
43
43
|
require_relative "toolkit_binding"
|
|
44
|
+
require_relative "participant_mode"
|
|
45
|
+
require_relative "toolkit_suggest"
|
|
44
46
|
require_relative "tool_loads"
|
|
45
47
|
require_relative "answer_tail"
|
|
46
48
|
require_relative "archive_store"
|
|
@@ -156,6 +158,7 @@ module Samagotchi
|
|
|
156
158
|
@no_interrupt = no_interrupt
|
|
157
159
|
@chat_backend = nil
|
|
158
160
|
@chat_backend_mutex = Mutex.new
|
|
161
|
+
@toolkit_audit_thread = nil
|
|
159
162
|
@host_registry = host_registry || HostRegistry.new
|
|
160
163
|
# A model given here (a worker's session model) is the one it runs,
|
|
161
164
|
# so its host is checked; the config default is only checked once it
|
|
@@ -1082,6 +1085,10 @@ module Samagotchi
|
|
|
1082
1085
|
|
|
1083
1086
|
# How long #shutdown waits for the running anytime commands, in all.
|
|
1084
1087
|
SHUTDOWN_JOIN_SECONDS = 3.0
|
|
1088
|
+
# How long #shutdown waits for a running toolkit audit ask (a one-shot
|
|
1089
|
+
# -p --non-interactive exits right after its turn); a slower ask goes
|
|
1090
|
+
# unlogged.
|
|
1091
|
+
TOOLKIT_AUDIT_JOIN_SECONDS = 1.0
|
|
1085
1092
|
|
|
1086
1093
|
# The REPL or the session's worker is leaving (/exit, an idle exit, a
|
|
1087
1094
|
# crash, TERM): stop the idle jobs, give the running anytime commands
|
|
@@ -1107,6 +1114,7 @@ module Samagotchi
|
|
|
1107
1114
|
|
|
1108
1115
|
thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
1109
1116
|
end
|
|
1117
|
+
@toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
|
|
1110
1118
|
left = threads.count(&:alive?)
|
|
1111
1119
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
1112
1120
|
@plugin_tasks.kill_leftovers
|
|
@@ -1516,6 +1524,13 @@ module Samagotchi
|
|
|
1516
1524
|
Steer.author_fields(author, @participants.name_for(author, session: session))
|
|
1517
1525
|
end
|
|
1518
1526
|
|
|
1527
|
+
# A participant's opening message keeps the rules it was delivered under
|
|
1528
|
+
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1529
|
+
# never changes when the session's mode does.
|
|
1530
|
+
def participant_mode_fields(author, session: @session)
|
|
1531
|
+
Steer.participant_author?(author) ? { participant_mode: ParticipantMode.of(session).to_s } : {}
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1519
1534
|
# Why a turn stopped (TurnEndings#stop_facts).
|
|
1520
1535
|
def stop_facts(event) = @turn_endings.stop_facts(event)
|
|
1521
1536
|
private :stop_facts
|
|
@@ -2128,6 +2143,8 @@ module Samagotchi
|
|
|
2128
2143
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2129
2144
|
raise image_error if image_error
|
|
2130
2145
|
|
|
2146
|
+
start_toolkit_suggestion_audit(turn)
|
|
2147
|
+
|
|
2131
2148
|
# The window and the profile probes run after run_turn's probe-cancel
|
|
2132
2149
|
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
2133
2150
|
# The cache itself was dropped at the previous turn's end, so this
|
|
@@ -2289,7 +2306,7 @@ module Samagotchi
|
|
|
2289
2306
|
|
|
2290
2307
|
unless turn.continue
|
|
2291
2308
|
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2292
|
-
MessageTime::KEY => turn.at }
|
|
2309
|
+
**participant_mode_fields(turn.origin.author, session: session), MessageTime::KEY => turn.at }
|
|
2293
2310
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2294
2311
|
turn.messages << user_message
|
|
2295
2312
|
session.last_prompt = turn.prompt
|
|
@@ -2712,6 +2729,95 @@ module Samagotchi
|
|
|
2712
2729
|
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
2713
2730
|
private :apply_toolkit!
|
|
2714
2731
|
|
|
2732
|
+
# Start an audit-only classifier after :turn_started. All session and
|
|
2733
|
+
# tool-registry reads happen here; the thread receives only this snapshot.
|
|
2734
|
+
def start_toolkit_suggestion_audit(turn)
|
|
2735
|
+
mode = Config.get("toolkit.suggest.mode").to_s
|
|
2736
|
+
return if mode == "off"
|
|
2737
|
+
|
|
2738
|
+
session = turn.session
|
|
2739
|
+
default = Toolkit.of(session.toolkit)
|
|
2740
|
+
path = ToolkitSuggest.log_path(session_state_dir)
|
|
2741
|
+
log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2742
|
+
warn: lambda { |reason|
|
|
2743
|
+
Log.warn(:toolkit, "toolkit_suggest_log_failed",
|
|
2744
|
+
echo: "[samagotchi:toolkit] #{reason}")
|
|
2745
|
+
})
|
|
2746
|
+
# The predicate checks the session first; the kits (memory kits are
|
|
2747
|
+
# read from disk) and the log (read whole) only when it gets there.
|
|
2748
|
+
kits = nil
|
|
2749
|
+
candidates = nil
|
|
2750
|
+
return unless ToolkitSuggest.eligible?(
|
|
2751
|
+
session: session, prompt: turn.prompt, author: turn.origin.author,
|
|
2752
|
+
candidate_count: lambda {
|
|
2753
|
+
kits = @toolkit_binding.kits
|
|
2754
|
+
candidates = ToolkitSuggest.candidates(kits, default_name: default.name)
|
|
2755
|
+
candidates.size
|
|
2756
|
+
},
|
|
2757
|
+
last_suggestion: -> { ToolkitSuggest.last_suggestion(log, session.id) },
|
|
2758
|
+
defer_turns: Config.get("toolkit.suggest.defer_turns"),
|
|
2759
|
+
defer_tokens: Config.get("toolkit.suggest.defer_tokens")
|
|
2760
|
+
)
|
|
2761
|
+
|
|
2762
|
+
# One ask at a time: an eligible turn while the last one still runs
|
|
2763
|
+
# goes unasked, and says so.
|
|
2764
|
+
if @toolkit_audit_thread&.alive?
|
|
2765
|
+
Log.info(:toolkit, "toolkit_suggest_in_flight_skipped", session_id: session.id)
|
|
2766
|
+
return
|
|
2767
|
+
end
|
|
2768
|
+
|
|
2769
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2770
|
+
return unless client
|
|
2771
|
+
|
|
2772
|
+
if toolkit_judge_shares_local_host?(client.host)
|
|
2773
|
+
Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
|
|
2774
|
+
return
|
|
2775
|
+
end
|
|
2776
|
+
|
|
2777
|
+
# Rules, not tool lists: this runs before await_init_tasks and
|
|
2778
|
+
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
2779
|
+
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
2780
|
+
snapshots = candidates.map do |kit|
|
|
2781
|
+
ToolkitSuggest::Candidate.new(name: kit.name.dup.freeze, description: kit.description.dup.freeze,
|
|
2782
|
+
rules: ToolkitSuggest.rules(Toolkit.resolve_kit(kit.name, kits)))
|
|
2783
|
+
end.freeze
|
|
2784
|
+
default_snapshot = ToolkitSuggest::Candidate.new(name: default.name.dup.freeze, description: nil,
|
|
2785
|
+
rules: ToolkitSuggest.rules(default.resolve(kits)))
|
|
2786
|
+
core_tools = @tools.entries(toolkit: nil).select(&:core?).map { |entry| entry.name.dup.freeze }.freeze
|
|
2787
|
+
state = ToolkitSuggest.question(
|
|
2788
|
+
default: default, kits: kits, project: File.basename(session.project_root.to_s),
|
|
2789
|
+
prompt: turn.prompt
|
|
2790
|
+
).last
|
|
2791
|
+
snapshot = ToolkitSuggest::Snapshot.new(
|
|
2792
|
+
session_id: session.id.dup.freeze, turn_number: ToolkitSuggest.turn_number(session.messages),
|
|
2793
|
+
default: default_snapshot, candidates: snapshots, core_tools: core_tools,
|
|
2794
|
+
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
2795
|
+
)
|
|
2796
|
+
threshold = Config.get("toolkit.suggest.threshold")
|
|
2797
|
+
@toolkit_audit_thread = Thread.new do
|
|
2798
|
+
ToolkitSuggest.ask(client: client, log: log, snapshot: snapshot, threshold: threshold)
|
|
2799
|
+
rescue StandardError => e
|
|
2800
|
+
ToolkitSuggest.warn(e)
|
|
2801
|
+
end
|
|
2802
|
+
rescue StandardError => e
|
|
2803
|
+
ToolkitSuggest.warn(e)
|
|
2804
|
+
nil
|
|
2805
|
+
end
|
|
2806
|
+
private :start_toolkit_suggestion_audit
|
|
2807
|
+
|
|
2808
|
+
# D7: System 1's host is the session's model server, and a local one
|
|
2809
|
+
# (a single-slot Splash or llama.cpp): the same name, or two hosts:
|
|
2810
|
+
# entries for one server root (scheme, host, port).
|
|
2811
|
+
def toolkit_judge_shares_local_host?(judge_name)
|
|
2812
|
+
model_host, = @host_registry.host_for_model(effective_model_name)
|
|
2813
|
+
return false if model_host.nil? || model_host.remote?
|
|
2814
|
+
return true if judge_name.to_s.strip.casecmp?(model_host.name.to_s)
|
|
2815
|
+
|
|
2816
|
+
judge = @host_registry.find_entry(judge_name)
|
|
2817
|
+
!judge.nil? && judge.root_url.downcase == model_host.root_url.downcase
|
|
2818
|
+
end
|
|
2819
|
+
private :toolkit_judge_shares_local_host?
|
|
2820
|
+
|
|
2715
2821
|
# The session's toolkit (see #initialize for when).
|
|
2716
2822
|
def build_toolkit_binding
|
|
2717
2823
|
ToolkitBinding.new(
|
|
@@ -56,7 +56,6 @@ module Samagotchi
|
|
|
56
56
|
@announce_or_hold = announce_or_hold
|
|
57
57
|
@working_set_emitted = nil
|
|
58
58
|
end
|
|
59
|
-
# rubocop:enable Metrics/ParameterLists
|
|
60
59
|
|
|
61
60
|
# Always return a handler so raw kernel loop events reach the persistent
|
|
62
61
|
# SessionObserver (and thus the analytics collector) even when there is no
|
|
@@ -15,6 +15,7 @@ require_relative "edit_preview"
|
|
|
15
15
|
require_relative "system_one"
|
|
16
16
|
require_relative "tool_activity"
|
|
17
17
|
require_relative "turn_origin"
|
|
18
|
+
require_relative "participant_mode"
|
|
18
19
|
|
|
19
20
|
module Samagotchi
|
|
20
21
|
# An Engine's tool guardrails: who can approve (the interface), the YAML
|
|
@@ -126,12 +127,12 @@ module Samagotchi
|
|
|
126
127
|
# The gate's core checks, in order. A delegate child's boundary comes
|
|
127
128
|
# before the rules, so its ask keeps the child-boundary rule id (and
|
|
128
129
|
# its once/session scopes) when a strict rule asks too. The review
|
|
129
|
-
# boundary comes before both: a participant's turn is denied
|
|
130
|
-
# may not do, never asked about it
|
|
131
|
-
# can turn it into a question).
|
|
130
|
+
# boundary comes before both: a participant's review turn is denied
|
|
131
|
+
# what it may not do, never asked about it.
|
|
132
132
|
def checks
|
|
133
133
|
rules = self.rules
|
|
134
|
-
|
|
134
|
+
boundary = ParticipantMode.of(@session_lookup.call) == :review ? review_boundary : nil
|
|
135
|
+
[@failures, boundary, rules.hook_asks, protected_paths,
|
|
135
136
|
(@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch), child_boundary, rules].compact
|
|
136
137
|
end
|
|
137
138
|
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# (debug dumps) are indented by four spaces and belong to the record above.
|
|
17
17
|
module LogLine
|
|
18
18
|
LEVELS = %w[DEBUG INFO WARN ERROR].freeze
|
|
19
|
-
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast].freeze
|
|
19
|
+
TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
|
|
20
20
|
INDENT = " "
|
|
21
21
|
|
|
22
22
|
Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
|
|
@@ -51,6 +51,17 @@ module Samagotchi
|
|
|
51
51
|
found && text(found)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A model-facing hint: report the name collision without giving the model
|
|
55
|
+
# a command that could move another live clone's memories.
|
|
56
|
+
def model_message(env: ENV, cwd: Dir.pwd)
|
|
57
|
+
found = find(env: env, cwd: cwd)
|
|
58
|
+
return unless found
|
|
59
|
+
|
|
60
|
+
name = File.basename(found.computed).sub(/_\h{8}\z/, "")
|
|
61
|
+
"a folder with the same project name (#{name}) exists with another path's hash; ask the user whether its " \
|
|
62
|
+
"memories belong to this project before using them."
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
def text(found)
|
|
55
66
|
computed = found.computed
|
|
56
67
|
lead = "no project memories in #{computed}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The rules for a participant's turn. P0 keeps every participant in review
|
|
5
|
+
# mode; this is the single resolver for the later mode split.
|
|
6
|
+
module ParticipantMode
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def of(_session) = :review
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -312,17 +312,20 @@ module Samagotchi
|
|
|
312
312
|
# a bundle has) with +add+ declared and +remove+ left out, and
|
|
313
313
|
# +prompt+: text the sessions on it get in their system prompt (nil:
|
|
314
314
|
# its base's, "" none; at most Toolkit::PROMPT_MAX characters);
|
|
315
|
-
# +groups+: MCP source group names/globs (nil inherits its base)
|
|
315
|
+
# +groups+: MCP source group names/globs (nil inherits its base);
|
|
316
|
+
# +description+: optional classifier text, not included in prompts
|
|
317
|
+
# (at most Toolkit::DESCRIPTION_MAX characters).
|
|
316
318
|
# A
|
|
317
319
|
# name chi (full, lean, child) or another bundle has is a load error,
|
|
318
320
|
# and so is a prompt that isn't text or is too long; a base that
|
|
319
321
|
# doesn't resolve leaves the sessions on it with every tool
|
|
320
322
|
# (Toolkit#resolve).
|
|
321
|
-
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil)
|
|
323
|
+
def toolkit(name, base:, add: [], remove: [], prompt: nil, groups: nil, description: nil)
|
|
322
324
|
name = name.to_s
|
|
323
325
|
raise ArgumentError, "toolkit name #{name.inspect} must be a-z, 0-9, _ and -" unless name.match?(TOOLKIT_NAME)
|
|
324
326
|
|
|
325
|
-
problem = Toolkit.prompt_error(prompt) ||
|
|
327
|
+
problem = Toolkit.prompt_error(prompt) || Toolkit.description_error(description) ||
|
|
328
|
+
(Toolkit.groups_error(groups) unless groups == :all)
|
|
326
329
|
raise ArgumentError, "toolkit #{name}: #{problem}" if problem
|
|
327
330
|
raise ArgumentError, "this chi has no toolkits (plugins: false)" unless @registries.toolkits
|
|
328
331
|
if Toolkit::BUILTIN_KITS.key?(name)
|
|
@@ -333,7 +336,7 @@ module Samagotchi
|
|
|
333
336
|
raise ArgumentError, "toolkit #{name} is declared twice" if @toolkits.any? { |kit| kit.name == name }
|
|
334
337
|
|
|
335
338
|
@toolkits << Toolkit::Kit.new(name: name, base: base, add: add, remove: remove, prompt: prompt,
|
|
336
|
-
groups: groups, source: @bundle)
|
|
339
|
+
groups: groups, source: @bundle, description: description)
|
|
337
340
|
nil
|
|
338
341
|
end
|
|
339
342
|
|
|
@@ -87,6 +87,19 @@ module Samagotchi
|
|
|
87
87
|
nil
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Whether +pid+ is stopped (SIGSTOP, Ctrl-Z; t: under a tracer): ps's
|
|
91
|
+
# state starts with T or t on macOS and Linux alike. Such a process
|
|
92
|
+
# takes no signal but KILL and answers no socket until it is continued.
|
|
93
|
+
# false when it isn't running or ps can't say.
|
|
94
|
+
def stopped?(pid)
|
|
95
|
+
return false unless signalable?(pid)
|
|
96
|
+
|
|
97
|
+
out, status = Open3.capture2("ps", "-o", "state=", "-p", pid.to_s, err: File::NULL)
|
|
98
|
+
status.success? && out.lstrip.start_with?("T", "t")
|
|
99
|
+
rescue SystemCallError, IOError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
90
103
|
# Field 22 (starttime); the command name (field 2) may hold spaces and
|
|
91
104
|
# parentheses, so the fields are counted after its last ")".
|
|
92
105
|
def proc_start_time(stat) = stat[/\A.*\)\s(.*)\z/m, 1]&.split&.[](19)
|
|
@@ -7,6 +7,7 @@ require_relative "session_chain"
|
|
|
7
7
|
require_relative "recap_store"
|
|
8
8
|
require_relative "guardrails/context"
|
|
9
9
|
require_relative "guardrails/review_paths"
|
|
10
|
+
require_relative "participant_mode"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
autoload :SessionManager, File.expand_path("session_manager", __dir__)
|
|
@@ -75,6 +76,7 @@ module Samagotchi
|
|
|
75
76
|
subjects = check_subjects(subjects, dir: dir)
|
|
76
77
|
raise Refused, NO_SUBJECTS if subjects.empty?
|
|
77
78
|
|
|
79
|
+
setup = setup.with(toolkit_origin: "chosen")
|
|
78
80
|
SessionManager.spawn_session(prompt: nil, preview: preview(subjects), working_directory: dir, model_name: model,
|
|
79
81
|
setup: setup, review: SessionReview.new(subjects: subjects),
|
|
80
82
|
note: note(subjects: subjects), note_source: NOTE_SOURCE, state_dir: state_dir)
|
|
@@ -103,7 +105,8 @@ module Samagotchi
|
|
|
103
105
|
recap = RecapStore.read(Session.session_dir(source.id, state_dir: sd))
|
|
104
106
|
session = SessionManager.spawn_session(
|
|
105
107
|
prompt: nil, preview: preview(subjects, source: source), working_directory: dir, model_name: choice.model,
|
|
106
|
-
setup: SessionSetup.of(source)
|
|
108
|
+
setup: SessionSetup.of(source).with(toolkit_origin: "chosen"),
|
|
109
|
+
review: SessionReview.new(subjects: subjects, from: source.id),
|
|
107
110
|
note: note(subjects: subjects, source: source, recap: recap, seed: seed), note_source: NOTE_SOURCE, state_dir: sd
|
|
108
111
|
)
|
|
109
112
|
warnings = [choice.warning, session.model_warning].compact
|
|
@@ -145,10 +148,11 @@ module Samagotchi
|
|
|
145
148
|
# @param session [Session]
|
|
146
149
|
# @return [String, nil]
|
|
147
150
|
def share_warning(session)
|
|
151
|
+
mode = ParticipantMode.of(session)
|
|
148
152
|
return nil if session.review?
|
|
149
153
|
|
|
150
154
|
count = session.tool_output_count
|
|
151
|
-
"#{session.id[0, 8]} is a working session, not a review session: a participant's
|
|
155
|
+
"#{session.id[0, 8]} is a working session, not a review session: a participant's #{mode} turn can quote what " \
|
|
152
156
|
"its history holds (#{count} tool output#{"s" unless count == 1} so far). A review session starts from its recap instead."
|
|
153
157
|
end
|
|
154
158
|
|
|
@@ -166,6 +166,7 @@ module Samagotchi
|
|
|
166
166
|
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
167
167
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
168
168
|
chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
|
|
169
|
+
chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
|
|
169
170
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
170
171
|
chi models [--format json] [TEXT] the models --model takes, from every host
|
|
171
172
|
chi remote <add|ls|rm> other machines' chi web, for chi --remote NAME
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Samagotchi
|
|
|
32
32
|
STATUS_RUNNING = "running"
|
|
33
33
|
STATUS_ERROR = "error"
|
|
34
34
|
STATUS_STOPPED = "stopped"
|
|
35
|
+
TOOLKIT_ORIGINS = %w[chosen default inherited suggested].freeze
|
|
35
36
|
|
|
36
37
|
# A stopped session's marker, <session dir>/stopped: a stop (chi
|
|
37
38
|
# sessions stop, from another process) creates it instead of rewriting
|
|
@@ -222,6 +223,15 @@ module Samagotchi
|
|
|
222
223
|
@toolkit = value.is_a?(Toolkit) ? value : Toolkit.from_file(value)
|
|
223
224
|
end
|
|
224
225
|
|
|
226
|
+
# Why this session is on its toolkit. Files from before the field read
|
|
227
|
+
# as chosen, so an old default session is never newly suggested.
|
|
228
|
+
attr_reader :toolkit_origin
|
|
229
|
+
|
|
230
|
+
def toolkit_origin=(value)
|
|
231
|
+
origin = value.to_s
|
|
232
|
+
@toolkit_origin = TOOLKIT_ORIGINS.include?(origin) ? origin : "chosen"
|
|
233
|
+
end
|
|
234
|
+
|
|
225
235
|
# The session's own settings (chi --set), a SessionConfig::Settings:
|
|
226
236
|
# empty for none and for a session from before them. Its core part
|
|
227
237
|
# ({key => value}: recap.enabled) is the file's config_settings, its
|
|
@@ -322,7 +332,7 @@ module Samagotchi
|
|
|
322
332
|
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
|
|
323
333
|
last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
|
|
324
334
|
continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
|
|
325
|
-
toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
|
|
335
|
+
toolkit: nil, toolkit_origin: "chosen", settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
|
|
326
336
|
working_set_pins: [], review: nil)
|
|
327
337
|
@id = id
|
|
328
338
|
@metadata_version = metadata_version
|
|
@@ -355,6 +365,7 @@ module Samagotchi
|
|
|
355
365
|
self.reasoning_history = reasoning_history
|
|
356
366
|
self.appended_prompt_files = appended_prompt_files
|
|
357
367
|
self.toolkit = toolkit
|
|
368
|
+
self.toolkit_origin = toolkit_origin
|
|
358
369
|
self.settings = settings
|
|
359
370
|
self.parent_answers = parent_answers
|
|
360
371
|
self.working_set_pins = working_set_pins
|
|
@@ -464,6 +475,7 @@ module Samagotchi
|
|
|
464
475
|
reasoning_history: setup.reasoning_history,
|
|
465
476
|
appended_prompt_files: setup.appended_prompt_files,
|
|
466
477
|
toolkit: setup.toolkit,
|
|
478
|
+
toolkit_origin: setup.toolkit_origin || "chosen",
|
|
467
479
|
settings: setup.settings,
|
|
468
480
|
parent_answers: setup.parent_answers,
|
|
469
481
|
working_set_pins: setup.working_set_pins,
|
|
@@ -534,6 +546,7 @@ module Samagotchi
|
|
|
534
546
|
"reasoning_history" => nil,
|
|
535
547
|
"appended_prompt_files" => [],
|
|
536
548
|
"toolkit" => nil,
|
|
549
|
+
"toolkit_origin" => "chosen",
|
|
537
550
|
"bundle_settings" => {},
|
|
538
551
|
"config_settings" => {},
|
|
539
552
|
"interrupted_turn" => nil,
|
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
# bin/chi subcommands that act on this machine: never sessions. chi
|
|
27
27
|
# remote (this machine's list of remotes) runs here under a remote too
|
|
28
28
|
# (Dispatch); the others are refused there.
|
|
29
|
-
MACHINE = %w[bootstrap update models desktop memory bundle guardrails remote].freeze
|
|
29
|
+
MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote].freeze
|
|
30
30
|
|
|
31
31
|
# "command", "command sub" or either with a flag (its long name) →
|
|
32
32
|
# SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
|
|
@@ -193,7 +193,7 @@ module Samagotchi
|
|
|
193
193
|
return true if owner(id)&.worker?
|
|
194
194
|
return false if monotonic >= deadline
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
@sleeper.call(WORKER_POLL)
|
|
197
197
|
end
|
|
198
198
|
end
|
|
199
199
|
|
|
@@ -310,7 +310,10 @@ module Samagotchi
|
|
|
310
310
|
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
311
311
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
312
312
|
case reply.status
|
|
313
|
-
when 200
|
|
313
|
+
when 200
|
|
314
|
+
raise_refusal(reply) unless json["status"].is_a?(String)
|
|
315
|
+
|
|
316
|
+
NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
|
|
314
317
|
when 409
|
|
315
318
|
return NoteDelivery::Result.new(id: id, status: :refused, queued: 0) if json["error"] == "owned_by_tui"
|
|
316
319
|
|
|
@@ -470,14 +473,13 @@ module Samagotchi
|
|
|
470
473
|
def context_add_url(target, url, why:, hint:, added_by:)
|
|
471
474
|
reply = request(:post, "#{session_path(target.session_id)}/context",
|
|
472
475
|
body: { url: url, why: why, hint: hint, added_by: added_by }.compact)
|
|
473
|
-
if reply.status == 422
|
|
476
|
+
if reply.status == 422 && no_provider?(reply)
|
|
474
477
|
require_relative "../context_providers"
|
|
475
|
-
detail =
|
|
476
|
-
"no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
|
|
477
|
-
else
|
|
478
|
-
"#{reply.json["detail"]} (on #{name})"
|
|
479
|
-
end
|
|
478
|
+
detail = "no installed bundle on #{name} resolves #{url} (chi bundle install github-pr there for GitHub PRs)"
|
|
480
479
|
raise ContextProviders::Invalid, detail
|
|
480
|
+
elsif reply.status == 422
|
|
481
|
+
require_relative "../context_providers"
|
|
482
|
+
raise ContextProviders::Invalid, "#{reply.json["detail"]} (on #{name})"
|
|
481
483
|
end
|
|
482
484
|
context_added(reply)
|
|
483
485
|
reply.json.fetch("name")
|
|
@@ -492,7 +494,7 @@ module Samagotchi
|
|
|
492
494
|
case reply.status
|
|
493
495
|
when 200 then ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"])
|
|
494
496
|
when 404
|
|
495
|
-
return ContextPush.new(status: :missing) if reply
|
|
497
|
+
return ContextPush.new(status: :missing) if context_missing?(reply)
|
|
496
498
|
|
|
497
499
|
raise_refusal(reply)
|
|
498
500
|
when 409 then ContextPush.new(status: :not_push)
|
|
@@ -513,7 +515,7 @@ module Samagotchi
|
|
|
513
515
|
def context_entry(target, name)
|
|
514
516
|
ContextSources.check_name!(name)
|
|
515
517
|
reply = request(:get, "#{session_path(target.session_id)}/context/#{name}", query: { full: 1 })
|
|
516
|
-
return nil if
|
|
518
|
+
return nil if context_missing?(reply)
|
|
517
519
|
|
|
518
520
|
raise_refusal(reply) unless reply.status == 200
|
|
519
521
|
|
|
@@ -527,7 +529,7 @@ module Samagotchi
|
|
|
527
529
|
case reply.status
|
|
528
530
|
when 200 then :removed
|
|
529
531
|
when 409 then reply.error == "project_source" ? :project : raise_refusal(reply)
|
|
530
|
-
when 404 then
|
|
532
|
+
when 404 then context_missing?(reply) ? :missing : raise_refusal(reply)
|
|
531
533
|
else raise_refusal(reply)
|
|
532
534
|
end
|
|
533
535
|
end
|
|
@@ -655,6 +657,21 @@ module Samagotchi
|
|
|
655
657
|
reply.status == 404 && reply.error == "not_found" && reply.json["detail"].to_s.start_with?("not found: /")
|
|
656
658
|
end
|
|
657
659
|
|
|
660
|
+
def context_missing?(reply)
|
|
661
|
+
return false unless reply.status == 404
|
|
662
|
+
return true if reply.error == "context_missing"
|
|
663
|
+
|
|
664
|
+
# Older chi web used not_found for a missing context source.
|
|
665
|
+
reply.error == "not_found" && reply.json["detail"].to_s.start_with?("no source")
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def no_provider?(reply)
|
|
669
|
+
return true if reply.error == "no_provider"
|
|
670
|
+
|
|
671
|
+
# Older chi web didn't give this refusal its own error code.
|
|
672
|
+
reply.json["detail"].to_s.start_with?("no installed bundle resolves")
|
|
673
|
+
end
|
|
674
|
+
|
|
658
675
|
# Raises what Local's callee raises for this refusal (chi web sends
|
|
659
676
|
# the fields each class is built from; see docs/architecture.md, chi
|
|
660
677
|
# web's routes for the session commands), else RemoteFailed.
|
|
@@ -674,7 +691,10 @@ module Samagotchi
|
|
|
674
691
|
when "continued", "open_children", "folder_gone"
|
|
675
692
|
raise SessionContinue::Refused.new(sid, reason, ids: Array(json["ids"]))
|
|
676
693
|
when "held", "not_running", "unsupported", "failed"
|
|
677
|
-
|
|
694
|
+
if reason
|
|
695
|
+
raise SessionManager::RestartRefused.new(sid, reason, version: json["version"], holds: json["holds"],
|
|
696
|
+
pid: json["pid"], via: invocation)
|
|
697
|
+
end
|
|
678
698
|
when "parent_answers_refused", "parent_continue_refused", "no_vision"
|
|
679
699
|
raise Refused.new(message.to_s, code: error)
|
|
680
700
|
# No model configured there, or --model's host unknown there: what
|
|
@@ -67,7 +67,14 @@ module Samagotchi
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def stream(sock, status, headers
|
|
70
|
+
def stream(sock, status, headers, session_id:, remote:)
|
|
71
|
+
outcome = BridgeClient::StreamOpen.classify(sock, status, headers)
|
|
72
|
+
return outcome unless status.to_i == 503 && outcome.is_a?(BridgeClient::StreamOpen::Unreachable)
|
|
73
|
+
|
|
74
|
+
expected = [WorkerLink.unanswered(session_id), WorkerLink.not_live(session_id)]
|
|
75
|
+
detail = expected.include?(outcome.detail) ? outcome.detail : WorkerLink.not_live(session_id)
|
|
76
|
+
BridgeClient::StreamOpen::Unreachable.new(attempts: outcome.attempts, detail: "#{detail} on #{remote}")
|
|
77
|
+
end
|
|
71
78
|
|
|
72
79
|
def bridge(status, **body) = BridgeClient::Response.new(status: status, body: JSON.generate(body.compact))
|
|
73
80
|
end
|
|
@@ -223,7 +230,7 @@ module Samagotchi
|
|
|
223
230
|
end
|
|
224
231
|
return refused("chunked_unsupported", sock) if headers["transfer-encoding"].to_s.downcase.include?("chunked")
|
|
225
232
|
|
|
226
|
-
Mapping.stream(sock, status, headers)
|
|
233
|
+
Mapping.stream(sock, status, headers, session_id: @session_id, remote: @remote.name)
|
|
227
234
|
rescue SystemCallError, SocketError, IOError
|
|
228
235
|
sock&.close rescue nil
|
|
229
236
|
BridgeClient::StreamOpen::Unreachable.new(attempts: 1)
|
|
@@ -23,10 +23,12 @@ require_relative "host_registry"
|
|
|
23
23
|
require_relative "utf8_default"
|
|
24
24
|
require_relative "turn_note"
|
|
25
25
|
require_relative "owner_lock"
|
|
26
|
+
require_relative "process_group"
|
|
26
27
|
require_relative "bridge_client"
|
|
27
28
|
require_relative "worker_sidecar"
|
|
28
29
|
require_relative "worker_link"
|
|
29
30
|
require_relative "worker_signals"
|
|
31
|
+
require_relative "chi_invocation"
|
|
30
32
|
require_relative "log"
|
|
31
33
|
require_relative "log_path"
|
|
32
34
|
require_relative "paths"
|
|
@@ -194,6 +196,7 @@ module Samagotchi
|
|
|
194
196
|
review: review
|
|
195
197
|
)
|
|
196
198
|
session.toolkit = Toolkit.configured(delegate: delegate) if session.toolkit.nil?
|
|
199
|
+
session.toolkit_origin = setup.toolkit_origin || "default"
|
|
197
200
|
session.model_warning = model_warning
|
|
198
201
|
# With no prompt there is no first turn to run (an attaching UI sends
|
|
199
202
|
# the prompts), so the session starts idle.
|
|
@@ -629,16 +632,18 @@ module Samagotchi
|
|
|
629
632
|
# worker from before restarts, running +version+: stop it instead), a
|
|
630
633
|
# WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
|
|
631
634
|
# :fallback_held (disk-visible work blocks an unresponsive-worker restart),
|
|
632
|
-
#
|
|
635
|
+
# :stopped (its worker, +pid+, is SIGSTOPped: neither the ask nor a TERM
|
|
636
|
+
# reaches it until it is continued), or :failed (the worker answered something else or didn't release).
|
|
633
637
|
class RestartRefused < StandardError
|
|
634
|
-
attr_reader :session_id, :reason, :version, :holds
|
|
638
|
+
attr_reader :session_id, :reason, :version, :holds, :pid
|
|
635
639
|
|
|
636
|
-
def initialize(session_id, reason, version: nil, holds: [])
|
|
640
|
+
def initialize(session_id, reason, version: nil, holds: [], pid: nil, via: ChiInvocation::HERE)
|
|
637
641
|
@session_id = session_id
|
|
638
642
|
@reason = reason.to_sym
|
|
639
643
|
@version = version
|
|
640
644
|
@holds = Array(holds).map(&:to_sym).freeze
|
|
641
|
-
|
|
645
|
+
@pid = pid
|
|
646
|
+
super(RestartRefused.words(session_id, @reason, version, @holds, pid: pid, via: via))
|
|
642
647
|
end
|
|
643
648
|
|
|
644
649
|
HOLDS = {
|
|
@@ -654,17 +659,22 @@ module Samagotchi
|
|
|
654
659
|
}.freeze
|
|
655
660
|
|
|
656
661
|
# What the CLI and the web say.
|
|
657
|
-
def self.words(session_id, reason, version = nil, holds = [])
|
|
662
|
+
def self.words(session_id, reason, version = nil, holds = [], pid: nil, via: ChiInvocation::HERE)
|
|
663
|
+
stop = via.command("sessions stop #{session_id}")
|
|
658
664
|
case reason
|
|
665
|
+
when :stopped
|
|
666
|
+
where = via.remote? ? " on #{via.remote}" : ""
|
|
667
|
+
"session #{session_id}'s worker#{" (pid #{pid})" if pid} is stopped (SIGSTOP or Ctrl-Z) and can't take " \
|
|
668
|
+
"a restart: continue it#{" with `kill -CONT #{pid}`" if pid}#{where}, then restart again"
|
|
659
669
|
when :not_running
|
|
660
670
|
"session #{session_id} has no running worker; the next prompt starts one on the newest chi"
|
|
661
671
|
when :unsupported
|
|
662
672
|
"session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
|
|
663
|
-
"
|
|
673
|
+
"#{stop}, then any prompt starts it on the newest chi"
|
|
664
674
|
when :fallback_held
|
|
665
675
|
why = holds.map { |hold| HOLDS.fetch(hold, hold.to_s.tr("_", " ")) }.join("; ")
|
|
666
676
|
"session #{session_id} has disk-visible work that a restart might lose#{": #{why}" unless why.empty?}; " \
|
|
667
|
-
"run
|
|
677
|
+
"run `#{stop}`, then any prompt starts it on the newest chi"
|
|
668
678
|
when :failed then "session #{session_id}'s worker didn't take the restart"
|
|
669
679
|
else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
|
|
670
680
|
end
|
|
@@ -692,6 +702,10 @@ module Samagotchi
|
|
|
692
702
|
sd = state_dir || Session.default_state_dir
|
|
693
703
|
Session.load(session_id, state_dir: sd) # ArgumentError for none
|
|
694
704
|
owner = refuse_tui!(session_id, state_dir: sd)
|
|
705
|
+
# A stopped worker answers neither the ask (READ_TIMEOUT) nor the
|
|
706
|
+
# fallback's TERM (FALLBACK_STOP_WAIT): say so now, not after ~70 s.
|
|
707
|
+
raise RestartRefused.new(session_id, :stopped, pid: owner.pid) if owner&.worker? && ProcessGroup.stopped?(owner.pid)
|
|
708
|
+
|
|
695
709
|
dir = Session.session_dir(session_id, state_dir: sd)
|
|
696
710
|
link = WorkerLink.resolve(dir, unlink: false, owner: owner)
|
|
697
711
|
if link.is_a?(WorkerLink::Skew)
|