samagotchi 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -1
- data/README.md +20 -7
- data/bin/chi +10 -4
- data/docs/architecture.md +8 -6
- data/docs/cli.md +59 -11
- data/docs/configuration.md +29 -8
- data/docs/desktop.md +34 -8
- data/docs/guardrails.md +73 -7
- data/docs/hooks.md +13 -9
- data/docs/memory.md +5 -3
- data/docs/plugins.md +4 -4
- data/docs/sessions.md +9 -6
- data/docs/sub-agent.md +128 -0
- data/lib/samagotchi/answer_command.rb +220 -0
- data/lib/samagotchi/bridge.rb +15 -5
- data/lib/samagotchi/bridge_client.rb +8 -3
- data/lib/samagotchi/bundle_command.rb +18 -10
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/exit.rb +23 -0
- data/lib/samagotchi/cli/parent_wait.rb +82 -0
- data/lib/samagotchi/config.rb +50 -18
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
- data/lib/samagotchi/desktop/macos/Models.swift +238 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
- data/lib/samagotchi/desktop_command.rb +5 -3
- data/lib/samagotchi/engine.rb +480 -491
- data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
- data/lib/samagotchi/host_registry.rb +26 -3
- data/lib/samagotchi/idle_reminders.rb +13 -36
- data/lib/samagotchi/kernel_loop.rb +82 -120
- data/lib/samagotchi/llm/chat_loop.rb +17 -38
- data/lib/samagotchi/llm/model_result.rb +14 -20
- data/lib/samagotchi/llm/native_backend.rb +3 -21
- data/lib/samagotchi/llm/openai_chat.rb +10 -2
- data/lib/samagotchi/llm/turn_settings.rb +19 -0
- data/lib/samagotchi/llm/usage.rb +2 -57
- data/lib/samagotchi/memory_bundle/installer.rb +13 -1
- data/lib/samagotchi/memory_bundle/status.rb +9 -1
- data/lib/samagotchi/memory_paths.rb +8 -0
- data/lib/samagotchi/model_catalog.rb +101 -0
- data/lib/samagotchi/model_profile.rb +31 -17
- data/lib/samagotchi/models_command.rb +112 -0
- data/lib/samagotchi/parent_report.rb +213 -0
- data/lib/samagotchi/plugin/api.rb +15 -2
- data/lib/samagotchi/prompt_literal_guard.rb +4 -2
- data/lib/samagotchi/question_desk.rb +29 -2
- data/lib/samagotchi/recap_setup.rb +15 -5
- data/lib/samagotchi/reminder_queue.rb +73 -0
- data/lib/samagotchi/reminder_store.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +5 -1
- data/lib/samagotchi/self_report.rb +8 -1
- data/lib/samagotchi/send_command.rb +51 -76
- data/lib/samagotchi/session.rb +15 -0
- data/lib/samagotchi/session_commands.rb +5 -5
- data/lib/samagotchi/session_manager.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +13 -3
- data/lib/samagotchi/system_prompt.rb +10 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
- data/lib/samagotchi/terminal_ui.rb +23 -29
- data/lib/samagotchi/thinking.rb +1 -1
- data/lib/samagotchi/thought_stream_splitter.rb +4 -4
- data/lib/samagotchi/tool_call_parser.rb +5 -6
- data/lib/samagotchi/tool_runner.rb +4 -6
- data/lib/samagotchi/tools/builtin_calls.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +15 -3
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +8 -8
- data/lib/samagotchi/tools/task_create.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +7 -2
- data/lib/samagotchi/turn_flow.rb +4 -4
- data/lib/samagotchi/turn_state.rb +126 -0
- data/lib/samagotchi/used_memories.rb +88 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -4
- data/lib/samagotchi/web/app.rb +17 -35
- data/lib/samagotchi/web/message_parts.rb +2 -1
- data/lib/samagotchi/web/server.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +0 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../paths"
|
|
5
|
+
require_relative "../hooks/loader"
|
|
6
|
+
require_relative "approval"
|
|
7
|
+
require_relative "protected_paths"
|
|
8
|
+
|
|
9
|
+
module Samagotchi
|
|
10
|
+
module Guardrails
|
|
11
|
+
# What a parent agent may allow on an approval (guardrails.parent_approvals,
|
|
12
|
+
# config.yml only): off, a deny only; once, "Allow once" too, never a
|
|
13
|
+
# wider scope. `chi answer` checks it before it posts; the worker checks
|
|
14
|
+
# it again for an answer marked as chi answer's (CLIENT_ID), with its own
|
|
15
|
+
# config. A convention for an honest but eager parent, not a security
|
|
16
|
+
# boundary: any local process can post to the Bridge, with or without
|
|
17
|
+
# the marker.
|
|
18
|
+
#
|
|
19
|
+
# An option allows when Approval.settle would allow it: its index is
|
|
20
|
+
# below the approval's scopes. The scope is read by index, never by
|
|
21
|
+
# label. When the scopes are missing or don't fit the options, it fails
|
|
22
|
+
# closed: only a last option labelled Deny denies.
|
|
23
|
+
#
|
|
24
|
+
# Whatever the setting, an approval for a call on chi's own config dir
|
|
25
|
+
# (config.yml, memories/.bundles' rules), the hooks dir or the approval
|
|
26
|
+
# store is the user's alone (:protected): "Allow once" on a config.yml
|
|
27
|
+
# rewrite would be "allow everything" from then on.
|
|
28
|
+
module ParentApprovals
|
|
29
|
+
KEY = "guardrails.parent_approvals"
|
|
30
|
+
# The client id chi answer posts its answers with.
|
|
31
|
+
CLIENT_ID = "cli:answer"
|
|
32
|
+
# Set by chi's execute and task_create for the commands they run: a
|
|
33
|
+
# chi started there answers as a parent (#parent_process?).
|
|
34
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
35
|
+
# Environment variables that say an agent runs this process: Claude
|
|
36
|
+
# Code's, and chi's own. They survive a PTY wrapper (script, expect).
|
|
37
|
+
AGENT_MARKERS = ["CLAUDECODE", "AI_AGENT", PARENT_SESSION_ENV].freeze
|
|
38
|
+
# Rules that ask about chi's own config and hooks: the core
|
|
39
|
+
# ProtectedPaths asks and the guardrails bundle's shell rule.
|
|
40
|
+
PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
|
|
41
|
+
# A shell command naming chi's config, hooks or guardrails (the
|
|
42
|
+
# guardrails bundle's shell-touches-chi, less .git/hooks).
|
|
43
|
+
CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Whether an answer typed into this process is a parent agent's (so
|
|
48
|
+
# this setting applies to it): stdin isn't a terminal (a pipe, a file,
|
|
49
|
+
# /dev/null), or an agent marker is set. A person at chi --attach or
|
|
50
|
+
# the REPL has a terminal and no marker.
|
|
51
|
+
def parent_process?(env: ENV, stdin: $stdin)
|
|
52
|
+
return true if AGENT_MARKERS.any? { |key| !env.fetch(key, "").to_s.strip.empty? }
|
|
53
|
+
|
|
54
|
+
!(stdin.respond_to?(:tty?) && stdin.tty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# This process's setting: "once" or "off" (anything else is off).
|
|
58
|
+
def setting
|
|
59
|
+
Config.get(KEY).to_s == "once" ? "once" : "off"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Why a parent may not give this answer, or nil.
|
|
63
|
+
# @param pending [Hash] the pending question (symbol or string keys)
|
|
64
|
+
# @param indices [Array<Integer, nil>] the selected options' places
|
|
65
|
+
# @param setting [String, nil] "off" or "once"
|
|
66
|
+
# @return [Symbol, nil] :protected (chi's own config: the user's
|
|
67
|
+
# alone), :off (no allow at all), :once_only, or nil
|
|
68
|
+
def refusal(pending, indices, setting:)
|
|
69
|
+
return nil unless approval?(pending)
|
|
70
|
+
|
|
71
|
+
scopes = scopes(pending)
|
|
72
|
+
allows = Array(indices).reject { |index| deny?(pending, scopes, index) }
|
|
73
|
+
return nil if allows.empty?
|
|
74
|
+
return :protected if protected?(pending)
|
|
75
|
+
return :off unless setting.to_s == "once"
|
|
76
|
+
return nil if scopes && allows.all? { |index| index.is_a?(Integer) && index >= 0 && scopes[index] == "once" }
|
|
77
|
+
|
|
78
|
+
:once_only
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether +pending+ asks about a call on chi's own config, hooks,
|
|
82
|
+
# guardrail rules or approvals: by its rule, its paths or its command
|
|
83
|
+
# (the dirs as this process's environment and config.yml name them).
|
|
84
|
+
def protected?(pending)
|
|
85
|
+
facts = fetch(pending, :approval)
|
|
86
|
+
return false unless facts.is_a?(Hash)
|
|
87
|
+
return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
|
|
88
|
+
return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
|
|
89
|
+
|
|
90
|
+
dirs = chi_dirs
|
|
91
|
+
paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
|
|
92
|
+
return true if paths.any? { |path| dirs.any? { |dir| ProtectedPaths.within?(ProtectedPaths.real(path), dir) } }
|
|
93
|
+
|
|
94
|
+
command = fetch(facts, :command).to_s
|
|
95
|
+
return false if command.empty?
|
|
96
|
+
|
|
97
|
+
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# chi's config dir, the hooks dir and the approval store's dir.
|
|
101
|
+
def chi_dirs
|
|
102
|
+
hooks = begin Hooks::Loader.hooks_dir rescue nil end
|
|
103
|
+
[ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
|
|
104
|
+
.compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The refusal as a line for the parent: deny, and tell the user.
|
|
108
|
+
# Never how to allow it (the web, chi --attach): that is the user's.
|
|
109
|
+
# @param reason [Symbol] what #refusal returned
|
|
110
|
+
# @param typed [Boolean] the answer was typed at a ? prompt (the
|
|
111
|
+
# REPL), not given to chi answer
|
|
112
|
+
def message(reason, typed: false)
|
|
113
|
+
deny = typed ? "deny it (n; WHY), and tell your user" : "deny it (--option Deny --text WHY), and tell your user"
|
|
114
|
+
case reason
|
|
115
|
+
when :protected then "this call changes chi's own config, hooks or guardrails, and only the user can allow it: #{deny}"
|
|
116
|
+
when :off then "allowing a tool call is up to the user: #{deny}"
|
|
117
|
+
else "only Allow once (#{KEY}: once) can be given here: #{deny}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# An approval: its kind says so, or it carries approval facts.
|
|
122
|
+
def approval?(pending)
|
|
123
|
+
fetch(pending, :kind).to_s == Approval::KIND || !fetch(pending, :approval).nil?
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The offered scopes, in option order, or nil when they are missing,
|
|
127
|
+
# empty, not strings, or leave no option after them for Deny.
|
|
128
|
+
def scopes(pending)
|
|
129
|
+
facts = fetch(pending, :approval)
|
|
130
|
+
scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
|
|
131
|
+
return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
|
|
132
|
+
return nil unless scopes.size < options(pending).size
|
|
133
|
+
|
|
134
|
+
scopes
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def deny?(pending, scopes, index)
|
|
138
|
+
return false unless index.is_a?(Integer) && index >= 0
|
|
139
|
+
|
|
140
|
+
options = options(pending)
|
|
141
|
+
return index >= scopes.size && index < options.size if scopes
|
|
142
|
+
|
|
143
|
+
index == options.size - 1 && options[index].to_s == Approval::DENY
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def options(pending) = Array(fetch(pending, :options))
|
|
147
|
+
|
|
148
|
+
def fetch(hash, key)
|
|
149
|
+
return nil unless hash.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -4,6 +4,8 @@ require_relative "registry"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../log"
|
|
6
6
|
require_relative "../memory_bundle/provenance"
|
|
7
|
+
require_relative "../memory_bundle/manifest"
|
|
8
|
+
require_relative "../version"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
# Namespace that wraps hook plugin classes per-bundle. Each bundle's hooks are
|
|
@@ -28,7 +30,8 @@ module Samagotchi
|
|
|
28
30
|
# fails to load, or its file's sha256 differs from the one recorded at
|
|
29
31
|
# install, the failure is reported and the gate denies every tool call.
|
|
30
32
|
# Any hook whose sha256 differs is not loaded (reinstall the bundle
|
|
31
|
-
# after editing a hook by hand)
|
|
33
|
+
# after editing a hook by hand), nor any of a bundle whose requires_chi
|
|
34
|
+
# this chi doesn't meet.
|
|
32
35
|
#
|
|
33
36
|
# The +on_error+ policy is applied per hook at fire time (Hooks.wrap):
|
|
34
37
|
# - "fail_closed" (on :before_tool_call): a raising guardrail denies
|
|
@@ -48,10 +51,14 @@ module Samagotchi
|
|
|
48
51
|
# @param settings [Hash] the bundle's section of config.yml
|
|
49
52
|
# `bundles:` (string keys); a hook class whose initialize takes an
|
|
50
53
|
# argument gets it
|
|
54
|
+
# @param requires_chi [String, nil] the bundle's requirement; when
|
|
55
|
+
# this chi doesn't meet it no hook loads (each is reported, as the
|
|
56
|
+
# plugin loader does)
|
|
51
57
|
# @return [Integer] number of hooks successfully registered
|
|
52
|
-
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {})
|
|
58
|
+
def load(bundle_name:, hooks_dir:, metadata:, registry:, failures: nil, settings: {}, requires_chi: nil)
|
|
53
59
|
return 0 unless metadata.is_a?(Hash)
|
|
54
60
|
|
|
61
|
+
too_old = MemoryBundle::Manifest.requires_chi_failure(requires_chi, Samagotchi::VERSION)
|
|
55
62
|
loaded = 0
|
|
56
63
|
metadata.each do |raw_basename, raw_meta|
|
|
57
64
|
basename = raw_basename.to_s
|
|
@@ -72,7 +79,7 @@ module Samagotchi
|
|
|
72
79
|
failures&.add(what, "the file is missing", required: true) if fail_closed
|
|
73
80
|
next
|
|
74
81
|
end
|
|
75
|
-
if (mismatch = sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
82
|
+
if (mismatch = too_old || sha_mismatch(file, meta.(:sha256), required: fail_closed))
|
|
76
83
|
Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
|
|
77
84
|
failures&.add(what, mismatch, required: fail_closed)
|
|
78
85
|
next
|
|
@@ -21,7 +21,6 @@ module Samagotchi
|
|
|
21
21
|
class HostRegistry
|
|
22
22
|
CACHE_TTL_SECONDS = 60
|
|
23
23
|
REMOTE_CACHE_TTL_SECONDS = 600
|
|
24
|
-
LIST_TIMEOUT_SECONDS = 3
|
|
25
24
|
# Seconds a remote host's stream may take to show something (a queued
|
|
26
25
|
# free model on OpenRouter can send only keep-alives for minutes).
|
|
27
26
|
REMOTE_FIRST_TOKEN_TIMEOUT = 120
|
|
@@ -229,7 +228,14 @@ module Samagotchi
|
|
|
229
228
|
# Returns { host_name => { host:, port:, transport:, models: [LLM::ModelInfo], error: nil|String } }
|
|
230
229
|
# Also populates the model index. Unless forced, a host's list is reused
|
|
231
230
|
# for its TTL (60s; 10 minutes for a remote host).
|
|
232
|
-
|
|
231
|
+
#
|
|
232
|
+
# With +wait+ (seconds), answers by one shared deadline: a host still
|
|
233
|
+
# listing then gets the error "no answer in S s" and its thread is left
|
|
234
|
+
# running (only for a short-lived process: `chi models`). Such a partial
|
|
235
|
+
# listing is not stored as the cache or the model index, so a later
|
|
236
|
+
# resolve doesn't miss the slow host's ids; finished hosts' lists are
|
|
237
|
+
# kept for their TTL as usual.
|
|
238
|
+
def list_all_models(force: true, wait: nil)
|
|
233
239
|
results = {}
|
|
234
240
|
results_mutex = Mutex.new
|
|
235
241
|
threads = @entries.map do |name, entry|
|
|
@@ -247,7 +253,24 @@ module Samagotchi
|
|
|
247
253
|
results_mutex.synchronize { results[name] = data }
|
|
248
254
|
end
|
|
249
255
|
end
|
|
250
|
-
|
|
256
|
+
if wait
|
|
257
|
+
deadline = @clock.call + wait
|
|
258
|
+
threads.each { |thread| thread.join([deadline - @clock.call, 0].max) if thread.is_a?(Thread) }
|
|
259
|
+
partial = false
|
|
260
|
+
# a new hash: the threads keep writing into +results+ (their closure's)
|
|
261
|
+
snapshot = results_mutex.synchronize do
|
|
262
|
+
@entries.each_with_object({}) do |(name, entry), acc|
|
|
263
|
+
acc[name] = results.fetch(name) do
|
|
264
|
+
partial = true
|
|
265
|
+
{ host: entry.host, port: entry.port, transport: entry.transport, models: [],
|
|
266
|
+
error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
return snapshot if partial
|
|
271
|
+
else
|
|
272
|
+
threads.each { |thread| thread.join if thread.is_a?(Thread) }
|
|
273
|
+
end
|
|
251
274
|
|
|
252
275
|
# Model index: model_id downcased -> the hosts listing it, in hosts:
|
|
253
276
|
# order (not the order the threads answered in).
|
|
@@ -1,64 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require_relative "reminder_store"
|
|
3
|
+
require_relative "reminder_queue"
|
|
6
4
|
|
|
7
5
|
module Samagotchi
|
|
8
6
|
# Idle job for periodic reminders — polled by the shared IdleScheduler
|
|
9
7
|
# (one background thread for the whole idle layer).
|
|
10
8
|
#
|
|
11
9
|
# Two delivery paths cooperate:
|
|
12
|
-
# 1.
|
|
13
|
-
#
|
|
14
|
-
# [SYSTEM: REMINDERS DUE] → marks all as fired atomically.
|
|
10
|
+
# 1. Every turn injects what is due (ReminderQueue#inject!, from
|
|
11
|
+
# Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
|
|
15
12
|
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
16
|
-
# idle, it
|
|
17
|
-
# (
|
|
13
|
+
# idle, it fires @auto_turn_callback, which queues their names
|
|
14
|
+
# (Engine#note_due_reminders) for the worker's or the REPL's
|
|
15
|
+
# reminder turn. While names are queued the tick waits; whoever
|
|
16
|
+
# clears them (a turn's injection, the reminder turn's consumer)
|
|
17
|
+
# re-arms it. The queue is the only "already due" state.
|
|
18
18
|
class IdleReminders
|
|
19
19
|
DEFAULT_MIN_INACTIVITY_SECONDS = 60.0 # Minimum idle time before checking for reminders
|
|
20
20
|
|
|
21
21
|
attr_reader :inactivity
|
|
22
22
|
|
|
23
23
|
def initialize(engine:, inactivity: DEFAULT_MIN_INACTIVITY_SECONDS,
|
|
24
|
-
|
|
24
|
+
queue: nil,
|
|
25
25
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
26
26
|
callback: nil)
|
|
27
27
|
raise ArgumentError, "IdleReminders requires an engine" unless engine
|
|
28
28
|
|
|
29
29
|
@engine = engine
|
|
30
30
|
@inactivity = inactivity
|
|
31
|
-
@
|
|
31
|
+
@queue = queue
|
|
32
32
|
@clock = clock
|
|
33
33
|
@auto_turn_callback = callback
|
|
34
|
-
|
|
35
|
-
@mutex = Monitor.new
|
|
36
|
-
@due_reminder_name = nil
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Get all due reminders (from ReminderStore). Called by Engine#collect_due_reminders.
|
|
40
|
-
# Thread-safe. Returns all due reminders, not just one.
|
|
41
|
-
# @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
|
|
42
|
-
def due_reminders
|
|
43
|
-
@reminder_store&.due_reminders || []
|
|
44
|
-
end
|
|
45
|
-
# Get the names of all due reminders. Called by the background thread to
|
|
46
|
-
# determine which reminders need a synthetic turn.
|
|
47
|
-
# @return [Array<String>] reminder names that are due
|
|
48
|
-
def due_reminder_names
|
|
49
|
-
due_reminders.map { |r| r[:name] }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Clear the pending due reminder (after it has been delivered).
|
|
53
|
-
# Called by Engine after injecting the reminder into the system prompt.
|
|
54
|
-
def clear_due
|
|
55
|
-
@mutex.synchronize { @due_reminder_name = nil }
|
|
56
34
|
end
|
|
57
35
|
|
|
58
36
|
# One detector step, called by the shared IdleScheduler. Public so specs
|
|
59
37
|
# can drive it deterministically.
|
|
60
38
|
def tick
|
|
61
|
-
return if @
|
|
39
|
+
return if @queue&.pending? # already queued, waiting for a turn to deliver it
|
|
62
40
|
return unless should_check?
|
|
63
41
|
|
|
64
42
|
check_due_reminders
|
|
@@ -78,14 +56,13 @@ module Samagotchi
|
|
|
78
56
|
end
|
|
79
57
|
|
|
80
58
|
def check_due_reminders
|
|
81
|
-
return unless @
|
|
82
|
-
due_names = @
|
|
59
|
+
return unless @queue
|
|
60
|
+
due_names = @queue.due.map { |r| r[:name] }
|
|
83
61
|
return if due_names.empty?
|
|
84
62
|
# Signal the engine to create a synthetic turn via callback.
|
|
85
63
|
# The callback is responsible for triggering a turn (e.g. SessionManager
|
|
86
64
|
# writes a file, TerminalUI queues input). The engine's run_turn or
|
|
87
65
|
# REPL injection point then picks up and delivers the reminders.
|
|
88
|
-
@mutex.synchronize { @due_reminder_name = due_names.first }
|
|
89
66
|
if @auto_turn_callback
|
|
90
67
|
@auto_turn_callback.call(due_names)
|
|
91
68
|
end
|