samagotchi 0.13.0 → 0.15.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 +82 -1
- data/bin/chi +4 -7
- data/docs/configuration.md +50 -7
- data/docs/guardrails.md +2 -1
- data/docs/plugins.md +28 -13
- data/docs/sessions.md +1 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +94 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +68 -5
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +166 -34
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +31 -6
- data/lib/samagotchi/context_window.rb +30 -5
- data/lib/samagotchi/engine.rb +21 -7
- data/lib/samagotchi/guardrails/parent_approvals.rb +5 -2
- data/lib/samagotchi/host_registry.rb +31 -6
- data/lib/samagotchi/kernel_loop.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/turn_settings.rb +7 -5
- data/lib/samagotchi/plugin_session_state.rb +30 -0
- data/lib/samagotchi/self_report.rb +24 -4
- data/lib/samagotchi/session_manager.rb +8 -0
- data/lib/samagotchi/session_metrics.rb +1 -1
- data/lib/samagotchi/session_retention.rb +6 -1
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +65 -13
- data/lib/samagotchi/terminal_ui.rb +27 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -10
- data/lib/samagotchi/tools/memory.rb +3 -3
- data/lib/samagotchi/utf8_default.rb +29 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -7
- data/lib/samagotchi/web/public/activity.js +6 -1
- data/lib/samagotchi/web/public/app.js +46 -10
- data/lib/samagotchi/web/public/index.html +10 -1
- data/lib/samagotchi/web/public/notify.js +16 -3
- data/lib/samagotchi/web/public/question_cards.js +3 -1
- data/lib/samagotchi/web/public/sessions_list.js +45 -0
- data/lib/samagotchi/web/public/strip.js +8 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/turn_view.js +10 -10
- metadata +3 -1
|
@@ -271,7 +271,7 @@ module Samagotchi
|
|
|
271
271
|
def prepare_request(turn)
|
|
272
272
|
prompt, images = format_prompt(turn)
|
|
273
273
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
274
|
-
window = ContextWindow.resolve(client: @client, model: turn.model_name)
|
|
274
|
+
window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
|
|
275
275
|
emit_context_status_event(turn.on_stream_event, turn.context, prompt, iteration_index: turn.iteration - 1,
|
|
276
276
|
window: window, image_tokens: image_tokens)
|
|
277
277
|
if (line = turn.context.take_guidance)
|
|
@@ -172,11 +172,13 @@ module Samagotchi
|
|
|
172
172
|
|
|
173
173
|
# The window for +model+: the kernel's client probes the server it
|
|
174
174
|
# points at (Engine keeps it on the effective model's host; a local
|
|
175
|
-
# llama.cpp answers /props), then the host's model list,
|
|
176
|
-
# and the default. A remote
|
|
175
|
+
# llama.cpp answers /props), then the host's model list, the model's
|
|
176
|
+
# or host's window_tokens, config, env and the default. A remote
|
|
177
|
+
# provider has no /props to probe.
|
|
177
178
|
def context_window(model)
|
|
178
179
|
client = @adapter.remote? ? nil : @kernel.client
|
|
179
|
-
ContextWindow.resolve(client: client, model: model, adapter: @adapter
|
|
180
|
+
ContextWindow.resolve(client: client, model: model, adapter: @adapter,
|
|
181
|
+
setting: (@kernel.turn_settings&.window_setting if @kernel.respond_to?(:turn_settings)))
|
|
180
182
|
rescue StandardError
|
|
181
183
|
nil
|
|
182
184
|
end
|
|
@@ -191,9 +193,10 @@ module Samagotchi
|
|
|
191
193
|
# The shape the engine persists: role and content (parts stay an
|
|
192
194
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
193
195
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
194
|
-
# refs of a user message or a tool result,
|
|
195
|
-
#
|
|
196
|
-
# never sent back), and an
|
|
196
|
+
# refs of a user message or a tool result, a prompt's turn_id (Engine,
|
|
197
|
+
# never sent), and a plugin tool result's tool_params and tool_labels
|
|
198
|
+
# (the live row's params line and label, never sent back), and an
|
|
199
|
+
# edit/write result's tool_diffs (never sent).
|
|
197
200
|
def plain(conversation)
|
|
198
201
|
conversation.map do |entry|
|
|
199
202
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -201,6 +204,7 @@ module Samagotchi
|
|
|
201
204
|
message[:tool_calls] = entry[:tool_calls] if entry[:tool_calls].is_a?(Array) && !entry[:tool_calls].empty?
|
|
202
205
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
203
206
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
207
|
+
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
204
208
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
205
209
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
206
210
|
message[TurnNote::RETRY_NUDGE] = true if TurnNote.retry_nudge?(entry)
|
|
@@ -8,12 +8,14 @@ module Samagotchi
|
|
|
8
8
|
# ChatLoop) and ToolRunner: the turn's VisionContext (nil sends no
|
|
9
9
|
# images, placeholders instead), its request parameters
|
|
10
10
|
# (SamplingSettings.for; empty sends none), its thinking level
|
|
11
|
-
# (Thinking.resolve)
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
11
|
+
# (Thinking.resolve), the bare model id the turn runs on (every debug
|
|
12
|
+
# dump is tagged with it) and the model's or host's configured context
|
|
13
|
+
# window (ContextWindow.setting, nil for none). The Engine sets them
|
|
14
|
+
# once per turn, in Engine#generate, after the model's host is synced.
|
|
15
|
+
TurnSettings = Data.define(:vision, :sampling, :thinking, :model_name, :window_setting) do
|
|
15
16
|
# No vision, no sampling, the default thinking, no model name.
|
|
16
|
-
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil
|
|
17
|
+
def self.none = new(vision: nil, sampling: {}.freeze, thinking: Thinking::DEFAULT, model_name: nil,
|
|
18
|
+
window_setting: nil)
|
|
17
19
|
end
|
|
18
20
|
end
|
|
19
21
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "paths"
|
|
5
|
+
require_relative "session"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# A plugin's state for one session, by convention under its data dir
|
|
9
|
+
# (ctx.data_dir): sessions/<id>.json or sessions/<id>/. It goes when the
|
|
10
|
+
# session does (SessionManager.delete_session: the CLI, the web, a
|
|
11
|
+
# discarded empty session; SessionRetention's prune), so no bundle keeps
|
|
12
|
+
# state for sessions that are gone (check-in's /checkin settings).
|
|
13
|
+
module PluginSessionState
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @return [Array<String>] the paths removed
|
|
17
|
+
def remove(session_id, env: ENV)
|
|
18
|
+
id = session_id.to_s
|
|
19
|
+
# Only a plain id: anything else could name a path outside the dir.
|
|
20
|
+
return [] unless Session.valid_id?(id)
|
|
21
|
+
|
|
22
|
+
Dir.glob(File.join(Paths.state_dir(env: env), "plugins", "*", "sessions")).flat_map do |dir|
|
|
23
|
+
[File.join(dir, "#{id}.json"), File.join(dir, id)].select { |path| File.exist?(path) }
|
|
24
|
+
.each { |path| FileUtils.rm_rf(path) }
|
|
25
|
+
end
|
|
26
|
+
rescue SystemCallError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -56,7 +56,7 @@ module Samagotchi
|
|
|
56
56
|
["profile", model ? profile_for(model, env) : "-"],
|
|
57
57
|
["thinking", model ? thinking_for(model, env) : "-"],
|
|
58
58
|
["served model", model ? served_model_for(model, env) : "-"],
|
|
59
|
-
["context window", context_window(env)],
|
|
59
|
+
["context window", context_window(env, model)],
|
|
60
60
|
["bundles", bundles_summary],
|
|
61
61
|
["desktop", desktop_summary(env)],
|
|
62
62
|
["chi web", web_summary]
|
|
@@ -100,9 +100,29 @@ module Samagotchi
|
|
|
100
100
|
def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
|
|
101
101
|
|
|
102
102
|
# Offline, so only the fallback: the running server's n_ctx wins at runtime.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
# The window config gives the current model (ContextWindow: its
|
|
104
|
+
# models.<key>.window_tokens, its host's, then context.window_tokens or
|
|
105
|
+
# the default) and which of them, as a turn resolves it offline.
|
|
106
|
+
def context_window(env, model = nil)
|
|
107
|
+
tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
|
|
108
|
+
"#{tokens} (#{where}; the server's n_ctx wins at runtime)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
|
|
112
|
+
def model_window(env, model)
|
|
113
|
+
return nil unless model
|
|
114
|
+
|
|
115
|
+
registry = HostRegistry.new(env: env)
|
|
116
|
+
target = registry.resolve(model)
|
|
117
|
+
names = registry.lookup_names(model, target: target)
|
|
118
|
+
models = ConfigFile.model_settings(env: env)
|
|
119
|
+
window = ContextWindow.setting(target, names: names, models: models)
|
|
120
|
+
return nil unless window
|
|
121
|
+
return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
|
|
122
|
+
|
|
123
|
+
[window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
def model_name
|
|
@@ -12,6 +12,7 @@ require_relative "config"
|
|
|
12
12
|
require_relative "session"
|
|
13
13
|
require_relative "session_inbox"
|
|
14
14
|
require_relative "session_metrics"
|
|
15
|
+
require_relative "utf8_default"
|
|
15
16
|
require_relative "turn_note"
|
|
16
17
|
require_relative "owner_lock"
|
|
17
18
|
require_relative "bridge_client"
|
|
@@ -22,6 +23,7 @@ require_relative "installed_gem"
|
|
|
22
23
|
require_relative "recap_store"
|
|
23
24
|
require_relative "archive_store"
|
|
24
25
|
require_relative "image_store"
|
|
26
|
+
require_relative "plugin_session_state"
|
|
25
27
|
require_relative "terminal_ui"
|
|
26
28
|
|
|
27
29
|
module Samagotchi
|
|
@@ -552,6 +554,9 @@ module Samagotchi
|
|
|
552
554
|
File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
|
|
553
555
|
elsif name == "analytics.json"
|
|
554
556
|
Array(JSON.parse(File.read(path))["turn_records"]).empty?
|
|
557
|
+
# Cards and notices from before any turn (Bridge::CardStore).
|
|
558
|
+
elsif name == "cards.json"
|
|
559
|
+
JSON.parse(File.read(path))["turns"].to_i.zero?
|
|
555
560
|
else
|
|
556
561
|
EMPTY_SKELETON_FILES.include?(name)
|
|
557
562
|
end
|
|
@@ -594,6 +599,7 @@ module Samagotchi
|
|
|
594
599
|
FileUtils.rm_rf(dir)
|
|
595
600
|
removed << dir
|
|
596
601
|
end
|
|
602
|
+
PluginSessionState.remove(id)
|
|
597
603
|
{ id: id, removed: removed, stopped: !owner.nil? }
|
|
598
604
|
end
|
|
599
605
|
|
|
@@ -631,6 +637,8 @@ module Samagotchi
|
|
|
631
637
|
# @return [Symbol] :idle_exit or :exit_requested
|
|
632
638
|
def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
|
|
633
639
|
idle_exit_minutes: nil, poll_interval: nil)
|
|
640
|
+
# A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
|
|
641
|
+
Utf8Default.apply!
|
|
634
642
|
sd = state_dir || Session.default_state_dir
|
|
635
643
|
# The spawner passed the file and level through ENV; a worker's stderr
|
|
636
644
|
# is /dev/null, so warnings only reach the file.
|
|
@@ -8,6 +8,7 @@ require_relative "config"
|
|
|
8
8
|
require_relative "log"
|
|
9
9
|
require_relative "model_profile"
|
|
10
10
|
require_relative "session_manager"
|
|
11
|
+
require_relative "plugin_session_state"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
13
14
|
# Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
|
|
@@ -79,7 +80,10 @@ module Samagotchi
|
|
|
79
80
|
next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
|
|
80
81
|
next if SessionManager.session_owner(name, state_dir: state_dir)
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
unless dry_run
|
|
84
|
+
FileUtils.rm_rf(dir)
|
|
85
|
+
PluginSessionState.remove(name)
|
|
86
|
+
end
|
|
83
87
|
name
|
|
84
88
|
rescue SystemCallError
|
|
85
89
|
nil
|
|
@@ -246,6 +250,7 @@ module Samagotchi
|
|
|
246
250
|
FileUtils.rm_f(path)
|
|
247
251
|
sidecar = File.join(state_dir, session.id)
|
|
248
252
|
FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
|
|
253
|
+
PluginSessionState.remove(session.id)
|
|
249
254
|
deleted << session.id
|
|
250
255
|
rescue StandardError
|
|
251
256
|
skipped << session.id
|
|
@@ -335,7 +335,7 @@ module Samagotchi
|
|
|
335
335
|
path = agent_description_path
|
|
336
336
|
return nil unless path
|
|
337
337
|
|
|
338
|
-
content = File.read(path).strip
|
|
338
|
+
content = File.read(path, encoding: "UTF-8").strip
|
|
339
339
|
return nil if content.empty?
|
|
340
340
|
|
|
341
341
|
"Project specific description:\n#{content}"
|
|
@@ -21,6 +21,8 @@ require_relative "../output_formatter"
|
|
|
21
21
|
require_relative "../session_commands"
|
|
22
22
|
require_relative "../session_manager"
|
|
23
23
|
require_relative "../guardrails/parent_approvals"
|
|
24
|
+
require_relative "../tool_activity"
|
|
25
|
+
require_relative "../web/message_parts"
|
|
24
26
|
|
|
25
27
|
module Samagotchi
|
|
26
28
|
class TerminalUI
|
|
@@ -105,6 +107,7 @@ module Samagotchi
|
|
|
105
107
|
@running = false
|
|
106
108
|
@joined_mid_turn = false
|
|
107
109
|
@attached = false
|
|
110
|
+
@early_lines = []
|
|
108
111
|
@question = nil
|
|
109
112
|
@answered_ids = Set.new
|
|
110
113
|
@reader = nil
|
|
@@ -162,16 +165,17 @@ module Samagotchi
|
|
|
162
165
|
when :event
|
|
163
166
|
ended = safely_handle(payload)
|
|
164
167
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
168
|
+
|
|
169
|
+
ended = take_early_lines
|
|
170
|
+
return ended if ended
|
|
165
171
|
return end_of_input if @input_ended && !own_turns_pending?
|
|
166
172
|
when :line
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
end
|
|
174
|
-
return end_of_input if submit(payload) == :detach
|
|
173
|
+
# A pipe is read at once: its lines wait for the joining
|
|
174
|
+
# snapshot, which may bring the question they answer.
|
|
175
|
+
next @early_lines << payload if @wait_at_eof && !@attached
|
|
176
|
+
|
|
177
|
+
ended = take_line(payload)
|
|
178
|
+
return ended if ended
|
|
175
179
|
when :interrupt then return :detached if interrupt(payload) == :detach
|
|
176
180
|
end
|
|
177
181
|
end
|
|
@@ -187,6 +191,30 @@ module Samagotchi
|
|
|
187
191
|
stream&.close
|
|
188
192
|
end
|
|
189
193
|
|
|
194
|
+
# One input line (nil: the input ended).
|
|
195
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
196
|
+
def take_line(line)
|
|
197
|
+
if line.nil? && @wait_at_eof && own_turns_pending?
|
|
198
|
+
# The pipe's end: this run's turns first. An open question
|
|
199
|
+
# has nothing left to answer it.
|
|
200
|
+
return unanswered_question(@question_pending) if @question
|
|
201
|
+
|
|
202
|
+
@input_ended = true
|
|
203
|
+
return nil
|
|
204
|
+
end
|
|
205
|
+
end_of_input if submit(line) == :detach
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The lines read before the snapshot came, once it has.
|
|
209
|
+
# @return [Symbol, nil] how the run ends, or nil to go on
|
|
210
|
+
def take_early_lines
|
|
211
|
+
until !@attached || @early_lines.empty?
|
|
212
|
+
ended = take_line(@early_lines.shift)
|
|
213
|
+
return ended if ended
|
|
214
|
+
end
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
190
218
|
# Render one Bridge event (string keys).
|
|
191
219
|
# @return [Symbol, nil] :closed when the stream ended, :failed when the
|
|
192
220
|
# launch's first command didn't go through
|
|
@@ -911,9 +939,7 @@ module Samagotchi
|
|
|
911
939
|
|
|
912
940
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
913
941
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
914
|
-
|
|
915
|
-
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
916
|
-
end
|
|
942
|
+
render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
|
|
917
943
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
918
944
|
# be only a tool call.
|
|
919
945
|
answer = exchange[(last_user + 1)..].reverse_each
|
|
@@ -923,6 +949,31 @@ module Samagotchi
|
|
|
923
949
|
render_join_notes(messages, after_exchange: true)
|
|
924
950
|
end
|
|
925
951
|
|
|
952
|
+
# What the last exchange did, as the live turn drew it: each saved
|
|
953
|
+
# call's tool row (action, params, status from its result; no
|
|
954
|
+
# duration, which the session doesn't keep) and a plugin's steer
|
|
955
|
+
# where it came. The step texts stay out, as live (the ticker's).
|
|
956
|
+
def render_join_steps(messages, from)
|
|
957
|
+
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
958
|
+
if Steer.steer?(m)
|
|
959
|
+
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
960
|
+
elsif %w[model assistant].include?(m[:role].to_s)
|
|
961
|
+
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
962
|
+
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
|
|
963
|
+
end
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# A saved call's row; "no result" for a call the turn never answered
|
|
968
|
+
# (it ended first).
|
|
969
|
+
def join_tool_line(part)
|
|
970
|
+
output = part[:output]
|
|
971
|
+
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
972
|
+
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
973
|
+
params: part[:params], status: status }
|
|
974
|
+
"#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
975
|
+
end
|
|
976
|
+
|
|
926
977
|
# The context notes that came since the last prompt (all of them in a
|
|
927
978
|
# session with none), after the exchange the header shows.
|
|
928
979
|
def render_join_notes(messages, after_exchange: false)
|
|
@@ -948,7 +999,8 @@ module Samagotchi
|
|
|
948
999
|
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
949
1000
|
# on join the ones since the last turn (and the running turn's), as
|
|
950
1001
|
# the join header shows the last exchange; on a resync only cards not
|
|
951
|
-
# shown here yet
|
|
1002
|
+
# shown here yet, and not the ones an earlier worker saved (a resync
|
|
1003
|
+
# onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
|
|
952
1004
|
# the running turn's come with its parts, and the join shows no older
|
|
953
1005
|
# turn's steps.
|
|
954
1006
|
def render_snapshot_cards(entries, joining:)
|
|
@@ -959,7 +1011,7 @@ module Samagotchi
|
|
|
959
1011
|
|
|
960
1012
|
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
961
1013
|
when "card"
|
|
962
|
-
next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
|
|
1014
|
+
next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
|
|
963
1015
|
|
|
964
1016
|
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
965
1017
|
end
|
|
@@ -14,6 +14,7 @@ require_relative "llm/errors"
|
|
|
14
14
|
require_relative "host_registry"
|
|
15
15
|
require_relative "session"
|
|
16
16
|
require_relative "owner_lock"
|
|
17
|
+
require_relative "bridge/card_store"
|
|
17
18
|
require_relative "engine"
|
|
18
19
|
require_relative "guardrails/parent_approvals"
|
|
19
20
|
require_relative "tools/memory"
|
|
@@ -223,6 +224,7 @@ module Samagotchi
|
|
|
223
224
|
scratch: @scratch
|
|
224
225
|
))
|
|
225
226
|
claim_session!(session.id) unless @owner_lock
|
|
227
|
+
keep_cards(session)
|
|
226
228
|
# Saved at once, so a process killed before its first turn leaves a
|
|
227
229
|
# file the sweep knows to delete.
|
|
228
230
|
session.save if @scratch
|
|
@@ -345,6 +347,15 @@ module Samagotchi
|
|
|
345
347
|
@surface.commit("Session #{session.id} was not archived (#{e.message}): chi sessions archive #{session.id}")
|
|
346
348
|
end
|
|
347
349
|
|
|
350
|
+
# The session's cards.json, kept as a worker's Bridge keeps it (it owns
|
|
351
|
+
# the session as a worker would): this REPL's kept turns count on from
|
|
352
|
+
# the saved count, so a card an earlier worker showed stays that many
|
|
353
|
+
# turns back, and its own cards and notices are saved for the next.
|
|
354
|
+
def keep_cards(session)
|
|
355
|
+
path = File.join(Session.session_dir(session.id), Bridge::CardStore::FILE)
|
|
356
|
+
@cards_handle ||= @engine.subscribe(observer: Bridge::CardStore.new(path: path))
|
|
357
|
+
end
|
|
358
|
+
|
|
348
359
|
# Take the session's OwnerLock for this process's lifetime: the TUI runs
|
|
349
360
|
# its own Engine, so no worker may run the session meanwhile.
|
|
350
361
|
# @raise [SessionBusy] when a worker or another TUI owns it
|
|
@@ -1112,6 +1123,7 @@ module Samagotchi
|
|
|
1112
1123
|
# was cancelled).
|
|
1113
1124
|
def answer_question_widget(prompt)
|
|
1114
1125
|
loop do
|
|
1126
|
+
@choice_echoed = false
|
|
1115
1127
|
raw = begin
|
|
1116
1128
|
yield
|
|
1117
1129
|
rescue Interrupt
|
|
@@ -1133,7 +1145,7 @@ module Samagotchi
|
|
|
1133
1145
|
@surface.commit(answer.note) if answer.note
|
|
1134
1146
|
unless answer.ok?
|
|
1135
1147
|
# The read left no echo: the line shows above its error.
|
|
1136
|
-
|
|
1148
|
+
echo_choice(raw)
|
|
1137
1149
|
@surface.commit(answer.error)
|
|
1138
1150
|
next
|
|
1139
1151
|
end
|
|
@@ -1146,7 +1158,7 @@ module Samagotchi
|
|
|
1146
1158
|
rescue QuestionDesk::Refused => e
|
|
1147
1159
|
# A parent's allow guardrails.parent_approvals doesn't let through:
|
|
1148
1160
|
# the question stays open for a deny (or the end of the input).
|
|
1149
|
-
|
|
1161
|
+
echo_choice(raw)
|
|
1150
1162
|
@surface.commit(Guardrails::ParentApprovals.message(e.reason, typed: true))
|
|
1151
1163
|
next
|
|
1152
1164
|
rescue ArgumentError => e
|
|
@@ -1173,7 +1185,19 @@ module Samagotchi
|
|
|
1173
1185
|
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1174
1186
|
def read_choice_line(question_prompt)
|
|
1175
1187
|
@surface.set_slot(:editor, [question_prompt])
|
|
1176
|
-
$stdin.gets
|
|
1188
|
+
line = $stdin.gets
|
|
1189
|
+
# A pipe doesn't echo: the line completes the ? prompt here, once,
|
|
1190
|
+
# and the widget doesn't echo it again (#echo_choice).
|
|
1191
|
+
unless $stdin.tty?
|
|
1192
|
+
@surface.commit(line.to_s.chomp)
|
|
1193
|
+
@choice_echoed = true
|
|
1194
|
+
end
|
|
1195
|
+
line
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
# The answer line above its error, unless its read echoed it already.
|
|
1199
|
+
def echo_choice(raw)
|
|
1200
|
+
@surface.commit("#{paint(QUESTION_PROMPT, 33)}#{raw}") unless @choice_echoed
|
|
1177
1201
|
end
|
|
1178
1202
|
|
|
1179
1203
|
# The next line submitted at the open prompt (?), nil for Ctrl-D
|
|
@@ -9,7 +9,8 @@ require_relative "peers"
|
|
|
9
9
|
module Samagotchi
|
|
10
10
|
module Tools
|
|
11
11
|
# Waiting for a delegated session's next reply, shared by delegate and
|
|
12
|
-
# delegate_result: ReplyWait in the tools' words, with
|
|
12
|
+
# delegate_result: ReplyWait in the tools' words, with the status words
|
|
13
|
+
# `chi send --wait` and `chi answer` use (ParentReport.status), with a cursor per
|
|
13
14
|
# child (the newest reply this parent was already given) and the child
|
|
14
15
|
# as it was before the message went in (ReplyWait's baseline), so a turn
|
|
15
16
|
# that ends before the wait's first look (a fast failure) still ends it.
|
|
@@ -56,22 +57,23 @@ module Samagotchi
|
|
|
56
57
|
owner_grace: owner_grace)
|
|
57
58
|
# The turn sent to is handed over: a later wait looks for a later one.
|
|
58
59
|
baselines.delete(key) if %i[done no_reply].include?(wait.status)
|
|
60
|
+
status = ParentReport.status(wait)
|
|
59
61
|
case wait.status
|
|
60
62
|
when :done
|
|
61
63
|
seen[key] = wait.file
|
|
62
|
-
reply_result(child_id, wait.text)
|
|
64
|
+
reply_result(child_id, status, wait.text)
|
|
63
65
|
when :error
|
|
64
|
-
result(child_id,
|
|
66
|
+
result(child_id, status, "the child's worker failed: #{wait.text}; its session shows what happened")
|
|
65
67
|
when :stopped
|
|
66
|
-
result(child_id,
|
|
68
|
+
result(child_id, status, "the child was stopped (chi sessions stop); delegate with session: #{child_id} starts it again with a message")
|
|
67
69
|
when :waiting_for_answer
|
|
68
|
-
result(child_id,
|
|
70
|
+
result(child_id, status, waiting_text(child_id, wait.question))
|
|
69
71
|
when :canceled
|
|
70
|
-
result(child_id,
|
|
72
|
+
result(child_id, status, "wait canceled; the child keeps running; delegate_result #{child_id} waits again")
|
|
71
73
|
when :no_reply
|
|
72
|
-
result(child_id,
|
|
74
|
+
result(child_id, status, "#{no_reply_text(wait)}; its session shows what happened")
|
|
73
75
|
when :worker_gone
|
|
74
|
-
result(child_id,
|
|
76
|
+
result(child_id, status,
|
|
75
77
|
"the child's worker is gone (it stopped or crashed); delegate with session: #{child_id} starts it again with a message")
|
|
76
78
|
else
|
|
77
79
|
timeout_result(child_id, timeout)
|
|
@@ -95,8 +97,13 @@ module Samagotchi
|
|
|
95
97
|
baselines[[parent_id, child.id]] = ReplyWait.baseline_of(child, question_id: nil)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
|
-
def reply_result(child_id, text)
|
|
99
|
-
"session: #{child_id}\nstatus:
|
|
100
|
+
def reply_result(child_id, status, text)
|
|
101
|
+
"session: #{child_id}\nstatus: #{status}\n---\n#{cut(text)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ParentReport's line for a turn that left no reply, about the child.
|
|
105
|
+
def no_reply_text(wait)
|
|
106
|
+
ParentReport.no_reply_line(wait).sub(/\Athe turn/, "the child's turn")
|
|
100
107
|
end
|
|
101
108
|
|
|
102
109
|
def result(child_id, status, text)
|
|
@@ -59,13 +59,13 @@ module Samagotchi
|
|
|
59
59
|
resolved_scopes.each do |resolved_scope|
|
|
60
60
|
path = memory_path(name, resolved_scope)
|
|
61
61
|
if File.exist?(path)
|
|
62
|
-
body = File.read(path)
|
|
62
|
+
body = File.read(path, encoding: "UTF-8")
|
|
63
63
|
# Append model-specific overlay if key is provided and overlay exists.
|
|
64
64
|
[model_key, fallback_model_key].compact.each do |key|
|
|
65
65
|
overlay_path = ModelOverlay.overlay_path_for(name, key, resolved_scope)
|
|
66
66
|
next unless overlay_path && File.exist?(overlay_path)
|
|
67
67
|
|
|
68
|
-
body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path)
|
|
68
|
+
body += SEPARATOR + "Model-specific guidance (#{key}):\n" + File.read(overlay_path, encoding: "UTF-8")
|
|
69
69
|
break
|
|
70
70
|
end
|
|
71
71
|
results << body
|
|
@@ -114,7 +114,7 @@ module Samagotchi
|
|
|
114
114
|
def self.scoped_index(scope)
|
|
115
115
|
dir = memories_dir(scope)
|
|
116
116
|
index_path = File.join(dir, "#{MEMORY_INDEX}.md")
|
|
117
|
-
return File.read(index_path) if File.exist?(index_path)
|
|
117
|
+
return File.read(index_path, encoding: "UTF-8") if File.exist?(index_path)
|
|
118
118
|
|
|
119
119
|
files = Dir.glob(File.join(dir, "*.md")).sort
|
|
120
120
|
return "No memories stored yet." if files.empty?
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# chi's files (memories, AGENT.md, sessions, config.yml) are UTF-8,
|
|
5
|
+
# whatever the locale. Under LC_ALL=C (or with no locale at all: an app
|
|
6
|
+
# started from Finder or launchd, `env -i`) Ruby reads files as US-ASCII,
|
|
7
|
+
# and a UTF-8 memory index then breaks the system prompt
|
|
8
|
+
# (Encoding::CompatibilityError). bin/chi and every worker call #apply!
|
|
9
|
+
# first.
|
|
10
|
+
module Utf8Default
|
|
11
|
+
LOCALE_KEYS = %w[LC_ALL LC_CTYPE LANG].freeze
|
|
12
|
+
|
|
13
|
+
# Files read as UTF-8 when the locale says US-ASCII. With no locale set
|
|
14
|
+
# at all, LANG too, so the workers and tools chi spawns get one; a
|
|
15
|
+
# locale the user set (LC_ALL=C) is left to them.
|
|
16
|
+
def self.apply!(env = ENV)
|
|
17
|
+
return unless Encoding.default_external == Encoding::US_ASCII
|
|
18
|
+
|
|
19
|
+
env["LANG"] = "en_US.UTF-8" if LOCALE_KEYS.none? { |key| env[key] }
|
|
20
|
+
verbose = $VERBOSE
|
|
21
|
+
begin
|
|
22
|
+
$VERBOSE = nil # Encoding.default_external= warns
|
|
23
|
+
Encoding.default_external = Encoding::UTF_8
|
|
24
|
+
ensure
|
|
25
|
+
$VERBOSE = verbose
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "rack/request"
|
|
|
11
11
|
|
|
12
12
|
require_relative "../bridge_client"
|
|
13
13
|
require_relative "../bridge/bounded_queue"
|
|
14
|
+
require_relative "../bridge/card_store"
|
|
14
15
|
require_relative "../session"
|
|
15
16
|
require_relative "../session_manager"
|
|
16
17
|
require_relative "../session_commands"
|
|
@@ -533,7 +534,7 @@ module Samagotchi
|
|
|
533
534
|
# ?cards=1: the page's re-read when a card arrives, for its rendered
|
|
534
535
|
# body (the stream carries the card's plain text only).
|
|
535
536
|
if req.params["cards"] == "1"
|
|
536
|
-
return json_response(200, { cards: cards_for_display(turn_snapshot) })
|
|
537
|
+
return json_response(200, { cards: cards_for_display(turn_snapshot, id) })
|
|
537
538
|
end
|
|
538
539
|
snapshot = live && live["session_state_snapshot"]
|
|
539
540
|
last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
|
|
@@ -584,7 +585,7 @@ module Samagotchi
|
|
|
584
585
|
plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
|
|
585
586
|
# Plugins' slow setup still running (chi.init): the page's init row.
|
|
586
587
|
init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
|
|
587
|
-
cards: cards_for_display(turn_snapshot),
|
|
588
|
+
cards: cards_for_display(turn_snapshot, id),
|
|
588
589
|
# The composer's / autocomplete; the built-ins until a worker
|
|
589
590
|
# names its plugins' too.
|
|
590
591
|
commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
|
|
@@ -601,11 +602,14 @@ module Samagotchi
|
|
|
601
602
|
end
|
|
602
603
|
|
|
603
604
|
# The worker's last cards and between-turns notices (Bridge
|
|
604
|
-
# snapshot[:cards]),
|
|
605
|
-
#
|
|
606
|
-
#
|
|
607
|
-
|
|
608
|
-
|
|
605
|
+
# snapshot[:cards]), else (no live worker) the ones the last worker
|
|
606
|
+
# saved in the session's folder (CardStore.saved), a card's body as
|
|
607
|
+
# body_html: rendered markdown when the renderer is on, else the
|
|
608
|
+
# escaped text in a <pre>.
|
|
609
|
+
# @return [Array<Hash>]
|
|
610
|
+
def cards_for_display(turn_snapshot, id)
|
|
611
|
+
cards = turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)
|
|
612
|
+
Array(cards).filter_map do |card|
|
|
609
613
|
next unless card.is_a?(Hash)
|
|
610
614
|
next card unless card["type"].to_s == "card"
|
|
611
615
|
|
|
@@ -613,6 +617,13 @@ module Samagotchi
|
|
|
613
617
|
end
|
|
614
618
|
end
|
|
615
619
|
|
|
620
|
+
# As JSON has them (string keys and values), like a snapshot's.
|
|
621
|
+
def saved_cards(id)
|
|
622
|
+
JSON.parse(JSON.generate(Samagotchi::Bridge::CardStore.saved(Session.session_dir(id, state_dir: default_state_dir))))
|
|
623
|
+
rescue StandardError
|
|
624
|
+
[]
|
|
625
|
+
end
|
|
626
|
+
|
|
616
627
|
def card_body_html(body)
|
|
617
628
|
return "" if body.strip.empty?
|
|
618
629
|
|
|
@@ -1258,6 +1269,10 @@ module Samagotchi
|
|
|
1258
1269
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1259
1270
|
# read by step +step+.
|
|
1260
1271
|
message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1272
|
+
# The turn it started (Engine): the page pairs it with that turn's
|
|
1273
|
+
# record by this, not by its place.
|
|
1274
|
+
turn_id = m[:turn_id] || m["turn_id"]
|
|
1275
|
+
message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
|
|
1261
1276
|
images = m[:images] || m["images"]
|
|
1262
1277
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1263
1278
|
if norm_role == "assistant"
|