samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session"
|
|
4
|
+
require_relative "bridge_client"
|
|
5
|
+
require_relative "worker_sidecar"
|
|
6
|
+
require_relative "question_desk"
|
|
7
|
+
require_relative "guardrails/parent_approvals"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
# A child worker taking its parent's answer to a relayed approval. The
|
|
12
|
+
# POST that says "answered" carries no answer and no authority: this
|
|
13
|
+
# worker asks its parent session's live Bridge what the parent's relay
|
|
14
|
+
# holds (RelayDesk, which only the parent's question flow writes), and
|
|
15
|
+
# answers its own question with that, only when it is about this session
|
|
16
|
+
# and the question pending now, and is answered.
|
|
17
|
+
class RelayVerifier
|
|
18
|
+
# The client id a relayed answer is recorded with: relay:<parent8>.
|
|
19
|
+
CLIENT_PREFIX = "relay"
|
|
20
|
+
|
|
21
|
+
# @param engine [Engine] this (the child's) worker's Engine
|
|
22
|
+
def initialize(engine:, state_dir:, session_id:)
|
|
23
|
+
@engine = engine
|
|
24
|
+
@state_dir = state_dir
|
|
25
|
+
@session_id = session_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array(Hash, Integer, Hash)] headers, status, body: 200
|
|
29
|
+
# answered, 409 no longer pending, 403 refused (a parent agent's allow
|
|
30
|
+
# beyond this worker's guardrails.parent_approvals), 422
|
|
31
|
+
# relay_unverified (logged; the question stays open)
|
|
32
|
+
def answer(relay_id:, question_id:)
|
|
33
|
+
parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
|
|
34
|
+
relay = parent_relay(parent_id, relay_id) or return unverified("the parent has no such relay", relay_id)
|
|
35
|
+
return unverified("the relay is about another session", relay_id) unless relay["child_id"] == @session_id
|
|
36
|
+
return unverified("the relay is about another question", relay_id) unless relay["child_question_id"] == question_id
|
|
37
|
+
return unverified("the relay is #{relay["state"]}, not answered", relay_id) unless relay["state"] == "answered"
|
|
38
|
+
|
|
39
|
+
apply(relay, parent_id: parent_id, question_id: question_id, relay_id: relay_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def apply(relay, parent_id:, question_id:, relay_id:)
|
|
45
|
+
answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
|
|
46
|
+
parent_agent = relay["by"] != "user"
|
|
47
|
+
client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
|
|
48
|
+
if answer["dismissed"]
|
|
49
|
+
return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
|
|
50
|
+
|
|
51
|
+
log("relay_answered", relay_id, id: question_id, dismissed: true, by: relay["by"])
|
|
52
|
+
return [{}, 200, { status: "dismissed", question_id: question_id }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
selected = labels(answer["selected_indices"], question_id) or return unverified("the answer's option is out of range", relay_id)
|
|
56
|
+
result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
|
|
57
|
+
client_id: client_id, parent_agent: parent_agent)
|
|
58
|
+
log("relay_answered", relay_id, id: question_id, by: relay["by"])
|
|
59
|
+
[{}, 200, { status: "answered", question_id: question_id, answer: result }]
|
|
60
|
+
rescue QuestionDesk::NotPending
|
|
61
|
+
not_pending(question_id)
|
|
62
|
+
rescue QuestionDesk::Refused => e
|
|
63
|
+
log("relay_refused", relay_id, id: question_id, reason: e.reason)
|
|
64
|
+
[{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
|
|
65
|
+
detail: Guardrails::ParentApprovals.message(e.reason) }]
|
|
66
|
+
rescue ArgumentError => e
|
|
67
|
+
unverified("the answer doesn't fit the question: #{e.message}", relay_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The parent's option indices as this question's own labels (the
|
|
71
|
+
# parent's are relabelled; the order is the same). nil when one is out
|
|
72
|
+
# of range; [] for a freeform-only deny.
|
|
73
|
+
def labels(indices, question_id)
|
|
74
|
+
pending = @engine.pending_question
|
|
75
|
+
return [] unless pending && pending[:id].to_s == question_id
|
|
76
|
+
|
|
77
|
+
options = Array(pending[:options])
|
|
78
|
+
indices = Array(indices)
|
|
79
|
+
return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
|
|
80
|
+
|
|
81
|
+
indices.map { |index| options[index] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def own_parent_id
|
|
85
|
+
Session.load(@session_id, state_dir: @state_dir).parent_id
|
|
86
|
+
rescue ArgumentError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Hash, nil] what the parent's live Bridge says about the relay
|
|
91
|
+
def parent_relay(parent_id, relay_id)
|
|
92
|
+
dir = Session.session_dir(parent_id, state_dir: @state_dir)
|
|
93
|
+
client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
|
|
94
|
+
response = client.relay_status(relay_id)
|
|
95
|
+
response.ok? ? response.json : nil
|
|
96
|
+
rescue SystemCallError, IOError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def not_pending(question_id)
|
|
101
|
+
[{}, 409, { error: "question_not_pending", detail: "no pending question #{question_id}" }]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unverified(why, relay_id)
|
|
105
|
+
log("relay_unverified", relay_id, why: why)
|
|
106
|
+
[{}, 422, { error: "relay_unverified", detail: why }]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def log(event, relay_id, **fields)
|
|
110
|
+
Log.info(:bridge, event, sid: @session_id, relay: relay_id.to_s[0, 8], **fields)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "worker_sidecar"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A child's look at its parent while its question is relayed there: when
|
|
8
|
+
# the parent's worker is gone, its relay card and RelayDesk went with it,
|
|
9
|
+
# so the question's relay mark is cleared (reason parent_gone) and its
|
|
10
|
+
# cards say "answer here" again. Outside the question desk: it clears a
|
|
11
|
+
# mark, never cancels the question. Stops once the question is no longer
|
|
12
|
+
# that relay's.
|
|
13
|
+
module RelayWatcher
|
|
14
|
+
INTERVAL = 2
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param engine [Engine] the child's
|
|
19
|
+
# @param parent_dir [String] the parent session's folder (its sidecar)
|
|
20
|
+
# @param live [#call] whether the parent's worker is up
|
|
21
|
+
# @return [Thread]
|
|
22
|
+
def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
|
|
23
|
+
live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
|
|
24
|
+
Thread.new do
|
|
25
|
+
loop do
|
|
26
|
+
sleep(interval)
|
|
27
|
+
break unless relayed?(engine.pending_question, question_id, relay_id)
|
|
28
|
+
next if live.call
|
|
29
|
+
|
|
30
|
+
Log.info(:bridge, "relay_parent_gone", id: question_id)
|
|
31
|
+
engine.annotate_question(question_id, relayed_to: nil, reason: "parent_gone")
|
|
32
|
+
break
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Log.warn(:bridge, "relay_watch_failed", error: e.class.name, message: e.message)
|
|
36
|
+
end.tap { |thread| thread.report_on_exception = false }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def relayed?(pending, question_id, relay_id)
|
|
40
|
+
relay = pending && pending[:relayed_to]
|
|
41
|
+
return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
|
|
42
|
+
|
|
43
|
+
(relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -50,10 +50,13 @@ module Samagotchi
|
|
|
50
50
|
# a failure replaces the note and leaves the count as it was.
|
|
51
51
|
# @param owner_grace [Numeric, nil] seconds with no live worker before
|
|
52
52
|
# :worker_gone (one that died before its rescue leaves it running)
|
|
53
|
+
# @param interject [#call, nil] called once a poll, before the sleep
|
|
54
|
+
# (the delegate wait relays other children's approvals there); the
|
|
55
|
+
# time it takes doesn't count against +timeout+
|
|
53
56
|
# @return [Result]
|
|
54
57
|
# @raise [ArgumentError] no such session
|
|
55
58
|
def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
|
|
56
|
-
baseline: nil, owner_grace: nil)
|
|
59
|
+
baseline: nil, owner_grace: nil, interject: nil)
|
|
57
60
|
deadline = timeout && (monotonic + timeout.to_f)
|
|
58
61
|
seen_running = false
|
|
59
62
|
gone_since = nil
|
|
@@ -104,6 +107,12 @@ module Samagotchi
|
|
|
104
107
|
|
|
105
108
|
return Result.new(status: :timeout) if deadline && monotonic > deadline
|
|
106
109
|
|
|
110
|
+
if interject
|
|
111
|
+
started = monotonic
|
|
112
|
+
interject.call
|
|
113
|
+
deadline += monotonic - started if deadline
|
|
114
|
+
next if cancelled.call
|
|
115
|
+
end
|
|
107
116
|
sleep(poll_interval)
|
|
108
117
|
end
|
|
109
118
|
end
|
|
@@ -7,6 +7,7 @@ require_relative "context_window"
|
|
|
7
7
|
require_relative "session"
|
|
8
8
|
require_relative "log_path"
|
|
9
9
|
require_relative "model_profile"
|
|
10
|
+
require_relative "model_overlay"
|
|
10
11
|
require_relative "thinking"
|
|
11
12
|
require_relative "served_model"
|
|
12
13
|
require_relative "host_registry"
|
|
@@ -39,7 +40,8 @@ module Samagotchi
|
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def fields(env: ENV)
|
|
42
|
-
|
|
43
|
+
default = model_name
|
|
44
|
+
model = session_model(env) || default
|
|
43
45
|
[
|
|
44
46
|
["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
|
|
45
47
|
["source", "#{SOURCE_DIR} (#{install_kind})"],
|
|
@@ -49,7 +51,8 @@ module Samagotchi
|
|
|
49
51
|
["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
|
|
50
52
|
["sessions", Session.default_state_dir(env: env)],
|
|
51
53
|
["log", log_path(env)],
|
|
52
|
-
["model", model
|
|
54
|
+
["model", model ? model_label(model, default, env) : "(not configured)"],
|
|
55
|
+
["model key", model ? model_key_for(model, env) : "-"],
|
|
53
56
|
["host", model ? host_for(model, env) : "-"],
|
|
54
57
|
["api key", model ? api_key_for(model, env) : "-"],
|
|
55
58
|
["loop", model ? loop_for(model, env) : "-"],
|
|
@@ -125,6 +128,46 @@ module Samagotchi
|
|
|
125
128
|
nil
|
|
126
129
|
end
|
|
127
130
|
|
|
131
|
+
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
132
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
133
|
+
|
|
134
|
+
# The model of the session this chi self runs in (its execute exports
|
|
135
|
+
# SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
|
|
136
|
+
# outside a session's commands.
|
|
137
|
+
def session_model(env)
|
|
138
|
+
name = env[SESSION_MODEL_ENV].to_s.strip
|
|
139
|
+
name.empty? ? nil : name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# "splash:x (this session abcd1234; default main:y)", "main:y (this
|
|
143
|
+
# session abcd1234, the default)", or outside a session "main:y (default)".
|
|
144
|
+
def model_label(model, default, env)
|
|
145
|
+
return "#{model} (default)" unless session_model(env)
|
|
146
|
+
|
|
147
|
+
id = env[PARENT_SESSION_ENV].to_s
|
|
148
|
+
session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
|
|
149
|
+
return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
|
|
150
|
+
|
|
151
|
+
"#{model} (#{session}; default #{default || "not configured"})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def same_model?(one, other, env)
|
|
155
|
+
return true if one == other
|
|
156
|
+
|
|
157
|
+
ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The memory overlay key (ModelOverlay.key_for the bare id, as the
|
|
163
|
+
# engine keys memory_write current_model_only): which `<name>.<key>.md`
|
|
164
|
+
# overlays are this model's.
|
|
165
|
+
def model_key_for(model, env)
|
|
166
|
+
ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
|
|
167
|
+
rescue StandardError
|
|
168
|
+
ModelOverlay.key_for(model) || "-"
|
|
169
|
+
end
|
|
170
|
+
|
|
128
171
|
def model_name
|
|
129
172
|
ModelProfile.required_model_name
|
|
130
173
|
rescue ArgumentError
|
|
@@ -147,6 +190,8 @@ module Samagotchi
|
|
|
147
190
|
entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
|
|
148
191
|
end
|
|
149
192
|
|
|
193
|
+
NO_PROPS = "reported per turn (the server has no /props)"
|
|
194
|
+
|
|
150
195
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
151
196
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
152
197
|
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
@@ -157,10 +202,11 @@ module Samagotchi
|
|
|
157
202
|
bare = target.bare_model
|
|
158
203
|
return "-" unless entry
|
|
159
204
|
return "reported per turn (remote host)" if entry.remote?
|
|
205
|
+
return NO_PROPS if entry.chat?
|
|
160
206
|
|
|
161
207
|
client = registry.client_for(entry)
|
|
162
208
|
props = client.server_props(model: bare)
|
|
163
|
-
return
|
|
209
|
+
return NO_PROPS if props.nil? || props.status == :http_error
|
|
164
210
|
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
165
211
|
|
|
166
212
|
served = ServedModel.from_props(props)
|
|
@@ -37,6 +37,8 @@ module Samagotchi
|
|
|
37
37
|
chi send --wait [--timeout S] [--format json] ID
|
|
38
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
39
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
40
|
+
A session command (/model x, !cmd) runs as the command; --wait
|
|
41
|
+
then has no reply to wait for (exit 0, status command).
|
|
40
42
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
41
43
|
quote (context); without -m, stdin is the message
|
|
42
44
|
--image PATH
|
|
@@ -64,7 +66,7 @@ module Samagotchi
|
|
|
64
66
|
(--wait) one JSON object on stdout, whatever the end:
|
|
65
67
|
status answered (text), question (question,
|
|
66
68
|
answer_with), running, or failed, canceled,
|
|
67
|
-
no_answer, error, worker_gone, stopped (detail)
|
|
69
|
+
no_answer, error, worker_gone, stopped, command (detail)
|
|
68
70
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
71
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
70
72
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -246,23 +248,27 @@ module Samagotchi
|
|
|
246
248
|
error_line("chi send: refused: #{refusal}")
|
|
247
249
|
return 1
|
|
248
250
|
end
|
|
251
|
+
# With images the web's way: idle (the message names it in the
|
|
252
|
+
# lists), the images copied in, then the message as a turn. So too a
|
|
253
|
+
# line that may be a session command (/model x): its worker's answer
|
|
254
|
+
# says whether it ran as one.
|
|
255
|
+
through_worker = images || command_like?(prompt)
|
|
249
256
|
begin
|
|
250
|
-
|
|
251
|
-
# lists), the images copied in, then the message as a turn.
|
|
252
|
-
start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
257
|
+
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
253
258
|
session = SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
254
259
|
model_name: options[:model], state_dir: @state_dir)
|
|
255
260
|
rescue StandardError => e
|
|
256
261
|
error_line("chi send: could not start a session: #{e.message}")
|
|
257
262
|
return 1
|
|
258
263
|
end
|
|
259
|
-
if
|
|
264
|
+
if through_worker
|
|
260
265
|
return 1 unless deliver_new(session, prompt)
|
|
261
266
|
else
|
|
262
267
|
@info.puts("#{session.id} started")
|
|
263
268
|
end
|
|
264
269
|
@session_id = session.id
|
|
265
270
|
return 0 unless options[:wait]
|
|
271
|
+
return report_command(session.id) if @sent_command
|
|
266
272
|
|
|
267
273
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
268
274
|
timeout: options[:timeout])
|
|
@@ -297,6 +303,7 @@ module Samagotchi
|
|
|
297
303
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
298
304
|
baseline = baseline_of(session)
|
|
299
305
|
return 1 unless deliver(id, prompt)
|
|
306
|
+
return report_command(id) if @sent_command
|
|
300
307
|
|
|
301
308
|
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
302
309
|
end
|
|
@@ -352,10 +359,36 @@ module Samagotchi
|
|
|
352
359
|
return false
|
|
353
360
|
end
|
|
354
361
|
|
|
355
|
-
@
|
|
362
|
+
@sent_command = command_ack?(result[:ack])
|
|
363
|
+
@info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
356
364
|
true
|
|
357
365
|
end
|
|
358
366
|
|
|
367
|
+
SENT_COMMAND = "sent as a session command"
|
|
368
|
+
|
|
369
|
+
# Whether the worker took the message as a session command (a POST
|
|
370
|
+
# /turn whose line is one answers with its command_id).
|
|
371
|
+
def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
|
|
372
|
+
|
|
373
|
+
# Whether +text+ may be a session command, which only its worker knows
|
|
374
|
+
# (a bundle's too).
|
|
375
|
+
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
376
|
+
|
|
377
|
+
# --wait after a message that ran as a session command: no reply comes
|
|
378
|
+
# for it. Its output shows where the session is open.
|
|
379
|
+
# @return [Integer] 0
|
|
380
|
+
def report_command(id)
|
|
381
|
+
detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
|
|
382
|
+
if @json
|
|
383
|
+
@stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
|
|
384
|
+
@stdout.flush
|
|
385
|
+
@reported = true
|
|
386
|
+
else
|
|
387
|
+
@stderr.puts("#{id[0, 8]} #{detail}")
|
|
388
|
+
end
|
|
389
|
+
CLI::Exit::OK
|
|
390
|
+
end
|
|
391
|
+
|
|
359
392
|
# @return [Boolean] whether the message was queued
|
|
360
393
|
def deliver(id, prompt)
|
|
361
394
|
short = id[0, 8]
|
|
@@ -373,6 +406,12 @@ module Samagotchi
|
|
|
373
406
|
return false
|
|
374
407
|
end
|
|
375
408
|
|
|
409
|
+
@sent_command = command_ack?(result[:ack])
|
|
410
|
+
if @sent_command
|
|
411
|
+
@info.puts("#{short} #{SENT_COMMAND}")
|
|
412
|
+
return true
|
|
413
|
+
end
|
|
414
|
+
|
|
376
415
|
# A busy worker runs a message with images as its own next turn
|
|
377
416
|
# rather than merging it into the running one.
|
|
378
417
|
note = if owner.nil? then " (started its worker)"
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -111,7 +111,9 @@ module Samagotchi
|
|
|
111
111
|
return nil unless live && pending.is_a?(Hash) && pending[:id]
|
|
112
112
|
|
|
113
113
|
kind = pending[:kind].to_s
|
|
114
|
-
|
|
114
|
+
relayed = pending[:relayed_to]
|
|
115
|
+
relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
|
|
116
|
+
{ id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
# The stored project root, else (a file from before the field) the
|
|
@@ -464,7 +466,18 @@ module Samagotchi
|
|
|
464
466
|
entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
|
|
465
467
|
end
|
|
466
468
|
end
|
|
467
|
-
message
|
|
469
|
+
symbolize_empty_answer(message)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# An empty-answer note's marker (TurnNote.empty) and its steps, as
|
|
473
|
+
# they were saved.
|
|
474
|
+
def symbolize_empty_answer(message)
|
|
475
|
+
marker = message[:empty_answer]
|
|
476
|
+
return message unless marker.is_a?(Hash)
|
|
477
|
+
|
|
478
|
+
marker = marker.transform_keys(&:to_sym)
|
|
479
|
+
marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
|
|
480
|
+
message.merge(empty_answer: marker)
|
|
468
481
|
end
|
|
469
482
|
|
|
470
483
|
def session_path(session_id, state_dir:)
|
|
@@ -241,7 +241,8 @@ module Samagotchi
|
|
|
241
241
|
# a turn running, recap = the saved recap's first sentence, project =
|
|
242
242
|
# Session#project_root, waiting = the kind of question it waits on
|
|
243
243
|
# ("question", "approval", "hook"; Session#waiting_question) or nil,
|
|
244
|
-
# waiting_id = that question's id or nil
|
|
244
|
+
# waiting_id = that question's id or nil, relayed_to = the parent
|
|
245
|
+
# whose card it waits in too (the approval relay), its short id, or nil
|
|
245
246
|
def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
|
|
246
247
|
project_root: nil, include_archived: false, sort: nil, order: nil)
|
|
247
248
|
sd = state_dir || Session.default_state_dir
|
|
@@ -269,7 +270,7 @@ module Samagotchi
|
|
|
269
270
|
# waiting: the question's kind, waiting_id: its id (what chi answer
|
|
270
271
|
# --question takes); both nil with none.
|
|
271
272
|
def self.waiting_fields(waiting)
|
|
272
|
-
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
|
|
273
|
+
{ waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
|
|
273
274
|
end
|
|
274
275
|
private_class_method :waiting_fields
|
|
275
276
|
|