samagotchi 0.66.0 → 0.68.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 +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -1
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "level_setting_command"
|
|
4
4
|
require_relative "reasoning_history"
|
|
5
5
|
|
|
6
6
|
module Samagotchi
|
|
7
7
|
# /reasoning-history, the session's own reasoning_history level
|
|
8
|
-
# (Session#reasoning_history):
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# the
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
# --yes (or -y) applies it.
|
|
17
|
-
class ReasoningHistoryCommand
|
|
8
|
+
# (Session#reasoning_history): `/reasoning-history drop|turn|all` sets it
|
|
9
|
+
# (an explicit turn too), `/reasoning-history default` unsets it (the
|
|
10
|
+
# session follows SAMAGOTCHI_REASONING_HISTORY, the model, its host, then
|
|
11
|
+
# turn). Resolved on any host: a native one ignores the level (its loop
|
|
12
|
+
# keeps the running turn's reasoning), but the session keeps it for a
|
|
13
|
+
# chat model. A change that rewrites the earlier replies asks first. See
|
|
14
|
+
# LevelSettingCommand.
|
|
15
|
+
class ReasoningHistoryCommand < LevelSettingCommand
|
|
18
16
|
NAME = "/reasoning-history"
|
|
19
17
|
USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
|
|
20
18
|
NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
|
|
@@ -24,84 +22,25 @@ module Samagotchi
|
|
|
24
22
|
"from the first earlier thought."
|
|
25
23
|
}.freeze
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
level = ReasoningHistory.session_level(word)
|
|
48
|
-
raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
|
|
49
|
-
|
|
50
|
-
session = @engine.session or raise ArgumentError, "no session yet"
|
|
51
|
-
before = explained
|
|
52
|
-
ask = confirm && !yes ? ask(before, level, word) : nil
|
|
53
|
-
return ask if ask
|
|
54
|
-
|
|
55
|
-
@engine.reasoning_history_override = level
|
|
56
|
-
@save.call(session)
|
|
57
|
-
after = explained
|
|
58
|
-
[[listing(after), *notes(before, after)].join("\n"), true, nil]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# The level, where it came from, and what the session sets itself.
|
|
62
|
-
def listing(explained)
|
|
63
|
-
return "reasoning history: can't be resolved for this model" unless explained
|
|
64
|
-
|
|
65
|
-
lines = ["reasoning history: #{explained.label}"]
|
|
66
|
-
unless explained.own
|
|
67
|
-
lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
|
|
68
|
-
end
|
|
69
|
-
lines << NATIVE_NOTE unless chat?
|
|
70
|
-
lines.join("\n")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
# Resolved on any host: a native one ignores it, but /reasoning-history
|
|
76
|
-
# still shows what a chat model would get.
|
|
77
|
-
def explained(own: @engine.reasoning_history_override)
|
|
78
|
-
@engine.reasoning_history_explained(native: true, own: own)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# The reply that asks before a costly change, nil when it needn't.
|
|
82
|
-
def ask(before, own, word)
|
|
83
|
-
would = explained(own: own)
|
|
84
|
-
return nil unless before && would
|
|
85
|
-
|
|
86
|
-
cost = @engine.reasoning_history_cache_cost(before.level, would.level)
|
|
87
|
-
return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
|
|
88
|
-
|
|
89
|
-
step = CacheConfirm.confirm(NAME, word)
|
|
90
|
-
[[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def chat?
|
|
94
|
-
@engine.chat_model?
|
|
95
|
-
rescue StandardError
|
|
96
|
-
false
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def notes(before, after)
|
|
100
|
-
return [] unless before && after
|
|
101
|
-
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
102
|
-
|
|
103
|
-
cost = @engine.reasoning_history_cache_cost(before.level, after.level)
|
|
104
|
-
["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
|
|
105
|
-
end
|
|
25
|
+
SPEC = Spec.new(
|
|
26
|
+
name: NAME, label: "reasoning history", usage: USAGE, cost_notes: COST_NOTES,
|
|
27
|
+
parse: lambda { |word|
|
|
28
|
+
level = ReasoningHistory.session_level(word)
|
|
29
|
+
[!level.nil? || word == ReasoningHistory::UNSET, level]
|
|
30
|
+
},
|
|
31
|
+
own: lambda(&:reasoning_history_override),
|
|
32
|
+
explained: ->(engine, own) { engine.reasoning_history_explained(native: true, own: own) },
|
|
33
|
+
set: ->(engine, own) { engine.reasoning_history_override = own },
|
|
34
|
+
cache_cost: ->(engine, from, to) { engine.reasoning_history_cache_cost(from, to) },
|
|
35
|
+
follows: "#{ReasoningHistory::ENV_NAME}, the model, its host, then turn",
|
|
36
|
+
extra_line: lambda { |engine|
|
|
37
|
+
chat = begin
|
|
38
|
+
engine.chat_model?
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
chat ? nil : NATIVE_NOTE
|
|
43
|
+
}
|
|
44
|
+
)
|
|
106
45
|
end
|
|
107
46
|
end
|
data/lib/samagotchi/row_refs.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Samagotchi
|
|
|
26
26
|
|
|
27
27
|
module_function
|
|
28
28
|
|
|
29
|
-
# Runs +fn+ (the tool's refs: lambda, as
|
|
29
|
+
# Runs +fn+ (the tool's refs: lambda, as ToolSpec#per_call wraps it: call →
|
|
30
30
|
# a list of {kind:, target:, label:, actions:}) on +call+. Never
|
|
31
31
|
# raises: a lambda that raises or returns something other than a list
|
|
32
32
|
# gives none, and a bad ref is dropped; both are logged (warn, not
|
|
@@ -13,6 +13,7 @@ require_relative "thinking"
|
|
|
13
13
|
require_relative "reasoning_history"
|
|
14
14
|
require_relative "served_model"
|
|
15
15
|
require_relative "host_registry"
|
|
16
|
+
require_relative "host_keys"
|
|
16
17
|
require_relative "llm/adapters"
|
|
17
18
|
require_relative "tools/memory"
|
|
18
19
|
require_relative "hooks/loader"
|
|
@@ -391,13 +392,14 @@ module Samagotchi
|
|
|
391
392
|
"#{entry.name} #{location}#{suffix}"
|
|
392
393
|
end
|
|
393
394
|
|
|
394
|
-
# The variable the host's API key comes from and whether it is set
|
|
395
|
-
# key itself is never shown.
|
|
395
|
+
# The variable the host's API key comes from and whether it is set, or
|
|
396
|
+
# comes from the Keychain (HostKeys); the key itself is never shown.
|
|
396
397
|
def api_key_for(model, env)
|
|
397
398
|
name = HostRegistry.new(env: env).resolve(model).entry&.api_key_env
|
|
398
399
|
return "-" unless name
|
|
399
400
|
|
|
400
|
-
|
|
401
|
+
state = { env: "set", keychain: "keychain" }.fetch(HostKeys.source(name, env: env), "unset")
|
|
402
|
+
"#{name} (#{state})"
|
|
401
403
|
end
|
|
402
404
|
|
|
403
405
|
# The Chi Helper app's version against this chi's (read from its
|
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
USAGE = <<~TEXT
|
|
41
41
|
Usage: chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...
|
|
42
42
|
chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [--thinking LEVEL] [--toolkit NAME] [-m TEXT] [--image PATH]...
|
|
43
|
-
chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
|
|
43
|
+
chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT] [--image PATH]...
|
|
44
44
|
chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
|
|
45
45
|
chi send --queue --wait [--timeout S] [--format json] [-m TEXT] ID
|
|
46
46
|
chi send --wait [--timeout S] [--format json] ID
|
|
@@ -63,9 +63,11 @@ module Samagotchi
|
|
|
63
63
|
the current one
|
|
64
64
|
--continues ID
|
|
65
65
|
(--new) start the next link of that session's chain:
|
|
66
|
-
in its folder, on its model
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
in its folder, on its model (or --model's; one whose
|
|
67
|
+
host is gone falls back to the default, with a
|
|
68
|
+
warning), LLM context, thinking level and toolkit,
|
|
69
|
+
with a note carrying its recap; it is archived.
|
|
70
|
+
last:ID names the chain's latest link.
|
|
69
71
|
Without -m the new session waits idle. Refused for a
|
|
70
72
|
session continued already (naming the next link) or
|
|
71
73
|
with delegates still open
|
|
@@ -289,11 +291,11 @@ module Samagotchi
|
|
|
289
291
|
return usage_error("--new takes no session ids: it starts one session") unless options[:ids].empty?
|
|
290
292
|
|
|
291
293
|
if options[:continues]
|
|
292
|
-
taken = [("--dir" if options[:dir])
|
|
294
|
+
taken = [("--dir" if options[:dir])] +
|
|
293
295
|
LLM_CONTEXT_FLAGS.filter_map { |key, flag| flag if options[key] } + [("--thinking" if options[:thinking])] +
|
|
294
296
|
[("--toolkit" if options[:toolkit])] + TOOLKIT_FLAGS.filter_map { |key, flag| flag unless Array(options[key]).empty? }
|
|
295
297
|
unless taken.compact.empty?
|
|
296
|
-
return usage_error("--continues takes the previous session's folder,
|
|
298
|
+
return usage_error("--continues takes the previous session's folder, LLM context, thinking level and " \
|
|
297
299
|
"toolkit; " \
|
|
298
300
|
"leave out #{taken.compact.join(", ")}")
|
|
299
301
|
end
|
|
@@ -358,7 +360,8 @@ module Samagotchi
|
|
|
358
360
|
begin
|
|
359
361
|
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
360
362
|
session = if options[:continues]
|
|
361
|
-
SessionManager.continue_session(options[:continues], **start,
|
|
363
|
+
SessionManager.continue_session(options[:continues], **start, model: options[:model],
|
|
364
|
+
state_dir: @state_dir)
|
|
362
365
|
else
|
|
363
366
|
SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
364
367
|
model_name: options[:model],
|
|
@@ -388,15 +391,22 @@ module Samagotchi
|
|
|
388
391
|
timeout: options[:timeout])
|
|
389
392
|
end
|
|
390
393
|
|
|
391
|
-
#
|
|
392
|
-
#
|
|
394
|
+
# The model the new session will run, for the images check: --model
|
|
395
|
+
# (nil: the default), or with --continues the one the new link will run
|
|
396
|
+
# (SessionContinue.model_for: --model's, else the previous link's, else
|
|
397
|
+
# the default it falls back to).
|
|
393
398
|
# @return [Array, false] [model_name, model_typed], or false after the
|
|
394
|
-
# error line (no such session)
|
|
399
|
+
# error line (no such session, a model it can't start on)
|
|
395
400
|
def continued_model(options)
|
|
396
401
|
return [options[:model], nil] unless options[:continues]
|
|
397
402
|
|
|
398
403
|
previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
|
|
399
|
-
|
|
404
|
+
choice = SessionContinue.model_for(previous, options[:model])
|
|
405
|
+
inherited = options[:model].to_s.strip.empty? && choice.warning.nil?
|
|
406
|
+
inherited ? [previous.model_name, previous.model_typed] : [choice.model, nil]
|
|
407
|
+
rescue ModelProfile::MissingModel => e
|
|
408
|
+
error_line("chi send: could not start a session: #{e.message}")
|
|
409
|
+
false
|
|
400
410
|
rescue ArgumentError => e
|
|
401
411
|
error_line("chi send: #{e.message}")
|
|
402
412
|
false
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -259,6 +259,8 @@ module Samagotchi
|
|
|
259
259
|
self.interrupted_turn = interrupted_turn
|
|
260
260
|
@turn_checkpoint = nil
|
|
261
261
|
@archived = false
|
|
262
|
+
# #save's lock: one snapshot-and-write at a time (see #save).
|
|
263
|
+
@save_lock = Mutex.new
|
|
262
264
|
end
|
|
263
265
|
|
|
264
266
|
# A `delegate` child: its turns report to its parent (ChildReports). A
|
|
@@ -513,18 +515,30 @@ module Samagotchi
|
|
|
513
515
|
end
|
|
514
516
|
|
|
515
517
|
# Persist the session atomically. Updates +updated_at+ in place.
|
|
518
|
+
#
|
|
519
|
+
# One save at a time per Session: a worker saves its session from the
|
|
520
|
+
# loop/turn thread (TurnCheckpoint, Engine's turn end, WorkerInbound),
|
|
521
|
+
# from Bridge threads (QuestionDesk, commands run mid-turn) and from a
|
|
522
|
+
# signal's thread. AtomicFile keeps each write whole, but two
|
|
523
|
+
# unserialized saves could rename an older snapshot over a newer one (a
|
|
524
|
+
# lost update); under the lock the last rename is the latest snapshot.
|
|
525
|
+
# Never call it from a trap handler (Mutex#lock raises ThreadError
|
|
526
|
+
# there; WorkerSignals saves on a thread of its own) or from inside a
|
|
527
|
+
# save (the lock isn't re-entrant).
|
|
516
528
|
def save(state_dir: self.class.default_state_dir)
|
|
517
|
-
@
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
529
|
+
@save_lock.synchronize do
|
|
530
|
+
@updated_at = Time.now.iso8601(3)
|
|
531
|
+
# Persist with current metadata version so new flag is written
|
|
532
|
+
@metadata_version = METADATA_VERSION
|
|
533
|
+
FileUtils.mkdir_p(state_dir)
|
|
521
534
|
|
|
522
|
-
|
|
535
|
+
path = self.class.session_file(@id, state_dir: state_dir)
|
|
523
536
|
|
|
524
|
-
|
|
525
|
-
|
|
537
|
+
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
538
|
+
compute_first_preview!
|
|
526
539
|
|
|
527
|
-
|
|
540
|
+
AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
|
|
541
|
+
end
|
|
528
542
|
self
|
|
529
543
|
end
|
|
530
544
|
|
|
@@ -56,11 +56,21 @@ module Samagotchi
|
|
|
56
56
|
# moved the chain's end while it waited for the lock.
|
|
57
57
|
TRIES = 3
|
|
58
58
|
|
|
59
|
+
# The model a new link runs on (#model_for): the name to spawn it with,
|
|
60
|
+
# and why it isn't the previous link's (nil when it is, or was given).
|
|
61
|
+
ModelChoice = Data.define(:model, :warning)
|
|
62
|
+
# What #check! decided before anything changes: the delegates to move
|
|
63
|
+
# (ChildMove::Plan) and the new link's model (ModelChoice).
|
|
64
|
+
Checked = Data.define(:plan, :choice)
|
|
65
|
+
|
|
59
66
|
# Start the next link of a chain: a new session that continues
|
|
60
67
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
61
68
|
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
62
69
|
# starting with a context note from chi: the link and the previous
|
|
63
|
-
# link's recap (SessionChain.note_text).
|
|
70
|
+
# link's recap (SessionChain.note_text). +model+, when given, is the
|
|
71
|
+
# new link's model instead; an inherited one whose host can't run it any
|
|
72
|
+
# more (gone, disabled, an alias naming another host) falls back to the
|
|
73
|
+
# default with a warning (#model_for). The previous link is archived
|
|
64
74
|
# first, with its finished delegates (archive_session's rules: a turn
|
|
65
75
|
# running, a prompt queued or the step-limit question refuse; an idle
|
|
66
76
|
# worker is stopped), so a refusal starts nothing. Its open delegates
|
|
@@ -81,7 +91,7 @@ module Samagotchi
|
|
|
81
91
|
# @raise [Refused] continued already, delegates open that can't
|
|
82
92
|
# move, or its folder is gone
|
|
83
93
|
# @raise [ArchiveRefused, OwnedByTUI] as archive_session
|
|
84
|
-
def self.run(id_or_ref, prompt: nil, title: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
94
|
+
def self.run(id_or_ref, prompt: nil, title: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5)
|
|
85
95
|
sd = state_dir || Session.default_state_dir
|
|
86
96
|
TRIES.times do |try|
|
|
87
97
|
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
@@ -89,7 +99,7 @@ module Samagotchi
|
|
|
89
99
|
follow = id_or_ref.to_s.strip.start_with?(SessionChain::LAST_PREFIX) && try < TRIES - 1
|
|
90
100
|
next :moved if follow && SessionChain.next_of(id, state_dir: sd)
|
|
91
101
|
|
|
92
|
-
run_locked(id, prompt: prompt, title: title, state_dir: sd, recap_wait: recap_wait,
|
|
102
|
+
run_locked(id, prompt: prompt, title: title, model: model, state_dir: sd, recap_wait: recap_wait,
|
|
93
103
|
archive_wait: archive_wait)
|
|
94
104
|
end
|
|
95
105
|
return result unless result == :moved
|
|
@@ -105,9 +115,10 @@ module Samagotchi
|
|
|
105
115
|
end
|
|
106
116
|
end
|
|
107
117
|
|
|
108
|
-
private_class_method def self.run_locked(id, prompt:, title:, state_dir:, recap_wait:, archive_wait:)
|
|
118
|
+
private_class_method def self.run_locked(id, prompt:, title:, model:, state_dir:, recap_wait:, archive_wait:)
|
|
109
119
|
previous = Session.load(id, state_dir: state_dir)
|
|
110
|
-
|
|
120
|
+
checked = check!(previous, state_dir, model: model)
|
|
121
|
+
plan = checked.plan
|
|
111
122
|
# archive_session's refusals that can be known now, before the wait.
|
|
112
123
|
SessionManager.archivable!(id, state_dir, except: plan.move)
|
|
113
124
|
recap_before_archive(id, state_dir, wait: recap_wait)
|
|
@@ -120,7 +131,8 @@ module Samagotchi
|
|
|
120
131
|
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
121
132
|
archived = archive[:archived] - already
|
|
122
133
|
begin
|
|
123
|
-
spawn_continuation(previous, prompt: prompt, title: title,
|
|
134
|
+
spawn_continuation(previous, prompt: prompt, title: title, choice: checked.choice, state_dir: state_dir,
|
|
135
|
+
move: plan.move)
|
|
124
136
|
rescue StandardError
|
|
125
137
|
# Nothing started: the delegates go back, what this archived goes
|
|
126
138
|
# back to the lists, and a new link saved before its worker failed
|
|
@@ -181,7 +193,7 @@ module Samagotchi
|
|
|
181
193
|
|
|
182
194
|
# The new link, holding off any worker but its own (ChildMove.starting?)
|
|
183
195
|
# while +move+ goes to it.
|
|
184
|
-
private_class_method def self.spawn_continuation(previous, prompt:, title:, state_dir:, move:)
|
|
196
|
+
private_class_method def self.spawn_continuation(previous, prompt:, title:, choice:, state_dir:, move:)
|
|
185
197
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
186
198
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
187
199
|
title = previous.first_preview if title.to_s.strip.empty? && start.nil?
|
|
@@ -191,33 +203,64 @@ module Samagotchi
|
|
|
191
203
|
end
|
|
192
204
|
link = SessionManager.spawn_session(
|
|
193
205
|
prompt: start, title: title, working_directory: previous.working_directory,
|
|
194
|
-
model_name:
|
|
206
|
+
model_name: choice.model, setup: SessionSetup.of(previous),
|
|
195
207
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
196
208
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
197
209
|
)
|
|
198
210
|
link.moved_children = move
|
|
211
|
+
# The fallback's warning first: spawn_session set its own.
|
|
212
|
+
warnings = [choice.warning, link.model_warning].compact
|
|
213
|
+
link.model_warning = warnings.join("; ") unless warnings.empty?
|
|
199
214
|
link
|
|
200
215
|
end
|
|
201
216
|
|
|
217
|
+
# The model a new link of +previous+'s chain runs on: +given+ when
|
|
218
|
+
# there is one (its host checked: ModelProfile::UnknownHost as for a new
|
|
219
|
+
# session), else the previous link's, as typed (so an alias holds). An
|
|
220
|
+
# inherited one whose host can't run it any more (gone from config.yml,
|
|
221
|
+
# disabled, an alias naming another host: UnknownHost) falls back to the
|
|
222
|
+
# default, with a warning naming why. `chi send --new --continues`
|
|
223
|
+
# judges images by it before #run decides the same.
|
|
224
|
+
# @param previous [Session]
|
|
225
|
+
# @param given [String, nil] blank: inherit
|
|
226
|
+
# @return [ModelChoice]
|
|
227
|
+
# @raise [ModelProfile::MissingModel] a +given+ model it can't run, or
|
|
228
|
+
# a fallback with no default configured (or the default's host can't run it)
|
|
229
|
+
def self.model_for(previous, given)
|
|
230
|
+
unless given.to_s.strip.empty?
|
|
231
|
+
return ModelChoice.new(model: ModelProfile.check_host!(ModelProfile.required_model_name(given.strip)),
|
|
232
|
+
warning: nil)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
inherited = ModelProfile.required_model_name(previous.model_typed || previous.model_name)
|
|
236
|
+
begin
|
|
237
|
+
ModelChoice.new(model: ModelProfile.check_host!(inherited), warning: nil)
|
|
238
|
+
rescue ModelProfile::UnknownHost => e
|
|
239
|
+
default = ModelProfile.check_host!(ModelProfile.required_model_name(nil))
|
|
240
|
+
ModelChoice.new(model: default, warning: "#{inherited} can't run (#{e.message}); started on #{default}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
202
244
|
# The refusals that come before anything changes: continued already (no
|
|
203
245
|
# forks in a chain), an open delegate that can't move to the new link
|
|
204
246
|
# (ChildMove.plan), the folder gone (the worker would run elsewhere), a
|
|
205
|
-
# model whose host config.yml
|
|
206
|
-
#
|
|
247
|
+
# given model whose host config.yml doesn't have, or an inherited one
|
|
248
|
+
# that can't run with no default to fall back to (#model_for).
|
|
249
|
+
# @return [Checked]
|
|
207
250
|
# @raise [Refused, ModelProfile::MissingModel]
|
|
208
|
-
private_class_method def self.check!(previous, state_dir)
|
|
251
|
+
private_class_method def self.check!(previous, state_dir, model: nil)
|
|
209
252
|
if (following = SessionChain.next_of(previous.id, state_dir: state_dir))
|
|
210
253
|
raise Refused.new(previous.id, :continued, ids: [following])
|
|
211
254
|
end
|
|
212
255
|
|
|
213
256
|
plan = move_plan!(previous.id, state_dir)
|
|
214
|
-
#
|
|
215
|
-
# the archive stops the previous link's worker.
|
|
216
|
-
|
|
257
|
+
# The new link's model, its host checked as SessionManager.spawn_session
|
|
258
|
+
# checks it: before the archive stops the previous link's worker.
|
|
259
|
+
choice = model_for(previous, model)
|
|
217
260
|
dir = previous.working_directory.to_s
|
|
218
261
|
raise Refused.new(previous.id, :folder_gone, detail: dir) unless File.directory?(dir)
|
|
219
262
|
|
|
220
|
-
plan
|
|
263
|
+
Checked.new(plan: plan, choice: choice)
|
|
221
264
|
end
|
|
222
265
|
|
|
223
266
|
# A child still open below a session (#open_children); why: running,
|
|
@@ -236,15 +236,6 @@ module Samagotchi
|
|
|
236
236
|
UNREADABLE_INPUT
|
|
237
237
|
end
|
|
238
238
|
|
|
239
|
-
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
240
|
-
# leaves it for its own turn).
|
|
241
|
-
def self.input_has_images?(input_file)
|
|
242
|
-
data = JSON.parse(File.read(input_file))
|
|
243
|
-
data.is_a?(Hash) && Array(data["images"]).any?
|
|
244
|
-
rescue JSON::ParserError, SystemCallError
|
|
245
|
-
false
|
|
246
|
-
end
|
|
247
|
-
|
|
248
239
|
# Whether a mid-turn drain leaves this unclaimed input file for a turn
|
|
249
240
|
# of its own instead of merging it at a step boundary: a queued message
|
|
250
241
|
# (`delivery: queue`) or an image message (steering merges text only).
|
|
@@ -258,10 +249,6 @@ module Samagotchi
|
|
|
258
249
|
false
|
|
259
250
|
end
|
|
260
251
|
|
|
261
|
-
# Whether an input file carrying +enqueued_id+ still waits unclaimed in
|
|
262
|
-
# input/ (no worker has taken it for a turn or a step yet).
|
|
263
|
-
def self.input_waiting?(session_dir, enqueued_id) = !waiting_input(session_dir, enqueued_id).nil?
|
|
264
|
-
|
|
265
252
|
# How the unclaimed input file carrying +enqueued_id+ waits: :turn_end
|
|
266
253
|
# (a queue or an image message: it runs as a turn of its own, never
|
|
267
254
|
# merged, #waits_for_turn_end?), :merges (a running turn's drain may
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effective_model"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# One per-session setting (the session's own value, before its model's):
|
|
7
|
+
# what the UIs show as a chip and the commands set. +key+ is the Session
|
|
8
|
+
# attribute and the chip's key everywhere (the session state, /stats, a
|
|
9
|
+
# :command_ran, the web's session JSON); +changed_by+ the commands'
|
|
10
|
+
# `changed` entries that move the chip; +snapshot_fallback+ how the web
|
|
11
|
+
# treats a worker snapshot's value: :or (a nil value falls back to the
|
|
12
|
+
# session file's) or :key (a key present stands, nil included: a worker
|
|
13
|
+
# says nil on a native host, which ignores reasoning_history; an older
|
|
14
|
+
# worker has no key). +chip+ the chip from an EffectiveModel and the
|
|
15
|
+
# session's own value; nil for the toolkit, which resolves against the
|
|
16
|
+
# worker's kits (Engine#toolkit_summary), not the model. +live+ the
|
|
17
|
+
# chip as an Engine's public readers give it (a :command_ran's).
|
|
18
|
+
SessionSetting = Data.define(:key, :changed_by, :snapshot_fallback, :chip, :live) do
|
|
19
|
+
# The session's own value, nil without a session or one.
|
|
20
|
+
def own(session) = session&.public_send(key)
|
|
21
|
+
|
|
22
|
+
# Sets the session's own value (nil unsets it); the caller saves.
|
|
23
|
+
def set(session, value) = session.public_send(:"#{key}=", value)
|
|
24
|
+
|
|
25
|
+
# Whether a command that changed +changed+ moves this chip.
|
|
26
|
+
def changed_by?(changed) = changed.intersect?(changed_by)
|
|
27
|
+
|
|
28
|
+
# Whether the chip comes from the model (not the toolkit).
|
|
29
|
+
def model? = !chip.nil?
|
|
30
|
+
|
|
31
|
+
# The chip for +session+ on +model+ (EffectiveModel, nil when it
|
|
32
|
+
# doesn't resolve: nil then).
|
|
33
|
+
def chip_for(model, session) = model && chip.call(model, own(session))
|
|
34
|
+
|
|
35
|
+
# The chip as +engine+ (a worker's) gives it now.
|
|
36
|
+
def summary(engine) = live.call(engine)
|
|
37
|
+
|
|
38
|
+
# The chip for a saved session no worker runs (Web::App, the session
|
|
39
|
+
# lists): resolved from its file, config and the registry (no network);
|
|
40
|
+
# nil when it can't be.
|
|
41
|
+
def saved_summary(session, registry:)
|
|
42
|
+
return saved_toolkit(session.toolkit) unless model?
|
|
43
|
+
|
|
44
|
+
chip_for(EffectiveModel.of_session(session, registry: registry), session)
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The web's chip: a worker's snapshot (string keys) per
|
|
50
|
+
# +snapshot_fallback+, else the block's (the saved one).
|
|
51
|
+
def snapshot_value(snapshot)
|
|
52
|
+
name = key.to_s
|
|
53
|
+
return snapshot[name] if snapshot_fallback == :key && snapshot&.key?(name)
|
|
54
|
+
return snapshot[name] if snapshot_fallback == :or && snapshot && snapshot[name]
|
|
55
|
+
|
|
56
|
+
yield
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# A toolkit as its file names it ({name:, label:}), nil on full:
|
|
62
|
+
# without a worker nothing resolves it (a bundle's kits load there).
|
|
63
|
+
def saved_toolkit(toolkit)
|
|
64
|
+
toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class SessionSetting
|
|
69
|
+
LLM_CONTEXT = new(key: :llm_context, changed_by: %i[llm_context model], snapshot_fallback: :or,
|
|
70
|
+
chip: ->(model, own) { model.llm_context_explained(own&.resolve_args || {})&.summary(own) },
|
|
71
|
+
live: lambda(&:llm_context_summary))
|
|
72
|
+
THINKING = new(key: :thinking, changed_by: %i[thinking model], snapshot_fallback: :or,
|
|
73
|
+
chip: ->(model, own) { model.thinking_explained(own: own)&.summary },
|
|
74
|
+
live: ->(engine) { engine.thinking_explained&.summary })
|
|
75
|
+
# nil on a native host (its loop ignores the level).
|
|
76
|
+
REASONING_HISTORY = new(key: :reasoning_history, changed_by: %i[reasoning_history model], snapshot_fallback: :key,
|
|
77
|
+
chip: ->(model, own) { model.reasoning_history_explained(own: own)&.summary },
|
|
78
|
+
live: ->(engine) { engine.reasoning_history_explained&.summary })
|
|
79
|
+
TOOLKIT = new(key: :toolkit, changed_by: %i[toolkit], snapshot_fallback: :key, chip: nil, live: lambda(&:toolkit_summary))
|
|
80
|
+
|
|
81
|
+
# In the order the session state, a :command_ran and the web name them.
|
|
82
|
+
ALL = [LLM_CONTEXT, THINKING, REASONING_HISTORY, TOOLKIT].freeze
|
|
83
|
+
# /stats's (no toolkit: its JSON never had one).
|
|
84
|
+
STATS = [LLM_CONTEXT, THINKING, REASONING_HISTORY].freeze
|
|
85
|
+
end
|
|
86
|
+
end
|