samagotchi 0.5.1 → 0.6.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 +64 -1
- data/bin/chi +32 -891
- data/docs/architecture.md +8 -1
- data/docs/cli.md +12 -13
- data/docs/configuration.md +4 -1
- data/docs/desktop.md +3 -1
- data/docs/hooks.md +69 -4
- data/docs/plugins.md +98 -3
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/card_store.rb +30 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
- data/lib/samagotchi/bridge/turn_notice.rb +37 -0
- data/lib/samagotchi/bundle_command.rb +652 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/cancellation_controller.rb +42 -1
- data/lib/samagotchi/config.rb +2 -2
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
- data/lib/samagotchi/desktop/macos/Panel.swift +15 -0
- data/lib/samagotchi/engine.rb +138 -618
- data/lib/samagotchi/guardrail_wiring.rb +238 -0
- data/lib/samagotchi/hooks/registry.rb +11 -3
- data/lib/samagotchi/hooks/stream_watch.rb +93 -0
- data/lib/samagotchi/hooks.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +65 -6
- data/lib/samagotchi/llm/chat_loop.rb +73 -11
- data/lib/samagotchi/llm/openai_chat.rb +4 -2
- data/lib/samagotchi/log_subscriber.rb +10 -4
- data/lib/samagotchi/plugin/api.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +12 -3
- data/lib/samagotchi/plugin_tasks.rb +229 -0
- data/lib/samagotchi/question_desk.rb +280 -0
- data/lib/samagotchi/recap_setup.rb +128 -0
- data/lib/samagotchi/session.rb +9 -1
- data/lib/samagotchi/sessions_command.rb +255 -0
- data/lib/samagotchi/steer.rb +19 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate.rb +4 -1
- data/lib/samagotchi/turn_note.rb +20 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -8
- data/lib/samagotchi/web/public/app.js +94 -52
- data/lib/samagotchi/web/public/card.js +8 -1
- data/lib/samagotchi/web/public/format.js +1 -1
- data/lib/samagotchi/web/public/index.html +2 -5
- data/lib/samagotchi/web/public/stage_model.js +44 -0
- data/lib/samagotchi/web/public/stage_view.js +26 -17
- data/lib/samagotchi/web/public/timing.js +9 -3
- data/lib/samagotchi/web/public/turn_events.js +18 -5
- data/lib/samagotchi/web/public/turn_view.js +68 -16
- data/lib/samagotchi/web/server.rb +4 -3
- metadata +9 -2
- data/lib/samagotchi/web/public/chat_view.js +0 -376
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "idle_recap"
|
|
6
|
+
require_relative "recap_store"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# Builds (or disables) an Engine's idle recap job from the `recap:` kwarg
|
|
10
|
+
# and the Config registry. Init-only: the Engine keeps the job it returns.
|
|
11
|
+
module RecapSetup
|
|
12
|
+
# Build (or disable) the idle recap job. On by default: with no recap
|
|
13
|
+
# host or model configured it asks the session's current model on its
|
|
14
|
+
# host, resolved at each attempt the way a turn does (a /model switch
|
|
15
|
+
# counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
|
|
16
|
+
# model:}` pins it; an incomplete one warns and leaves recap off.
|
|
17
|
+
#
|
|
18
|
+
# Single precedence path: explicit `recap:` kwarg > Config registry
|
|
19
|
+
# (CLI > ENV > file > default). An explicit disable (`recap: false` as
|
|
20
|
+
# the kwarg or in the config file, `recap: {enabled: false}`, or
|
|
21
|
+
# SAMAGOTCHI_RECAP_ENABLED=false) always wins.
|
|
22
|
+
#
|
|
23
|
+
# @param recap [Hash, false, nil] the Engine's `recap:` kwarg
|
|
24
|
+
# @param engine [Engine] the recap job's engine
|
|
25
|
+
# @param host_registry [HostRegistry]
|
|
26
|
+
# @param session_target [#call] → the session's current model as a recap target
|
|
27
|
+
# @param session_id [#call] → String, nil
|
|
28
|
+
# @param state_dir [#call] → String
|
|
29
|
+
# @return [IdleRecap, nil]
|
|
30
|
+
def self.build(recap, engine:, host_registry:, session_target:, session_id:, state_dir:)
|
|
31
|
+
return nil if recap == false
|
|
32
|
+
# The TUI passes the config file's section; a worker passes nothing, so
|
|
33
|
+
# read it here too (a scalar `recap: false` is only seen this way).
|
|
34
|
+
return nil if recap.nil? && ConfigFile.recap_config == false
|
|
35
|
+
return nil if Samagotchi::Config.get("recap.enabled") == false
|
|
36
|
+
|
|
37
|
+
# Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
|
|
38
|
+
kwarg_config = recap.is_a?(Hash) ? recap : {}
|
|
39
|
+
|
|
40
|
+
base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
|
|
41
|
+
host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
|
|
42
|
+
model = string_config(kwarg_config, :model) || registry_string("recap.model")
|
|
43
|
+
label = model
|
|
44
|
+
|
|
45
|
+
target = nil
|
|
46
|
+
if base_url.nil? && host_ref.nil? && model.nil?
|
|
47
|
+
target = -> { session_target.call }
|
|
48
|
+
else
|
|
49
|
+
# If host_ref given, derive base_url (the host's OpenAI base) and its
|
|
50
|
+
# API key variable from the host_registry entry
|
|
51
|
+
api_key_env = nil
|
|
52
|
+
if host_ref && !host_ref.empty?
|
|
53
|
+
entry = host_registry.find_entry(host_ref)
|
|
54
|
+
if entry
|
|
55
|
+
base_url = entry.openai_base_url
|
|
56
|
+
api_key_env = entry.api_key_env
|
|
57
|
+
# If model is host-qualified, extract bare model for recap client
|
|
58
|
+
_, bare = host_registry.parse_qualified_model(model) if model
|
|
59
|
+
model = bare if bare && !bare.empty?
|
|
60
|
+
else
|
|
61
|
+
Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if base_url.to_s.strip.empty? || model.to_s.strip.empty?
|
|
67
|
+
Log.warn(:recap, "recap_unconfigured",
|
|
68
|
+
echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
|
|
69
|
+
"Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
|
|
70
|
+
"or leave them all out to recap with the session's own model.")
|
|
71
|
+
return nil
|
|
72
|
+
end
|
|
73
|
+
fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
|
|
74
|
+
target = -> { fixed }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
IdleRecap.new(
|
|
78
|
+
engine: engine,
|
|
79
|
+
target: target,
|
|
80
|
+
inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
|
|
81
|
+
min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
|
|
82
|
+
timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
|
|
83
|
+
sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
|
|
84
|
+
store: RecapStore.new(session_id_lookup: -> { session_id.call }, state_dir_lookup: -> { state_dir.call })
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# recap.sentences as [min, max]; an invalid value warns and falls back to
|
|
89
|
+
# the default range (the recap stays on).
|
|
90
|
+
def self.recap_sentences(value)
|
|
91
|
+
range = IdleRecap::RecapPrompt.sentences_range(value)
|
|
92
|
+
return range if range
|
|
93
|
+
|
|
94
|
+
default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
|
|
95
|
+
Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join('-')}",
|
|
96
|
+
value: value.to_s)
|
|
97
|
+
default
|
|
98
|
+
end
|
|
99
|
+
private_class_method :recap_sentences
|
|
100
|
+
|
|
101
|
+
# Read a scalar recap setting via the Config registry (ENV > file > default).
|
|
102
|
+
def self.registry_string(key)
|
|
103
|
+
value = Samagotchi::Config.get(key)
|
|
104
|
+
value = value.to_s.strip
|
|
105
|
+
value.empty? ? nil : value
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
private_class_method :registry_string
|
|
110
|
+
|
|
111
|
+
# Resolve a numeric recap setting: kwarg > Config registry > built-in default.
|
|
112
|
+
def self.recap_number_setting(kwarg_config, kwarg_key, config_key, default, numeric_type)
|
|
113
|
+
value = kwarg_config[kwarg_key] || kwarg_config[kwarg_key.to_s]
|
|
114
|
+
value = Samagotchi::Config.get(config_key) if value.nil? || value.to_s.strip.empty?
|
|
115
|
+
value = default if value.nil? || value.to_s.strip.empty?
|
|
116
|
+
numeric_type == :float ? value.to_f : value.to_i
|
|
117
|
+
rescue StandardError
|
|
118
|
+
numeric_type == :float ? default.to_f : default.to_i
|
|
119
|
+
end
|
|
120
|
+
private_class_method :recap_number_setting
|
|
121
|
+
|
|
122
|
+
def self.string_config(config, key)
|
|
123
|
+
value = config[key]
|
|
124
|
+
value.to_s.strip.empty? ? nil : value.to_s
|
|
125
|
+
end
|
|
126
|
+
private_class_method :string_config
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -297,6 +297,9 @@ module Samagotchi
|
|
|
297
297
|
# A scratch session nobody owns (its REPL was killed) goes whatever its
|
|
298
298
|
# age, its status and the count; +test_only+ takes it too.
|
|
299
299
|
#
|
|
300
|
+
# +max_count+ counts the sessions that stay: one deleted whatever its
|
|
301
|
+
# place (scratch, left empty, expired, +any_age+) takes no slot.
|
|
302
|
+
#
|
|
300
303
|
# Archived sessions are not in .list, so they are neither deleted nor
|
|
301
304
|
# counted. One unarchived is aged from when it was unarchived, if later.
|
|
302
305
|
#
|
|
@@ -319,6 +322,7 @@ module Samagotchi
|
|
|
319
322
|
deleted = []
|
|
320
323
|
kept = []
|
|
321
324
|
skipped = []
|
|
325
|
+
passed_over = 0
|
|
322
326
|
|
|
323
327
|
all.each_with_index do |session, idx|
|
|
324
328
|
path = File.join(state_dir, "#{session.id}#{FILE_EXT}")
|
|
@@ -365,7 +369,11 @@ module Samagotchi
|
|
|
365
369
|
expired = updated < cutoff
|
|
366
370
|
end
|
|
367
371
|
|
|
368
|
-
|
|
372
|
+
# A session deleted whatever its place (scratch, left empty,
|
|
373
|
+
# expired, any_age) takes no max_count slot: the count is of the
|
|
374
|
+
# sessions ahead that stay.
|
|
375
|
+
overflow = max.positive? && idx - passed_over >= max
|
|
376
|
+
passed_over += 1 if expired || left_empty || any_age || session.scratch
|
|
369
377
|
|
|
370
378
|
# retain forever when both disabled
|
|
371
379
|
if max.zero? && cutoff.nil? && !left_empty && !any_age && !session.scratch
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "session"
|
|
5
|
+
require_relative "session_manager"
|
|
6
|
+
require_relative "project_scope"
|
|
7
|
+
require_relative "session_metrics"
|
|
8
|
+
require_relative "recap_store"
|
|
9
|
+
require_relative "session_delete_command"
|
|
10
|
+
require_relative "session_archive_command"
|
|
11
|
+
|
|
12
|
+
module Samagotchi
|
|
13
|
+
# `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
|
|
14
|
+
# sessions from the shell (bin/chi dispatches here before OptionParser;
|
|
15
|
+
# the subcommands have their own flags). Not SessionCommands, the REPL's
|
|
16
|
+
# slash commands for sessions (session_commands.rb).
|
|
17
|
+
class SessionsCommand
|
|
18
|
+
USAGE = <<~TEXT
|
|
19
|
+
Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
|
|
20
|
+
list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
|
|
21
|
+
[--live] [--cwd PATH] [--format text|json|tsv] [--archived]
|
|
22
|
+
--live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
|
|
23
|
+
--cwd PATH: sessions in PATH or below; json/tsv (id<TAB>description) are for scripts
|
|
24
|
+
[--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
|
|
25
|
+
--archived: archived sessions too (marked [archived]; json: archived: true)
|
|
26
|
+
stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
|
|
27
|
+
archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
|
|
28
|
+
delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
|
|
29
|
+
prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
|
|
30
|
+
clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
|
|
31
|
+
Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
|
|
32
|
+
TEXT
|
|
33
|
+
|
|
34
|
+
# The value flags: "--flag V" or "--flag=V", converted with the method
|
|
35
|
+
# given (nil: the string as it is). Every argument is scanned, the values
|
|
36
|
+
# too (--cwd --live takes "--live" as the folder and turns --live on),
|
|
37
|
+
# a flag with nothing after it is ignored, and so is an unknown one.
|
|
38
|
+
VALUE_FLAGS = {
|
|
39
|
+
"--days" => [:days, :to_i], "--keep" => [:keep, :to_i], "--keep-status" => [:keep_status, nil],
|
|
40
|
+
"--sort" => [:sort, nil], "--order" => [:order, nil], "--limit" => [:limit, :to_i],
|
|
41
|
+
"--cwd" => [:cwd, nil], "--format" => [:format, nil], "--scope" => [:scope, nil]
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# @param argv [Array<String>] the arguments after "sessions"
|
|
45
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
46
|
+
@argv = argv.dup
|
|
47
|
+
@stdout = stdout
|
|
48
|
+
@stderr = stderr
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer] exit status
|
|
52
|
+
def run
|
|
53
|
+
sub = @argv[0]
|
|
54
|
+
if sub.nil? || %w[-h --help help].include?(sub)
|
|
55
|
+
@stdout.puts USAGE
|
|
56
|
+
return 0
|
|
57
|
+
end
|
|
58
|
+
@opts = parse_flags
|
|
59
|
+
case sub
|
|
60
|
+
when "delete"
|
|
61
|
+
Samagotchi::SessionDeleteCommand.new(@argv[1..], stdout: @stdout, stderr: @stderr).run
|
|
62
|
+
when "archive", "unarchive"
|
|
63
|
+
Samagotchi::SessionArchiveCommand.new(sub, @argv[1..], stdout: @stdout, stderr: @stderr).run
|
|
64
|
+
when "list" then list
|
|
65
|
+
when "stop" then stop
|
|
66
|
+
when "prune", "clean" then prune(sub)
|
|
67
|
+
else
|
|
68
|
+
@stderr.puts "Unknown sessions subcommand: #{sub}. Use: list, stop, archive, unarchive, delete, prune, clean"
|
|
69
|
+
1
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def parse_flags
|
|
76
|
+
opts = {
|
|
77
|
+
dry_run: @argv.include?("--dry-run"),
|
|
78
|
+
test_only: @argv.include?("--test-only") || @argv.include?("--test"),
|
|
79
|
+
live: @argv.include?("--live"),
|
|
80
|
+
include_archived: @argv.include?("--archived")
|
|
81
|
+
}
|
|
82
|
+
@argv.each_with_index do |arg, i|
|
|
83
|
+
nxt = @argv[i + 1]
|
|
84
|
+
VALUE_FLAGS.each do |flag, (key, convert)|
|
|
85
|
+
value = if arg == flag && nxt then nxt
|
|
86
|
+
elsif arg.start_with?("#{flag}=") then arg.split("=", 2).last
|
|
87
|
+
end
|
|
88
|
+
next unless value
|
|
89
|
+
|
|
90
|
+
opts[key] = convert ? value.public_send(convert) : value
|
|
91
|
+
break
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
opts
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def list
|
|
98
|
+
format, scope, cwd = @opts.values_at(:format, :scope, :cwd)
|
|
99
|
+
unless format.nil? || %w[text json tsv].include?(format)
|
|
100
|
+
@stderr.puts "Unknown format #{format.inspect}: use --format text|json|tsv"
|
|
101
|
+
return 1
|
|
102
|
+
end
|
|
103
|
+
unless scope.nil? || %w[project all].include?(scope)
|
|
104
|
+
@stderr.puts "Unknown scope #{scope.inspect}: use --scope=project|all"
|
|
105
|
+
return 1
|
|
106
|
+
end
|
|
107
|
+
# The current git project's sessions (Samagotchi::ProjectScope), like
|
|
108
|
+
# chi web's; --scope=all, a folder in no repo or an explicit --cwd: every
|
|
109
|
+
# project's.
|
|
110
|
+
project = scope == "all" || cwd ? nil : Samagotchi::ProjectScope.root_for(Dir.pwd)
|
|
111
|
+
scope_note = ->(count) { "#{count} session(s) in #{File.basename(project)} (--scope=all: every project)" }
|
|
112
|
+
|
|
113
|
+
# The picker (chi note from a script): filters apply before the limit,
|
|
114
|
+
# and test runs stay out, unless this is one (SAMAGOTCHI_ENV=test, CI):
|
|
115
|
+
# then its own sessions are what it looks for.
|
|
116
|
+
return list_summaries(project, scope_note) if @opts[:live] || cwd || (format && format != "text")
|
|
117
|
+
|
|
118
|
+
list_plain(project, scope_note)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def list_summaries(project, scope_note)
|
|
122
|
+
live, cwd, limit, include_archived, format = @opts.values_at(:live, :cwd, :limit, :include_archived, :format)
|
|
123
|
+
summaries = Samagotchi::SessionManager.session_summaries(
|
|
124
|
+
live: live, cwd: cwd, limit: limit || (live ? 10 : nil), include_tests: Samagotchi::Session.test_session_env?,
|
|
125
|
+
project_root: project,
|
|
126
|
+
include_archived: include_archived
|
|
127
|
+
)
|
|
128
|
+
case format
|
|
129
|
+
when "json"
|
|
130
|
+
# owner: "worker", "tui" (a chi REPL: it takes no notes or messages)
|
|
131
|
+
# or nil; recap: the saved recap's first sentence, or nil; project:
|
|
132
|
+
# its git project's root, or nil
|
|
133
|
+
# parent_id: the session that delegated it (the delegate tool), or nil
|
|
134
|
+
# archived: hidden from the lists (only with --archived can it be true)
|
|
135
|
+
# scratch: a `chi scratch` session (deleted when its REPL ends, a
|
|
136
|
+
# leftover one at the next sweep)
|
|
137
|
+
# ctx_pct: how full the context was after the last turn, or nil
|
|
138
|
+
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct]
|
|
139
|
+
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
140
|
+
when "tsv"
|
|
141
|
+
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
142
|
+
else
|
|
143
|
+
summaries.each do |summary|
|
|
144
|
+
state = summary[:busy] ? "running" : (summary[:live] ? "live" : summary[:status].to_s)
|
|
145
|
+
# The saved recap's first sentence says more than the last prompt
|
|
146
|
+
# (text only: tsv and json keep desc for pickers).
|
|
147
|
+
desc = summary[:desc]
|
|
148
|
+
if summary[:recap]
|
|
149
|
+
desc = [File.basename(summary[:cwd].to_s), summary[:recap]].reject(&:empty?).join(" · ")
|
|
150
|
+
desc = "#{desc[0, 59]}…" if desc.length > 60
|
|
151
|
+
end
|
|
152
|
+
# A delegated session points at its parent.
|
|
153
|
+
child = summary[:parent_short_id] ? " ↳ #{summary[:parent_short_id]}" : ""
|
|
154
|
+
flag = summary[:scratch] ? " [scratch]" : (summary[:test_run] ? " [test]" : "")
|
|
155
|
+
flag += " [archived]" if summary[:archived]
|
|
156
|
+
ctx = Samagotchi::SessionMetrics.context_label(summary[:ctx_pct])
|
|
157
|
+
@stdout.puts "#{summary[:id]} #{state.ljust(8)} #{ctx.ljust(8)} #{summary[:updated_at]} #{desc}#{flag}#{child}"
|
|
158
|
+
end
|
|
159
|
+
if project
|
|
160
|
+
@stdout.puts "#{summaries.empty? ? "" : "\n"}#{scope_note.call(summaries.size)}"
|
|
161
|
+
else
|
|
162
|
+
@stdout.puts summaries.empty? ? "No sessions." : "\n#{summaries.size} session(s)"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def list_plain(project, scope_note)
|
|
169
|
+
sort, order, limit, include_archived = @opts.values_at(:sort, :order, :limit, :include_archived)
|
|
170
|
+
sort ||= "updated_at"
|
|
171
|
+
order ||= "desc"
|
|
172
|
+
sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
|
|
173
|
+
include_archived: include_archived)
|
|
174
|
+
# The saved recap's first sentence, else the last prompt, cut as before.
|
|
175
|
+
state_dir = Samagotchi::Session.default_state_dir
|
|
176
|
+
list_text = lambda do |s|
|
|
177
|
+
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
178
|
+
(recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
|
|
179
|
+
end
|
|
180
|
+
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
181
|
+
row = lambda do |s|
|
|
182
|
+
flag = s.scratch ? " [scratch]" : (s.test_run ? " [test]" : "")
|
|
183
|
+
flag += " [archived]" if s.archived
|
|
184
|
+
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
185
|
+
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
186
|
+
ctx = Samagotchi::SessionMetrics.context_label(
|
|
187
|
+
Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
188
|
+
)
|
|
189
|
+
"#{s.id} #{s.status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
190
|
+
end
|
|
191
|
+
if project
|
|
192
|
+
sessions.each { |s| @stdout.puts row.call(s) }
|
|
193
|
+
@stdout.puts "#{sessions.empty? ? "" : "\n"}#{scope_note.call(sessions.size)}"
|
|
194
|
+
elsif sessions.empty?
|
|
195
|
+
@stdout.puts "No sessions."
|
|
196
|
+
else
|
|
197
|
+
sessions.each { |s| @stdout.puts row.call(s) }
|
|
198
|
+
@stdout.puts "\n#{sessions.size} session(s) (sort=#{sort} order=#{order})"
|
|
199
|
+
end
|
|
200
|
+
0
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def stop
|
|
204
|
+
ids = @argv[1..]
|
|
205
|
+
if ids.empty? || ids.any? { |arg| arg.start_with?("-") }
|
|
206
|
+
@stderr.puts "Usage: chi sessions stop ID..."
|
|
207
|
+
return 1
|
|
208
|
+
end
|
|
209
|
+
# Each id in turn, like chi sessions delete: stdout is flushed before an
|
|
210
|
+
# error line, so the output keeps the order of the ids given.
|
|
211
|
+
ok = ids.uniq.map do |given|
|
|
212
|
+
begin
|
|
213
|
+
id = Samagotchi::Session.resolve_id(given)
|
|
214
|
+
released = Samagotchi::SessionManager.stop_session(id, wait: 10)
|
|
215
|
+
rescue Samagotchi::SessionManager::OwnedByTUI
|
|
216
|
+
@stdout.flush
|
|
217
|
+
@stderr.puts "session #{id} is open in a chi REPL; close it there first"
|
|
218
|
+
next false
|
|
219
|
+
rescue ArgumentError => e
|
|
220
|
+
@stdout.flush
|
|
221
|
+
@stderr.puts e.message
|
|
222
|
+
next false
|
|
223
|
+
end
|
|
224
|
+
if released
|
|
225
|
+
@stdout.puts "Stopped session #{id}. chi --resume #{id} starts a fresh worker."
|
|
226
|
+
else
|
|
227
|
+
@stdout.flush
|
|
228
|
+
@stderr.puts "Stopped session #{id}, but its worker is still shutting down; try chi --resume #{id} in a moment."
|
|
229
|
+
end
|
|
230
|
+
true
|
|
231
|
+
end
|
|
232
|
+
ok.all? ? 0 : 1
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def prune(sub)
|
|
236
|
+
days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
|
|
237
|
+
test_only = true if sub == "clean" && !@argv.include?("--all")
|
|
238
|
+
# Test sessions are throwaway: clean takes them whatever their age,
|
|
239
|
+
# unless --days asks for the older ones only. Live workers and
|
|
240
|
+
# keep_status still protect a session.
|
|
241
|
+
any_age = sub == "clean" && test_only && days.nil?
|
|
242
|
+
result = Samagotchi::SessionManager.prune_sessions(days: days, max_count: keep, keep_status: keep_status, dry_run: dry_run,
|
|
243
|
+
test_only: test_only, any_age: any_age)
|
|
244
|
+
mode = dry_run ? "Would delete" : "Deleted"
|
|
245
|
+
@stdout.puts "#{mode} #{result[:deleted].size} sessions (kept #{result[:kept].size}, skipped #{result[:skipped].size})"
|
|
246
|
+
if result[:deleted].any?
|
|
247
|
+
result[:deleted].each { |id| @stdout.puts " #{id}" }
|
|
248
|
+
end
|
|
249
|
+
if dry_run && result[:deleted].any?
|
|
250
|
+
@stdout.puts "\nRun without --dry-run to delete."
|
|
251
|
+
end
|
|
252
|
+
0
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -7,17 +7,23 @@ module Samagotchi
|
|
|
7
7
|
# lines merge into one user message as before; each steer follows it as its
|
|
8
8
|
# own user message marked kind: "steer" and source: (the ContextNote keys,
|
|
9
9
|
# which every message copy keeps). The model reads it as user text.
|
|
10
|
+
#
|
|
11
|
+
# The user's own lines merged into a running turn are marked kind:
|
|
12
|
+
# "input": they are part of that turn, not a turn of their own, so a
|
|
13
|
+
# reloaded history keeps the turn in one piece.
|
|
10
14
|
module Steer
|
|
11
15
|
KIND = "steer"
|
|
16
|
+
INPUT_KIND = "input"
|
|
12
17
|
|
|
13
18
|
# What one drain brought: the merged user text (nil when none) and how
|
|
14
19
|
# many lines made it, and the steers in order.
|
|
15
20
|
Merge = Struct.new(:content, :count, :steers, keyword_init: true) do
|
|
16
21
|
def empty? = content.nil? && steers.empty?
|
|
17
22
|
|
|
18
|
-
# The messages it appends: the user's first
|
|
23
|
+
# The messages it appends: the user's first (marked merged input),
|
|
24
|
+
# then each steer.
|
|
19
25
|
def messages
|
|
20
|
-
list = content ? [{ role: "user", content: content }] : []
|
|
26
|
+
list = content ? [{ role: "user", kind: INPUT_KIND, content: content }] : []
|
|
21
27
|
list + steers.map { |steer| Steer.message(**steer) }
|
|
22
28
|
end
|
|
23
29
|
|
|
@@ -46,6 +52,17 @@ module Samagotchi
|
|
|
46
52
|
message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# The user's lines merged into a running turn (Merge#messages).
|
|
56
|
+
def input?(message)
|
|
57
|
+
message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == INPUT_KIND
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A prompt that started a turn: not a steer, not input merged into a
|
|
61
|
+
# running turn. What the turns a page shows count.
|
|
62
|
+
def turn_prompt?(message)
|
|
63
|
+
prompt?(message) && !input?(message)
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Call a loop's drain: +at_answer+ goes only to a drain that takes it
|
|
50
67
|
# (the Engine's); a caller's own drain (a queue's #drain) is called bare.
|
|
51
68
|
# A failing drain drains nothing (the loop keeps going).
|
|
@@ -945,15 +945,19 @@ module Samagotchi
|
|
|
945
945
|
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
946
946
|
# on join the ones since the last turn (and the running turn's), as
|
|
947
947
|
# the join header shows the last exchange; on a resync only cards not
|
|
948
|
-
# shown here yet. A turn's own notices are left out:
|
|
949
|
-
# turn's come with its parts, and the join shows no older
|
|
948
|
+
# shown here yet. A turn's own rows (notices, retries) are left out:
|
|
949
|
+
# the running turn's come with its parts, and the join shows no older
|
|
950
|
+
# turn's steps.
|
|
950
951
|
def render_snapshot_cards(entries, joining:)
|
|
951
952
|
entries.each do |entry|
|
|
952
|
-
|
|
953
|
+
case entry[:type].to_s
|
|
954
|
+
when "hook_notice"
|
|
953
955
|
next if entry[:in_turn]
|
|
954
956
|
|
|
955
957
|
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
956
|
-
|
|
958
|
+
when "card"
|
|
959
|
+
next unless joining ? entry[:turns_since].to_i.zero? : !@renderer.card_shown?(entry[:id])
|
|
960
|
+
|
|
957
961
|
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
958
962
|
end
|
|
959
963
|
end
|
|
@@ -986,13 +990,22 @@ module Samagotchi
|
|
|
986
990
|
when "input" then @screen.commit("input> #{part[:text]}")
|
|
987
991
|
when "steer" then @screen.commit(format_steer_line(source: part[:source], text: part[:text]))
|
|
988
992
|
when "reminder" then @screen.commit(reminder_line(part[:reminders]))
|
|
989
|
-
when "
|
|
993
|
+
when "notice" then replay_notice(part[:event])
|
|
990
994
|
end
|
|
991
995
|
end
|
|
992
996
|
@view.resume(tail: tail, lane: lane, tool: running_tool, parts: turn[:parts])
|
|
993
997
|
ask(turn[:pending_question]) if turn[:pending_question]
|
|
994
998
|
end
|
|
995
999
|
|
|
1000
|
+
# One of the running turn's rows (Bridge::TurnNotice), drawn as it was
|
|
1001
|
+
# live: a hook's notice and a retry's line. Questions are left out:
|
|
1002
|
+
# the pending one is asked after the replay, answered ones showed no
|
|
1003
|
+
# line.
|
|
1004
|
+
def replay_notice(event)
|
|
1005
|
+
event = EventRenderer.symbolize(event || {})
|
|
1006
|
+
@renderer.call(event) if %i[hook_notice empty_answer_retry].include?(event[:type])
|
|
1007
|
+
end
|
|
1008
|
+
|
|
996
1009
|
def show_enqueued(event)
|
|
997
1010
|
return if own?(event[:client_id])
|
|
998
1011
|
|
|
@@ -37,9 +37,11 @@ module Samagotchi
|
|
|
37
37
|
paint("#{source.to_s.empty? ? "plugin" : source}> nudged: #{first}", 90)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer
|
|
40
|
+
# "↻ empty answer, asking again (1/1)": the loop retries an empty answer;
|
|
41
|
+
# "↻ cut by loop-guard, asking again (1/1)" after a plugin cut it.
|
|
41
42
|
def format_empty_retry_line(event)
|
|
42
|
-
|
|
43
|
+
what = event[:stopped_by] ? "cut by #{event[:stopped_by]}" : "empty answer"
|
|
44
|
+
paint("↻ #{what}, asking again (#{event[:attempt]}/#{event[:of]})", 90)
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# " → image 1280×800" after a tool line whose tool read an image.
|
|
@@ -348,7 +348,7 @@ module Samagotchi
|
|
|
348
348
|
},
|
|
349
349
|
timeout: {
|
|
350
350
|
type: "integer",
|
|
351
|
-
description: "Maximum seconds to wait (default: 600); a timed-out child keeps running"
|
|
351
|
+
description: "Maximum seconds to wait (default: 600; 0 checks once without waiting); a timed-out child keeps running"
|
|
352
352
|
}
|
|
353
353
|
},
|
|
354
354
|
required: ["task"]
|
|
@@ -366,7 +366,7 @@ module Samagotchi
|
|
|
366
366
|
},
|
|
367
367
|
timeout: {
|
|
368
368
|
type: "integer",
|
|
369
|
-
description: "Maximum seconds to wait (default: 600)"
|
|
369
|
+
description: "Maximum seconds to wait (default: 600; 0 checks once without waiting)"
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
}
|
|
@@ -159,9 +159,12 @@ module Samagotchi
|
|
|
159
159
|
!%w[false 0 no off].include?(value.to_s.strip.downcase)
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
+
# Seconds to wait: 0 (or less) looks once and returns at once; a
|
|
163
|
+
# missing or unreadable value is the default.
|
|
164
|
+
# @return [Integer]
|
|
162
165
|
def self.parse_timeout(value)
|
|
163
166
|
parsed = Integer(value.to_s.strip, exception: false)
|
|
164
|
-
parsed
|
|
167
|
+
parsed ? [parsed, 0].max : DelegateWait::TIMEOUT_DEFAULT
|
|
165
168
|
end
|
|
166
169
|
end
|
|
167
170
|
end
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Samagotchi
|
|
|
17
17
|
RESTORED = "The message went back to the user, who may send it again."
|
|
18
18
|
TASKS_LISTED = 5
|
|
19
19
|
TASK_COMMAND_CHARS = 60
|
|
20
|
+
RETRY_NUDGE = :retry_nudge
|
|
20
21
|
|
|
21
22
|
module_function
|
|
22
23
|
|
|
@@ -64,7 +65,25 @@ module Samagotchi
|
|
|
64
65
|
# The hidden nudge before a retry of an empty answer (EmptyAnswerRetry):
|
|
65
66
|
# the model never sees its empty generation, so it is told what happened.
|
|
66
67
|
def empty_retry
|
|
67
|
-
message("your last reply had no visible answer. Answer the user's last message now, briefly.")
|
|
68
|
+
retry_nudge(message("your last reply had no visible answer. Answer the user's last message now, briefly."))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The hidden nudge before a retry of a generation a plugin cut
|
|
72
|
+
# (stop_generation): +by+ is the bundle, +reason+ what it said.
|
|
73
|
+
def cut_retry(by, reason)
|
|
74
|
+
why = reason.to_s.strip.empty? ? "" : ": #{one_line(reason)}"
|
|
75
|
+
retry_nudge(message("your last reply was cut off by #{by || "a plugin"}#{why}. Don't start the same reasoning " \
|
|
76
|
+
"again; answer the user's last message now, briefly."))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A retry nudge is marked, so the spent one is found by the mark
|
|
80
|
+
# whatever its words (KernelLoop drops it when the retry fails too).
|
|
81
|
+
def retry_nudge(note)
|
|
82
|
+
note.merge(RETRY_NUDGE => true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def retry_nudge?(entry)
|
|
86
|
+
entry.respond_to?(:[]) && (entry[RETRY_NUDGE] || entry[RETRY_NUDGE.to_s]) == true
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
def note?(entry)
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -71,8 +71,8 @@ module Samagotchi
|
|
|
71
71
|
|
|
72
72
|
# @param bridge_wait_timeout [Float] bounded seconds to wait for a
|
|
73
73
|
# freshly-spawned worker's bridge before answering POST /api/sessions.
|
|
74
|
-
# @param view ["
|
|
75
|
-
#
|
|
74
|
+
# @param view ["stage", "turn"] the page's view of a turn (web.view);
|
|
75
|
+
# ?view=stage|turn overrides it for one page load
|
|
76
76
|
# @param annotate_presets [String, Array] the quick replies next to
|
|
77
77
|
# Annotate (web.annotate_presets, "|"-separated); "" shows none. The
|
|
78
78
|
# page parses them (annotate_presets.js).
|
|
@@ -87,7 +87,7 @@ module Samagotchi
|
|
|
87
87
|
# String, or a Web::Token::Source that follows the file); nil:
|
|
88
88
|
# loopback only, no token
|
|
89
89
|
def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
|
|
90
|
-
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "
|
|
90
|
+
bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
|
|
91
91
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
92
92
|
events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
|
|
93
93
|
registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
|
|
@@ -1282,16 +1282,17 @@ module Samagotchi
|
|
|
1282
1282
|
return nil unless saved
|
|
1283
1283
|
|
|
1284
1284
|
messages = Array(session.messages)
|
|
1285
|
-
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.
|
|
1285
|
+
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
|
|
1286
1286
|
{ text: saved[:text], turns_since: since }
|
|
1287
1287
|
rescue StandardError
|
|
1288
1288
|
nil
|
|
1289
1289
|
end
|
|
1290
1290
|
|
|
1291
|
-
# The step (iteration) that answered the steer
|
|
1292
|
-
# the model messages between it and the
|
|
1291
|
+
# The step (iteration) that answered the steer or merged input at
|
|
1292
|
+
# +index+: one past the model messages between it and the turn's
|
|
1293
|
+
# prompt.
|
|
1293
1294
|
def steer_step(list, index)
|
|
1294
|
-
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.
|
|
1295
|
+
list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
|
|
1295
1296
|
.count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
|
|
1296
1297
|
end
|
|
1297
1298
|
|
|
@@ -1340,6 +1341,9 @@ module Samagotchi
|
|
|
1340
1341
|
next unless %w[user assistant].include?(norm_role)
|
|
1341
1342
|
|
|
1342
1343
|
message = { role: norm_role, content: stripped }
|
|
1344
|
+
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
1345
|
+
# read by step +step+.
|
|
1346
|
+
message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
|
|
1343
1347
|
images = m[:images] || m["images"]
|
|
1344
1348
|
message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
|
|
1345
1349
|
if norm_role == "assistant"
|
|
@@ -1454,7 +1458,7 @@ module Samagotchi
|
|
|
1454
1458
|
|
|
1455
1459
|
VIEWS = Config::BY_KEY["web.view"].enum_values
|
|
1456
1460
|
|
|
1457
|
-
# The view for this page load: ?view=
|
|
1461
|
+
# The view for this page load: ?view=stage|turn wins, anything
|
|
1458
1462
|
# else leaves the config's choice (web.view).
|
|
1459
1463
|
def view_name(param)
|
|
1460
1464
|
VIEWS.include?(param) ? param : @view
|