samagotchi 0.11.0 → 0.13.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 +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../session"
|
|
4
4
|
require_relative "../reply_wait"
|
|
5
|
+
require_relative "../parent_report"
|
|
5
6
|
require_relative "output_guardrails"
|
|
6
7
|
require_relative "peers"
|
|
7
8
|
|
|
@@ -18,6 +19,10 @@ module Samagotchi
|
|
|
18
19
|
module DelegateWait
|
|
19
20
|
TIMEOUT_DEFAULT = 600
|
|
20
21
|
POLL_INTERVAL = 0.5
|
|
22
|
+
# Seconds with no worker before the child counts as gone: as long as
|
|
23
|
+
# Delegate gives a starting child (its worker takes the owner lock
|
|
24
|
+
# only once it has booted).
|
|
25
|
+
OWNER_GRACE = 15
|
|
21
26
|
# Cut like an execute result: full up to TRUNCATE_AT bytes, else a
|
|
22
27
|
# head-and-tail preview of PREVIEW bytes.
|
|
23
28
|
TRUNCATE_AT_BYTES = OutputGuardrails::DEFAULT_TRUNCATE_AT_BYTES
|
|
@@ -39,13 +44,16 @@ module Samagotchi
|
|
|
39
44
|
# @param peers [Peers] the parent (session_id, state_dir, cancelled?)
|
|
40
45
|
# @param timeout [Integer] seconds
|
|
41
46
|
# @param poll_interval [Float]
|
|
47
|
+
# @param owner_grace [Numeric] seconds with no live worker before
|
|
48
|
+
# the child's worker counts as gone
|
|
42
49
|
# @return [String] the tool result
|
|
43
|
-
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL)
|
|
50
|
+
def call(child_id, peers:, timeout: TIMEOUT_DEFAULT, poll_interval: POLL_INTERVAL, owner_grace: OWNER_GRACE)
|
|
44
51
|
sd = peers.state_dir || Session.default_state_dir
|
|
45
52
|
key = [peers.session_id, child_id]
|
|
46
|
-
cancelled = -> { peers.
|
|
53
|
+
cancelled = -> { peers.cancelled? }
|
|
47
54
|
wait = ReplyWait.call(child_id, state_dir: sd, cursor: seen[key], timeout: timeout.to_i,
|
|
48
|
-
poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key]
|
|
55
|
+
poll_interval: poll_interval, cancelled: cancelled, baseline: baselines[key],
|
|
56
|
+
owner_grace: owner_grace)
|
|
49
57
|
# The turn sent to is handed over: a later wait looks for a later one.
|
|
50
58
|
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
51
59
|
case wait.status
|
|
@@ -62,6 +70,9 @@ module Samagotchi
|
|
|
62
70
|
result(child_id, "canceled", "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
63
71
|
when :no_reply
|
|
64
72
|
result(child_id, "no_reply", "the child's turn ended without a reply (canceled, failed or empty); its session shows what happened")
|
|
73
|
+
when :worker_gone
|
|
74
|
+
result(child_id, "worker_gone",
|
|
75
|
+
"the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
|
|
65
76
|
else
|
|
66
77
|
timeout_result(child_id, timeout)
|
|
67
78
|
end
|
|
@@ -98,9 +109,10 @@ module Samagotchi
|
|
|
98
109
|
"chi --attach #{child_id} shows it.")
|
|
99
110
|
end
|
|
100
111
|
|
|
112
|
+
# The whole question, as `chi send --wait` prints it (ParentReport):
|
|
113
|
+
# its text, options and the commands that answer it.
|
|
101
114
|
def waiting_text(child_id, pending)
|
|
102
|
-
|
|
103
|
-
"Child #{child_id} is waiting for an answer (#{what}); attach with chi --attach #{child_id} or answer it in the web. " \
|
|
115
|
+
"Child #{child_id} is #{ParentReport.question_text(pending, session_id: child_id)}" \
|
|
104
116
|
"delegate_result #{child_id} waits again once it is answered."
|
|
105
117
|
end
|
|
106
118
|
|
|
@@ -25,7 +25,8 @@ module Samagotchi
|
|
|
25
25
|
|
|
26
26
|
# @param cancelled [#call] true once the user stopped the turn: the
|
|
27
27
|
# command is killed (or not started) and its output so far returned
|
|
28
|
-
|
|
28
|
+
# @param env [Hash, nil] added to the command's environment
|
|
29
|
+
def self.call(command, cwd: nil, cancelled: -> { false }, env: nil)
|
|
29
30
|
return NOT_RUN_ON_STOP if cancelled.call
|
|
30
31
|
|
|
31
32
|
command = command.strip
|
|
@@ -33,7 +34,8 @@ module Samagotchi
|
|
|
33
34
|
return "Error: cwd not found: #{resolved_cwd}" unless resolved_cwd
|
|
34
35
|
|
|
35
36
|
timeout_sec = timeout_seconds
|
|
36
|
-
stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled
|
|
37
|
+
stdout, stderr, status = run_command(command, timeout_sec: timeout_sec, cwd: resolved_cwd, cancelled: cancelled,
|
|
38
|
+
env: env)
|
|
37
39
|
|
|
38
40
|
stdout_block = output_block("stdout", stdout)
|
|
39
41
|
stderr_block = output_block("stderr", stderr)
|
|
@@ -66,7 +68,7 @@ module Samagotchi
|
|
|
66
68
|
end
|
|
67
69
|
private_class_method :stopped_result
|
|
68
70
|
|
|
69
|
-
def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false })
|
|
71
|
+
def self.run_command(command, timeout_sec:, cwd:, cancelled: -> { false }, env: nil)
|
|
70
72
|
stdout_text = ""
|
|
71
73
|
stderr_text = ""
|
|
72
74
|
status = nil
|
|
@@ -75,7 +77,7 @@ module Samagotchi
|
|
|
75
77
|
started = monotonic_time
|
|
76
78
|
deadline = started + timeout_sec
|
|
77
79
|
|
|
78
|
-
Open3.popen3(command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
80
|
+
Open3.popen3(env || {}, command, chdir: cwd, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
79
81
|
stdin.close
|
|
80
82
|
stdout_reader = reader_thread_for(stdout)
|
|
81
83
|
stderr_reader = reader_thread_for(stderr)
|
|
@@ -173,11 +175,9 @@ module Samagotchi
|
|
|
173
175
|
end
|
|
174
176
|
private_class_method :monotonic_time
|
|
175
177
|
|
|
178
|
+
# execute.timeout_sec (SAMAGOTCHI_EXECUTE_TIMEOUT_SEC); 0 or less = the default.
|
|
176
179
|
def self.timeout_seconds
|
|
177
|
-
|
|
178
|
-
return TIMEOUT_SEC if value.nil? || value <= 0
|
|
179
|
-
|
|
180
|
-
value
|
|
180
|
+
OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
|
|
181
181
|
end
|
|
182
182
|
private_class_method :timeout_seconds
|
|
183
183
|
|
|
@@ -9,8 +9,9 @@ module Samagotchi
|
|
|
9
9
|
|
|
10
10
|
def self.name = NAME
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# @param marker_env [Hash] set after the model's env (Builtins.parent_env)
|
|
13
|
+
def self.call(command, cwd: nil, env: nil, marker_env: {})
|
|
14
|
+
record, error = TaskRuntime.create_task(command, cwd: cwd, env: env, marker_env: marker_env)
|
|
14
15
|
return error if error
|
|
15
16
|
|
|
16
17
|
[
|
|
@@ -19,10 +19,13 @@ module Samagotchi
|
|
|
19
19
|
METADATA_VERSION = 1
|
|
20
20
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
21
21
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
22
|
+
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
23
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION].freeze
|
|
22
24
|
|
|
23
25
|
module_function
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
# @param marker_env [Hash] chi's own variables, set over the model's +env+
|
|
28
|
+
def create_task(command, cwd: nil, env: nil, marker_env: {})
|
|
26
29
|
normalized_command = command.to_s.strip
|
|
27
30
|
return [nil, "Error: command is required"] if normalized_command.empty?
|
|
28
31
|
|
|
@@ -32,6 +35,8 @@ module Samagotchi
|
|
|
32
35
|
spawn_env, env_error = spawn_env_for(env)
|
|
33
36
|
return [nil, env_error] if env_error
|
|
34
37
|
|
|
38
|
+
spawn_env = spawn_env.merge(marker_env || {})
|
|
39
|
+
|
|
35
40
|
task_id = generate_task_id
|
|
36
41
|
task_dir = task_dir_for(task_id)
|
|
37
42
|
output_path = File.join(task_dir, OUTPUT_FILENAME)
|
|
@@ -258,7 +263,7 @@ module Samagotchi
|
|
|
258
263
|
overrides.each do |key, value|
|
|
259
264
|
return [nil, "Error: env keys and values must be strings"] unless key.is_a?(String) && value.is_a?(String)
|
|
260
265
|
return [nil, "Error: env keys and values cannot contain NUL bytes"] if key.include?("\0") || value.include?("\0")
|
|
261
|
-
return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key)
|
|
266
|
+
return [nil, "Error: env key is reserved: #{key}"] if SANITIZED_ENV_KEYS.include?(key) || MARKER_ENV_KEYS.include?(key)
|
|
262
267
|
|
|
263
268
|
normalized[key] = value
|
|
264
269
|
end
|
data/lib/samagotchi/turn_flow.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Samagotchi
|
|
|
72
72
|
# turn: its partial progress stays, !rollback can undo it) or
|
|
73
73
|
# :continue_cancelled (back to before the continue; the offer stays)
|
|
74
74
|
def after_turn(result, continue: false, no_interrupt: false)
|
|
75
|
-
if result.
|
|
75
|
+
if result.canceled?
|
|
76
76
|
if continue
|
|
77
77
|
restore(@continue_checkpoint)
|
|
78
78
|
return :continue_cancelled
|
|
@@ -85,7 +85,7 @@ module Samagotchi
|
|
|
85
85
|
return :cancelled
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
if result.
|
|
88
|
+
if result.resumable?
|
|
89
89
|
@offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
|
|
90
90
|
:continue_offered
|
|
91
91
|
else
|
|
@@ -172,7 +172,7 @@ module Samagotchi
|
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def conversation_of(result)
|
|
175
|
-
result.conversation if result.
|
|
175
|
+
result.conversation if result.conversation.is_a?(Array)
|
|
176
176
|
end
|
|
177
177
|
|
|
178
178
|
def interrupted_turn_context(result)
|
|
@@ -198,7 +198,7 @@ module Samagotchi
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
def tool_trace(result)
|
|
201
|
-
activities =
|
|
201
|
+
activities = Array(result.tool_activity)
|
|
202
202
|
activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
|
|
203
203
|
tool = activity[:tool].to_s.strip
|
|
204
204
|
status = activity[:status].to_s.strip
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A session's turn as other threads see it: whether one runs, its cancel
|
|
7
|
+
# controller and event sink, the plugin steers waiting for its next
|
|
8
|
+
# boundary, and the activity clock the idle jobs read.
|
|
9
|
+
#
|
|
10
|
+
# The turn thread writes it (Engine#begin_turn, #release_turn); the idle
|
|
11
|
+
# scheduler, the bridge's request threads, plugin threads and the UIs
|
|
12
|
+
# read it. The lock is a leaf: nothing is called out while it is held,
|
|
13
|
+
# so any lock may be held around a call into it, and it is never held
|
|
14
|
+
# around anything else.
|
|
15
|
+
class TurnState
|
|
16
|
+
# @param clock [#call] monotonic seconds
|
|
17
|
+
def initialize(clock:)
|
|
18
|
+
@clock = clock
|
|
19
|
+
@lock = Monitor.new
|
|
20
|
+
@last_activity_at = clock.call
|
|
21
|
+
@activity_seq = 0
|
|
22
|
+
@running = false
|
|
23
|
+
@controller = nil
|
|
24
|
+
@sink = nil
|
|
25
|
+
@steers = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ── Activity clock ──────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
# Activity happened (user input, a finished turn): the clock moves to
|
|
31
|
+
# +now+ and the sequence advances, so the idle jobs re-arm.
|
|
32
|
+
# @param now [Float, nil] monotonic seconds; the clock's now by default
|
|
33
|
+
def record_activity(now = nil)
|
|
34
|
+
now = now ? now.to_f : @clock.call
|
|
35
|
+
@lock.synchronize do
|
|
36
|
+
@last_activity_at = now
|
|
37
|
+
@activity_seq += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Start the idle window now without counting it as activity (the
|
|
42
|
+
# sequence stays): the Engine does once its plugins have loaded, so a
|
|
43
|
+
# slow plugin start doesn't shorten the first window.
|
|
44
|
+
def restart_clock!
|
|
45
|
+
now = @clock.call
|
|
46
|
+
@lock.synchronize { @last_activity_at = now }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Float] monotonic seconds of the last activity
|
|
50
|
+
def last_activity_at
|
|
51
|
+
@lock.synchronize { @last_activity_at }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] how many times activity was recorded
|
|
55
|
+
def activity_seq
|
|
56
|
+
@lock.synchronize { @activity_seq }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── The turn ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether a turn runs
|
|
62
|
+
def running?
|
|
63
|
+
@lock.synchronize { @running }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A turn begins: running, with its controller and sink, in one step.
|
|
67
|
+
def begin!(controller:, sink:)
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
@running = true
|
|
70
|
+
@controller = controller
|
|
71
|
+
@sink = sink
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The turn is over: not running, no controller, sink or steers.
|
|
76
|
+
# @return [Array<Hash>] the steers it never took ({text:, source:})
|
|
77
|
+
def finish!
|
|
78
|
+
@lock.synchronize do
|
|
79
|
+
@running = false
|
|
80
|
+
@controller = nil
|
|
81
|
+
@sink = nil
|
|
82
|
+
@steers.tap { @steers = [] }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [CancellationController, nil] the running turn's controller
|
|
87
|
+
def controller
|
|
88
|
+
@lock.synchronize { @controller }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Cancel the running turn, if any (outside the lock).
|
|
92
|
+
# @return [Boolean] whether a cancellation was triggered
|
|
93
|
+
def cancel!(reason)
|
|
94
|
+
ctrl = controller
|
|
95
|
+
return false unless ctrl
|
|
96
|
+
|
|
97
|
+
ctrl.cancel!(reason)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Array(Boolean, #call)] whether a turn runs, and its sink
|
|
101
|
+
def in_turn_sink
|
|
102
|
+
@lock.synchronize { [@running, @sink] }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# ── Steers ──────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
# Queue +text+ for the running turn's next boundary.
|
|
108
|
+
# @return [Boolean] whether it was queued (false: blank, or no turn)
|
|
109
|
+
def steer(text, source:)
|
|
110
|
+
text = text.to_s.strip
|
|
111
|
+
return false if text.empty?
|
|
112
|
+
|
|
113
|
+
@lock.synchronize do
|
|
114
|
+
return false unless @running
|
|
115
|
+
|
|
116
|
+
@steers << { text: text, source: source.to_s }
|
|
117
|
+
end
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
|
|
122
|
+
def take_steers
|
|
123
|
+
@lock.synchronize { @steers.tap { @steers = [] } }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
require_relative "muted_memories"
|
|
6
|
+
require_relative "tools/memory"
|
|
7
|
+
require_relative "tools/read"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
# The memories a session used: the ones its turns read (memory_read, or a
|
|
11
|
+
# read of a memories/*.md file) or preloaded. Deduped, in first-use
|
|
12
|
+
# order. Written by the turn thread (and the main thread setting the
|
|
13
|
+
# session), read by any (the bridge's snapshot, the UIs).
|
|
14
|
+
class UsedMemories
|
|
15
|
+
# The memory names a tool call reads, or nil when it reads none.
|
|
16
|
+
# @param call [Hash] {name:, content:}
|
|
17
|
+
# @return [Array<String>, String, nil]
|
|
18
|
+
def self.names_from_call(call)
|
|
19
|
+
return nil unless call.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
content = call[:content].to_s.strip
|
|
22
|
+
return nil if content.empty?
|
|
23
|
+
|
|
24
|
+
case call[:name].to_s
|
|
25
|
+
when Tools::MemoryRead::NAME
|
|
26
|
+
Tools::MemoryRead.parse_names(content).filter_map { |name| normalize(name) }
|
|
27
|
+
when Tools::Read::NAME
|
|
28
|
+
path = content.tr("\\", "/")
|
|
29
|
+
path.match?(%r{memories/.+\.md\z}) ? normalize(path) : nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A memory's name as the list keeps it: the basename without .md.
|
|
34
|
+
def self.normalize(raw)
|
|
35
|
+
value = raw.to_s.strip
|
|
36
|
+
return nil if value.empty?
|
|
37
|
+
|
|
38
|
+
base = File.basename(value, ".md").strip
|
|
39
|
+
base.empty? ? nil : base
|
|
40
|
+
end
|
|
41
|
+
private_class_method :normalize
|
|
42
|
+
|
|
43
|
+
# @param names [Array<String>] the names already used (a resumed session's)
|
|
44
|
+
def initialize(names = [])
|
|
45
|
+
@lock = Monitor.new
|
|
46
|
+
@names = []
|
|
47
|
+
add(names)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<String>] a copy of the list
|
|
51
|
+
def names
|
|
52
|
+
@lock.synchronize { @names.dup }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add names, skipping blanks and ones already there.
|
|
56
|
+
def add(names)
|
|
57
|
+
@lock.synchronize do
|
|
58
|
+
Array(names).each do |name|
|
|
59
|
+
value = name.to_s.strip
|
|
60
|
+
@names << value unless value.empty? || @names.include?(value)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Add the names a session already used.
|
|
67
|
+
def absorb(session)
|
|
68
|
+
return self unless session
|
|
69
|
+
|
|
70
|
+
add(Array(session.used_memory_names))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A memory read as it starts (:tool_call_started): its names join the
|
|
74
|
+
# list. A refused read of a muted memory is not a use of it.
|
|
75
|
+
# @param muted [Array<String>] the session's muted names (normalized)
|
|
76
|
+
# @return [Array<String>, nil] the names this call read, nil for any other event
|
|
77
|
+
def capture(event, muted:)
|
|
78
|
+
return nil unless event.is_a?(Hash) && event[:type] == :tool_call_started
|
|
79
|
+
|
|
80
|
+
call = event[:call].is_a?(Hash) ? event[:call] : {}
|
|
81
|
+
names = Array(self.class.names_from_call(call)).reject { |name| MutedMemories.muted?(name, muted) }
|
|
82
|
+
return nil if names.empty?
|
|
83
|
+
|
|
84
|
+
add(names)
|
|
85
|
+
names
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -60,10 +60,10 @@ module Samagotchi
|
|
|
60
60
|
|
|
61
61
|
def self.native(target, entry, profile, trust_config:)
|
|
62
62
|
client = target.client
|
|
63
|
-
transport = client.
|
|
63
|
+
transport = client.transport.name
|
|
64
64
|
return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
|
|
65
65
|
|
|
66
|
-
props = client.
|
|
66
|
+
props = client.server_props(model: target.bare_model)
|
|
67
67
|
return no("can't reach /props for the media marker") unless props&.answered?
|
|
68
68
|
|
|
69
69
|
body = props.body.is_a?(Hash) ? props.body : {}
|
|
@@ -83,14 +83,14 @@ module Samagotchi
|
|
|
83
83
|
|
|
84
84
|
def self.chat(target, entry, adapter)
|
|
85
85
|
unless entry.remote?
|
|
86
|
-
props = entry.client.
|
|
86
|
+
props = entry.client.server_props(model: target.bare_model)
|
|
87
87
|
if props&.answered? && props.body.is_a?(Hash) && props.body.key?("modalities")
|
|
88
88
|
vision = props.body.dig("modalities", "vision") == true
|
|
89
89
|
return vision ? Answer.new(value: true, reason: nil) : no("the server has no vision model loaded (start llama.cpp with --mmproj)")
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
listed = adapter
|
|
93
|
+
listed = adapter&.image_input(model: target.bare_model)
|
|
94
94
|
return Answer.new(value: true, reason: nil) if listed == true
|
|
95
95
|
return no("host #{entry.name} lists #{target.bare_model} as text-only") if listed == false
|
|
96
96
|
|
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "../session_manager"
|
|
|
16
16
|
require_relative "../session_commands"
|
|
17
17
|
require_relative "../steer"
|
|
18
18
|
require_relative "../host_registry"
|
|
19
|
+
require_relative "../model_catalog"
|
|
19
20
|
require_relative "../model_profile"
|
|
20
21
|
require_relative "../project_scope"
|
|
21
22
|
require_relative "../prompt_history"
|
|
@@ -335,12 +336,10 @@ module Samagotchi
|
|
|
335
336
|
|
|
336
337
|
root = dir && ProjectScope.root_for(dir)
|
|
337
338
|
# Lazy retention sweep (once per 24h)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
nil
|
|
343
|
-
end
|
|
339
|
+
begin
|
|
340
|
+
@manager.retention_sweep_if_due(state_dir: @state_dir)
|
|
341
|
+
rescue StandardError
|
|
342
|
+
nil
|
|
344
343
|
end
|
|
345
344
|
limit = sanitize_limit(req.params["limit"])
|
|
346
345
|
offset = sanitize_offset(req.params["offset"])
|
|
@@ -417,19 +416,11 @@ module Samagotchi
|
|
|
417
416
|
warnings << "the hosts are still listing their models"
|
|
418
417
|
else
|
|
419
418
|
default_host = registry.default_entry&.name
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
next
|
|
426
|
-
end
|
|
427
|
-
Array(data[:models]).each do |info|
|
|
428
|
-
id = info.id.to_s
|
|
429
|
-
next if id.strip.empty? || id.end_with?(":batch")
|
|
430
|
-
|
|
431
|
-
models << { name: host == default_host ? id : "#{host}:#{id}", host: host, id: id }
|
|
432
|
-
end
|
|
419
|
+
listing = ModelCatalog.listing(results, registry: registry)
|
|
420
|
+
warnings.concat(listing.warnings)
|
|
421
|
+
# the web's own spelling, kept as it was: bare on the default host
|
|
422
|
+
listing.rows.each do |row|
|
|
423
|
+
models << { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
|
|
433
424
|
end
|
|
434
425
|
end
|
|
435
426
|
rescue StandardError => e
|
|
@@ -548,11 +539,12 @@ module Samagotchi
|
|
|
548
539
|
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
549
540
|
current_turn = turn_snapshot && turn_snapshot["current_turn"]
|
|
550
541
|
owner = session_owner(id)
|
|
551
|
-
# The file's question only
|
|
552
|
-
# saved can't be answered (its turn is gone)
|
|
542
|
+
# The file's question only while a worker owns the session: one a
|
|
543
|
+
# dead worker saved can't be answered (its turn is gone), nor a chi
|
|
544
|
+
# REPL's (it shares nothing with the web).
|
|
553
545
|
pending = if turn_snapshot
|
|
554
546
|
current_turn && current_turn["pending_question"]
|
|
555
|
-
elsif owner
|
|
547
|
+
elsif owner&.worker?
|
|
556
548
|
session.pending_question
|
|
557
549
|
end
|
|
558
550
|
# A /model in the worker changes it before the file catches up.
|
|
@@ -581,7 +573,7 @@ module Samagotchi
|
|
|
581
573
|
session: session_json,
|
|
582
574
|
history: read_history(id),
|
|
583
575
|
messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
|
|
584
|
-
cwd: session.
|
|
576
|
+
cwd: session.working_directory),
|
|
585
577
|
failed_turn: failed_turn_for(session, raw_messages),
|
|
586
578
|
current_turn: current_turn,
|
|
587
579
|
queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
|
|
@@ -677,7 +669,7 @@ module Samagotchi
|
|
|
677
669
|
line = body["line"].to_s.strip
|
|
678
670
|
return error_response(400, "missing_fields", "line is required") if line.empty?
|
|
679
671
|
|
|
680
|
-
@manager.resume_session(id, state_dir: @state_dir)
|
|
672
|
+
@manager.resume_session(id, state_dir: @state_dir)
|
|
681
673
|
# card: a card's action; its command events say so (no echo).
|
|
682
674
|
request = ->(client) { client.post_command(line: line, client_id: body["client_id"], card: body["card"] == true) }
|
|
683
675
|
relay(id, live_bridge_client(id), request, what: "the command was not run", cant: "run commands") do |reply|
|
|
@@ -852,8 +844,6 @@ module Samagotchi
|
|
|
852
844
|
|
|
853
845
|
# The process holding the session (an OwnerLock::Owner), or nil. A worker always runs a Bridge; a TUI (plain `chi`) doesn't share.
|
|
854
846
|
def session_owner(id)
|
|
855
|
-
return nil unless @manager.respond_to?(:session_owner)
|
|
856
|
-
|
|
857
847
|
@manager.session_owner(id, state_dir: @state_dir)
|
|
858
848
|
rescue StandardError
|
|
859
849
|
nil
|
|
@@ -1125,16 +1115,8 @@ module Samagotchi
|
|
|
1125
1115
|
end
|
|
1126
1116
|
end
|
|
1127
1117
|
|
|
1128
|
-
# status is turn state (idle/running): SessionSummary.displayed_status
|
|
1129
|
-
# unless the manager can't tell who owns a session (then the file's
|
|
1130
|
-
# word stands).
|
|
1118
|
+
# status is turn state (idle/running): SessionSummary.displayed_status.
|
|
1131
1119
|
def displayed_status(session, snapshot = nil, owner: session_owner(session.id))
|
|
1132
|
-
unless @manager.respond_to?(:session_owner)
|
|
1133
|
-
return snapshot["status"] if snapshot.is_a?(Hash) && snapshot["status"]
|
|
1134
|
-
|
|
1135
|
-
return session.status
|
|
1136
|
-
end
|
|
1137
|
-
|
|
1138
1120
|
SessionSummary.displayed_status(session, snapshot, owner: owner)
|
|
1139
1121
|
end
|
|
1140
1122
|
|
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
|
|
47
47
|
QWEN_THINK = %r{<think>(.*?)</think>}m
|
|
48
48
|
QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
|
|
49
|
-
GEMMA_THOUGHT =
|
|
49
|
+
GEMMA_THOUGHT = Regexp.new("#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN)}(.*?)" \
|
|
50
|
+
"(?:#{Regexp.escape(ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE)}|\\z)", Regexp::MULTILINE)
|
|
50
51
|
|
|
51
52
|
CallRef = Struct.new(:name, :arguments)
|
|
52
53
|
|
|
@@ -16,6 +16,7 @@ require_relative "../config"
|
|
|
16
16
|
require_relative "../project_scope"
|
|
17
17
|
require_relative "../log"
|
|
18
18
|
require_relative "../version"
|
|
19
|
+
require_relative "../cli/exit"
|
|
19
20
|
|
|
20
21
|
module Samagotchi
|
|
21
22
|
module Web
|
|
@@ -99,7 +100,7 @@ module Samagotchi
|
|
|
99
100
|
# Ctrl-C: the server has stopped (start's ensure); one line, and the
|
|
100
101
|
# status a shell gives a command it interrupted.
|
|
101
102
|
puts "#{"\n" if $stdout.tty?}Chi Web stopped."
|
|
102
|
-
|
|
103
|
+
CLI::Exit::INTERRUPTED
|
|
103
104
|
end
|
|
104
105
|
|
|
105
106
|
# @param hub [SessionHub, nil] the session projection the page streams
|
|
@@ -197,8 +197,6 @@ module Samagotchi
|
|
|
197
197
|
end
|
|
198
198
|
|
|
199
199
|
def sweep
|
|
200
|
-
return unless @manager.respond_to?(:retention_sweep_if_due)
|
|
201
|
-
|
|
202
200
|
@manager.retention_sweep_if_due(state_dir: @state_dir)
|
|
203
201
|
rescue StandardError
|
|
204
202
|
nil
|
|
@@ -288,8 +286,6 @@ module Samagotchi
|
|
|
288
286
|
end
|
|
289
287
|
|
|
290
288
|
def owner_of(id)
|
|
291
|
-
return nil unless @manager.respond_to?(:session_owner)
|
|
292
|
-
|
|
293
289
|
@manager.session_owner(id, state_dir: @state_dir)
|
|
294
290
|
rescue StandardError
|
|
295
291
|
nil
|
|
@@ -28,7 +28,7 @@ module Samagotchi
|
|
|
28
28
|
# looks its project up)
|
|
29
29
|
# @return [Hash] symbol keys
|
|
30
30
|
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
|
|
31
|
-
used =
|
|
31
|
+
used = Array(session.used_memory_names)
|
|
32
32
|
up = bridge_up?(session_dir, owner)
|
|
33
33
|
{
|
|
34
34
|
id: session.id,
|
|
@@ -43,10 +43,10 @@ module Samagotchi
|
|
|
43
43
|
test_run: !!session.test_run,
|
|
44
44
|
used_memory_names: used,
|
|
45
45
|
# The session's --memory and --mute lists (info-bar tooltip).
|
|
46
|
-
preloaded_memory_names:
|
|
47
|
-
muted_memory_names:
|
|
46
|
+
preloaded_memory_names: Array(session.preloaded_memory_names),
|
|
47
|
+
muted_memory_names: Array(session.muted_memory_names),
|
|
48
48
|
# The session that delegated this one (the `delegate` tool), else nil.
|
|
49
|
-
parent_id: session.
|
|
49
|
+
parent_id: session.parent_id,
|
|
50
50
|
first_preview: first_preview_for(session),
|
|
51
51
|
owner: owner&.kind,
|
|
52
52
|
# The saved recap's first sentence, for the session card.
|
|
@@ -61,22 +61,14 @@ module Samagotchi
|
|
|
61
61
|
bridge_up: up,
|
|
62
62
|
# For the tab's notifications (notify.js): an open question, the
|
|
63
63
|
# running turn's open card with actions, and how the last turn
|
|
64
|
-
# ended. A question saved by a worker that died is not open
|
|
65
|
-
|
|
64
|
+
# ended. A question saved by a worker that died is not open, nor a
|
|
65
|
+
# chi REPL's (SessionManager.worker_live?'s rule: only a worker shares it).
|
|
66
|
+
pending_question: session.waiting_question(live: !!owner&.worker?),
|
|
66
67
|
pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
|
|
67
|
-
last_turn: session.
|
|
68
|
+
last_turn: session.last_turn
|
|
68
69
|
}
|
|
69
70
|
end
|
|
70
71
|
|
|
71
|
-
# The open question as {id:, kind:} ("approval" for a guardrail's,
|
|
72
|
-
# else "question"); nil when there is none. The text stays out.
|
|
73
|
-
def pending_question_for(session)
|
|
74
|
-
pending = session.respond_to?(:pending_question) ? session.pending_question : nil
|
|
75
|
-
return nil unless pending.is_a?(Hash) && pending[:id]
|
|
76
|
-
|
|
77
|
-
{ id: pending[:id], kind: pending[:kind].to_s == "approval" ? "approval" : "question" }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
72
|
# status is turn state (idle/running). The live worker's snapshot is the
|
|
81
73
|
# truth; on disk, a "running" with no live owner was left by a worker
|
|
82
74
|
# that died mid-turn.
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -475,7 +475,7 @@ module Samagotchi
|
|
|
475
475
|
refuse_queued_commands
|
|
476
476
|
end
|
|
477
477
|
yield result, error
|
|
478
|
-
response = result
|
|
478
|
+
response = result&.output
|
|
479
479
|
SessionInbox.write_output(@session_dir, response) unless response.nil? || response.strip.empty?
|
|
480
480
|
save_session
|
|
481
481
|
end
|