samagotchi 0.101.0 → 0.103.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 +67 -1
- data/bin/chi +31 -196
- data/docs/architecture.md +62 -6
- data/docs/cli.md +154 -7
- data/docs/configuration.md +32 -5
- data/docs/guardrails.md +2 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +25 -1
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +174 -15
- data/docs/plugins.md +32 -5
- data/docs/sessions.md +38 -14
- data/docs/sub-agent.md +8 -0
- data/docs/testing.md +7 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge/turn_accumulator.rb +16 -0
- data/lib/samagotchi/bridge.rb +33 -7
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundle_command/check.rb +5 -1
- data/lib/samagotchi/bundle_command/try/command.rb +6 -6
- data/lib/samagotchi/bundle_command/try/sandbox.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +4 -4
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/commands/registry.rb +12 -1
- data/lib/samagotchi/config.rb +89 -53
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +138 -15
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/idle_scheduler.rb +16 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_edit.rb +32 -10
- data/lib/samagotchi/llm_context_forget.rb +24 -8
- data/lib/samagotchi/llm_context_view.rb +19 -7
- data/lib/samagotchi/memory_bundle/installer.rb +2 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +13 -2
- data/lib/samagotchi/message_parts.rb +8 -3
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/plugin/context.rb +2 -2
- data/lib/samagotchi/prompt.rb +1 -1
- data/lib/samagotchi/recap_command.rb +68 -0
- data/lib/samagotchi/recap_setup.rb +27 -14
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +186 -118
- data/lib/samagotchi/session.rb +48 -16
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +53 -6
- data/lib/samagotchi/session_config.rb +411 -0
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +15 -10
- data/lib/samagotchi/sessions_command.rb +42 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +54 -18
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +82 -76
- data/lib/samagotchi/terminal_ui/formatting.rb +10 -3
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +8 -6
- data/lib/samagotchi/testing/bundle.rb +3 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +387 -79
- data/lib/samagotchi/web/public/app.js +2 -0
- data/lib/samagotchi/web/public/commands.js +8 -2
- data/lib/samagotchi/web/public/data.js +6 -4
- data/lib/samagotchi/web/public/index.html +37 -3
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +7 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -0
- data/lib/samagotchi/web/public/working_set.js +247 -24
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +198 -25
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker.rb +9 -6
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +132 -20
- data/lib/samagotchi/working_set_command.rb +222 -0
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +414 -0
- metadata +23 -2
- data/lib/samagotchi/bundle_session_settings.rb +0 -251
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../session_access/local"
|
|
5
|
+
require_relative "../session_inbox"
|
|
6
|
+
require_relative "../reply_wait"
|
|
7
|
+
require_relative "../pending_question"
|
|
8
|
+
require_relative "../context_sources"
|
|
9
|
+
require_relative "../context_command"
|
|
10
|
+
require_relative "../send_command"
|
|
11
|
+
require_relative "../launch_mode"
|
|
12
|
+
require_relative "../model_profile"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
module Web
|
|
16
|
+
# The routes chi's session commands need from another machine's chi web
|
|
17
|
+
# (chi --remote NAME …, SessionAccess::Remote), one per
|
|
18
|
+
# SessionAccess::Local operation that the page's own routes don't cover:
|
|
19
|
+
# a prefix resolved, the CLI's list rows, a wait's pre-send state and
|
|
20
|
+
# the wait itself, a note, a push into a context source. Each calls
|
|
21
|
+
# SessionAccess::Local, so a Remote maps 1:1 onto them. Included in
|
|
22
|
+
# App: its ROUTES name the handlers, and its helpers (json_response,
|
|
23
|
+
# parse_json, …) answer. See docs/architecture.md (chi web's routes for
|
|
24
|
+
# the session commands).
|
|
25
|
+
module SessionAccessRoutes
|
|
26
|
+
# A wait route answers within this many seconds (status timeout); the
|
|
27
|
+
# client asks again until its own --timeout.
|
|
28
|
+
WAIT_MAX_SECONDS = 30.0
|
|
29
|
+
# GET /api/info's feature: this chi web has these routes (a remote
|
|
30
|
+
# client checks it before its first session command).
|
|
31
|
+
FEATURE = "session_commands"
|
|
32
|
+
# GET /api/info's feature: this chi web has the routes a remote
|
|
33
|
+
# attached TUI needs (wake, worker; chi --remote NAME --attach).
|
|
34
|
+
ATTACH_FEATURE = "remote_attach"
|
|
35
|
+
# The longest POST …/wake waits for a worker's Bridge
|
|
36
|
+
# (TerminalUI::AttachLauncher::BRIDGE_WAIT, an attach's here).
|
|
37
|
+
WAKE_MAX_WAIT = 10.0
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# This server's sessions, as a session command reaches them here.
|
|
42
|
+
def session_access = @session_access ||= SessionAccess::Local.new(state_dir: @state_dir)
|
|
43
|
+
|
|
44
|
+
# A refusal an exception carries: its stable +code+, +detail+ (what
|
|
45
|
+
# the page shows; the message by default), and for a remote client
|
|
46
|
+
# the exception's own message and the fields its class is built from
|
|
47
|
+
# (session_id, reason, …), so it can raise the same class.
|
|
48
|
+
def refusal_response(status, code, error, detail: error.message, **fields)
|
|
49
|
+
json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The body as a Hash ({} when there is none), or nil when it isn't one.
|
|
53
|
+
def body_hash(req)
|
|
54
|
+
raw = request_body(req)
|
|
55
|
+
return {} if raw.to_s.strip.empty?
|
|
56
|
+
|
|
57
|
+
parsed = parse_json(raw)
|
|
58
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def truthy_param?(value) = %w[1 true yes].include?(value.to_s.strip.downcase)
|
|
62
|
+
|
|
63
|
+
# GET /api/sessions/resolve?id=PREFIX: the full id of the session an id
|
|
64
|
+
# or a unique prefix names (SessionAccess::Local#resolve). 200 {id};
|
|
65
|
+
# 404 not_found; 400 ambiguous_id with ids (the sessions it names).
|
|
66
|
+
def handle_resolve(req)
|
|
67
|
+
given = req.params["id"].to_s
|
|
68
|
+
return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
|
|
69
|
+
|
|
70
|
+
json_response(200, { id: session_access.resolve(given) })
|
|
71
|
+
rescue Session::AmbiguousId => e
|
|
72
|
+
refusal_response(400, "ambiguous_id", e, ids: e.ids)
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
error_response(404, "not_found", "no session #{given[0, 80]}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# GET /api/sessions/:id?brief=1: the session as a session command
|
|
78
|
+
# reads it (SessionAccess::Local#show): the file's status (not the
|
|
79
|
+
# displayed one), its names, folder, model and parent_answers, its
|
|
80
|
+
# owner's kind, and the file's pending question, never its messages.
|
|
81
|
+
# A chi web from before ?brief answers the whole read, which has the
|
|
82
|
+
# session part too.
|
|
83
|
+
def handle_brief_read(id)
|
|
84
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
85
|
+
owner = session_access.owner(id)
|
|
86
|
+
json_response(200, {
|
|
87
|
+
brief: true,
|
|
88
|
+
session: { id: session.id, status: session.status, title: session.title,
|
|
89
|
+
display_title: session.display_title, project_root: session.project_root,
|
|
90
|
+
working_directory: session.working_directory, model_name: session.model_name,
|
|
91
|
+
model_typed: session.model_typed, parent_answers: session.parent_answers,
|
|
92
|
+
owner: owner&.kind },
|
|
93
|
+
pending_question: session.pending_question&.to_file
|
|
94
|
+
})
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# GET /api/sessions/summaries: the rows chi sessions list prints
|
|
98
|
+
# (SessionManager.session_summaries: status, live, busy, owner, recap,
|
|
99
|
+
# ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
|
|
100
|
+
# folder: its project's sessions; none: every project's), cwd (in that
|
|
101
|
+
# folder or below), live=1, archived=1, tests=0 (leave test runs out),
|
|
102
|
+
# sort, order, limit. An array, newest first by default.
|
|
103
|
+
def handle_summaries(req)
|
|
104
|
+
dir, error = scope_dir(req.params["dir"])
|
|
105
|
+
return error if error
|
|
106
|
+
|
|
107
|
+
cwd = req.params["cwd"].to_s
|
|
108
|
+
cwd = nil if cwd.strip.empty?
|
|
109
|
+
return error_response(400, "invalid_cwd", "cwd is an absolute path: #{cwd[0, 200]}") if cwd && !cwd.start_with?("/")
|
|
110
|
+
|
|
111
|
+
rows = session_access.summaries(
|
|
112
|
+
live: truthy_param?(req.params["live"]), cwd: cwd, limit: sanitize_limit(req.params["limit"]),
|
|
113
|
+
include_tests: !%w[0 false no].include?(req.params["tests"].to_s.strip.downcase),
|
|
114
|
+
project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
|
|
115
|
+
sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
|
|
116
|
+
)
|
|
117
|
+
json_response(200, rows)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
|
|
121
|
+
# answer read before anything is sent (SessionAccess::Local#
|
|
122
|
+
# prepare_wait). Body (all optional): question_id (the question the
|
|
123
|
+
# baseline counts as reported; absent: the one pending now, null:
|
|
124
|
+
# none), turn (--turn: known_turn says whether it can be waited for),
|
|
125
|
+
# created (a session just created: no reply, no question), busy (chi
|
|
126
|
+
# send --wait with a message: refuse a running turn, 409 busy or
|
|
127
|
+
# waiting in SendCommand::Busy's words, with its fields: question,
|
|
128
|
+
# parent_strategy; queue: true, --queue, refuses only a waiting
|
|
129
|
+
# question). 200 {session_id, cursor, baseline:
|
|
130
|
+
# {messages, question_id, last_turn}, own_turn, known_turn, running,
|
|
131
|
+
# live, settled, last_turn_id}.
|
|
132
|
+
def handle_wait_prepare(req, id)
|
|
133
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
134
|
+
turn = body["turn"]
|
|
135
|
+
return error_response(400, "invalid_turn", "turn is a turn id") unless turn.nil? || turn.is_a?(String)
|
|
136
|
+
|
|
137
|
+
session = session_access.show(id)
|
|
138
|
+
if body["busy"] == true
|
|
139
|
+
owner = session_access.owner(id) if session.status == Session::STATUS_RUNNING
|
|
140
|
+
busy = SendCommand::Busy.for(session, owner: owner, queue: body["queue"] == true)
|
|
141
|
+
return json_response(409, busy_json(busy)) if busy
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
question_id = body.key?("question_id") ? body["question_id"]&.to_s : :pending
|
|
145
|
+
start = session_access.prepare_wait(id, session: session, question_id: question_id, turn: turn,
|
|
146
|
+
created: body["created"] == true)
|
|
147
|
+
json_response(200, wait_start_json(id, start))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A Busy refusal: its words here, and its fields, which a chi --remote
|
|
151
|
+
# client words with its own commands (SessionAccess::Remote#busy).
|
|
152
|
+
def busy_json(busy)
|
|
153
|
+
{ error: busy.error, detail: busy.message, message: busy.message, session_id: busy.session_id,
|
|
154
|
+
question: busy.question&.to_file, parent_strategy: (strategy_json(busy.strategy) if busy.strategy) }.compact
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def strategy_json(strategy) = { name: strategy.name, allowed: strategy.allowed, origin: strategy.origin.to_s }
|
|
158
|
+
|
|
159
|
+
def wait_start_json(id, start)
|
|
160
|
+
{ session_id: id, cursor: start.cursor, baseline: start.baseline.to_h, own_turn: start.own_turn,
|
|
161
|
+
known_turn: start.known_turn, running: start.running, live: start.live, settled: start.settled,
|
|
162
|
+
last_turn_id: start.last_turn_id }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# POST /api/sessions/:id/wait: ReplyWait for at most WAIT_MAX_SECONDS
|
|
166
|
+
# (SessionAccess::Local#wait_reply). Body: cursor, baseline
|
|
167
|
+
# ({messages, question_id, last_turn}, as the prepare route gave it),
|
|
168
|
+
# turn, queued, ended_before, owner_grace (seconds, null: wait for
|
|
169
|
+
# whatever wakes a worker), timeout (seconds, capped). 200: the
|
|
170
|
+
# ReplyWait::Result's fields (status: done, waiting_for_answer,
|
|
171
|
+
# no_reply, error, stopped, timeout or worker_gone; text, file,
|
|
172
|
+
# question, outcome, …; never the Session) and parent_strategy, what
|
|
173
|
+
# a parent may pick on the session's questions here
|
|
174
|
+
# (ParentReport.strategy_for). A timeout status: ask again. 503
|
|
175
|
+
# server_stopping when chi web stops mid-wait.
|
|
176
|
+
def handle_wait(req, id)
|
|
177
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
178
|
+
options, error = wait_options(body)
|
|
179
|
+
return error if error
|
|
180
|
+
|
|
181
|
+
session_access.show(id)
|
|
182
|
+
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
183
|
+
cancelled: -> { !@server_running.call })
|
|
184
|
+
return error_response(503, "server_stopping", "chi web is stopping; wait again") if result.status == :canceled
|
|
185
|
+
|
|
186
|
+
json_response(200, wait_result_json(result, session_access.parent_strategy(id)))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The wait's ReplyWait arguments from the body: [options, nil] or
|
|
190
|
+
# [nil, a 400 response].
|
|
191
|
+
def wait_options(body)
|
|
192
|
+
strings = %w[cursor turn].to_h { |key| [key, body[key]] }
|
|
193
|
+
if strings.any? { |_, value| !value.nil? && !value.is_a?(String) }
|
|
194
|
+
return [nil, error_response(400, "invalid_wait", "cursor and turn are strings")]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
baseline, error = wait_baseline(body["baseline"])
|
|
198
|
+
return [nil, error] if error
|
|
199
|
+
|
|
200
|
+
numbers = %w[timeout owner_grace].to_h { |key| [key, body[key]] }
|
|
201
|
+
if numbers.any? { |_, value| !value.nil? && !(value.is_a?(Numeric) && value.positive?) }
|
|
202
|
+
return [nil, error_response(400, "invalid_wait", "timeout and owner_grace are positive seconds")]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
|
|
206
|
+
[{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
|
|
207
|
+
owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def wait_baseline(raw)
|
|
211
|
+
return [nil, nil] if raw.nil?
|
|
212
|
+
|
|
213
|
+
fields = { "messages" => Integer, "question_id" => String, "last_turn" => String }
|
|
214
|
+
unless raw.is_a?(Hash) && raw.all? { |key, value| fields.key?(key) && (value.nil? || value.is_a?(fields[key])) }
|
|
215
|
+
return [nil, error_response(400, "invalid_wait", "baseline is {messages, question_id, last_turn}")]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
[ReplyWait::Baseline.new(**raw.transform_keys(&:to_sym)), nil]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
|
|
222
|
+
|
|
223
|
+
def wait_result_json(result, strategy)
|
|
224
|
+
json = { status: result.status.to_s }
|
|
225
|
+
RESULT_FIELDS.each { |key| json[key] = result[key] }
|
|
226
|
+
json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
227
|
+
json[:question] = result.question&.to_file
|
|
228
|
+
json[:parent_strategy] = strategy_json(strategy)
|
|
229
|
+
json
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# POST /api/sessions/:id/wake {wait}: the session's worker for a remote
|
|
233
|
+
# attach (SessionAccess::Local#wake): the live one, else resumed and
|
|
234
|
+
# waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
|
|
235
|
+
# {session_id, woke, worker: {epoch, started_at, version, features},
|
|
236
|
+
# model_name, default_model (this machine's config, for the TUI's
|
|
237
|
+
# status row), parent_strategy, installed (the newest chi here)}; 409
|
|
238
|
+
# owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
|
|
239
|
+
# session, as chi --attach here says); 504 worker_timeout (its Bridge
|
|
240
|
+
# didn't come in time); 404 not_found.
|
|
241
|
+
def handle_wake(req, id)
|
|
242
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
243
|
+
wait = body.fetch("wait", WAKE_MAX_WAIT)
|
|
244
|
+
return error_response(400, "invalid_wait", "wait is positive seconds") unless wait.is_a?(Numeric) && wait.positive?
|
|
245
|
+
|
|
246
|
+
woken = session_access.wake(id, wait: [wait, WAKE_MAX_WAIT].min)
|
|
247
|
+
unless woken.sidecar
|
|
248
|
+
late = format(LaunchMode::BRIDGE_LATE, id)
|
|
249
|
+
return json_response(504, { error: "worker_timeout", detail: late, message: late, session_id: id })
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
json_response(200, { session_id: id, woke: woken.woke, worker: worker_json(woken.sidecar),
|
|
253
|
+
model_name: session_access.show(id).model_name,
|
|
254
|
+
default_model: ModelProfile.default_model_name,
|
|
255
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)),
|
|
256
|
+
installed: @hub&.installed })
|
|
257
|
+
rescue SessionManager::OwnedByTUI => e
|
|
258
|
+
owned_by_tui(e)
|
|
259
|
+
rescue SessionAccess::Refused => e
|
|
260
|
+
refusal_response(409, e.code, e, session_id: id)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# GET /api/sessions/:id/worker: the session's worker now, never
|
|
264
|
+
# started (SessionAccess::Local#worker): a remote attach's
|
|
265
|
+
# rediscovery and its features after a restart. 200 {session_id,
|
|
266
|
+
# live, stopped, owner: worker|tui|null, worker: {epoch, started_at,
|
|
267
|
+
# version, features}|null, parent_strategy}; 404 not_found.
|
|
268
|
+
def handle_worker(_req, id)
|
|
269
|
+
state = session_access.worker(id)
|
|
270
|
+
json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
|
|
271
|
+
worker: state.sidecar && worker_json(state.sidecar),
|
|
272
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)) })
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A live worker as a remote client knows it: its epoch is its
|
|
276
|
+
# identity (the one in its event ids).
|
|
277
|
+
def worker_json(sidecar)
|
|
278
|
+
{ epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# POST /api/sessions/:id/notes {text, source}: a context note for the
|
|
282
|
+
# session (NoteDelivery, as chi note; source: where it came from,
|
|
283
|
+
# "cli" by default, never "broadcast"). 200 {status: "queued" (a
|
|
284
|
+
# worker adds it) or "waits" (the next start does), session_id,
|
|
285
|
+
# queued}; 409 owned_by_tui (status refused: a chi REPL has it); 400
|
|
286
|
+
# note_rejected (empty, or over 16 KiB).
|
|
287
|
+
def handle_notes(req, id)
|
|
288
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
289
|
+
text = body["text"]
|
|
290
|
+
source = body.fetch("source", "cli")
|
|
291
|
+
return error_response(400, "missing_fields", "text is required") unless text.is_a?(String)
|
|
292
|
+
return error_response(400, "invalid_source", "source is a word") unless source.is_a?(String) && !source.strip.empty?
|
|
293
|
+
return error_response(400, "invalid_source", "source broadcast is chi broadcast's own") if source.strip == "broadcast"
|
|
294
|
+
|
|
295
|
+
session_access.show(id)
|
|
296
|
+
result = session_access.note(id, text: text, source: source.strip)
|
|
297
|
+
if result.status == :refused
|
|
298
|
+
error = SessionManager::OwnedByTUI.new(id)
|
|
299
|
+
return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
|
|
303
|
+
rescue SessionInbox::NoteRejected => e
|
|
304
|
+
error_response(400, "note_rejected", e.message)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# POST /api/sessions/:id/context/:name/push {text, summary, wake,
|
|
308
|
+
# hint}: new text for the session's --push source (chi context push;
|
|
309
|
+
# the fields as a source's JSON output gives them, checked the same
|
|
310
|
+
# way). 200 {status: "pushed", name, before, revision, changed}; 404
|
|
311
|
+
# not_found (no such source); 409 not_push (a command source); 400
|
|
312
|
+
# invalid_text.
|
|
313
|
+
def handle_context_push(req, id, name)
|
|
314
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
315
|
+
return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
|
|
316
|
+
|
|
317
|
+
fetched = begin
|
|
318
|
+
ContextSources.parse_output(JSON.generate(body.slice("text", "summary", "wake", "hint")))
|
|
319
|
+
rescue ContextSources::Invalid => e
|
|
320
|
+
return error_response(400, "invalid_text", e.message)
|
|
321
|
+
end
|
|
322
|
+
pushed = session_access.context_push(context_target(id), name, fetched)
|
|
323
|
+
case pushed.status
|
|
324
|
+
when :missing then error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
325
|
+
when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
|
|
326
|
+
else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
|
|
327
|
+
changed: pushed.before != pushed.revision })
|
|
328
|
+
end
|
|
329
|
+
rescue ContextSources::Invalid
|
|
330
|
+
error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The session as chi context's Target (SessionAccess::Local's context
|
|
334
|
+
# calls take one).
|
|
335
|
+
# @raise [ArgumentError] no such session
|
|
336
|
+
def context_target(id)
|
|
337
|
+
session = session_access.show(id)
|
|
338
|
+
ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
|
|
339
|
+
label: id[0, 8])
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -259,7 +259,7 @@ module Samagotchi
|
|
|
259
259
|
})
|
|
260
260
|
# A recap written while a continue offer waits says the turn stopped
|
|
261
261
|
# unfinished (before the idle jobs start).
|
|
262
|
-
@engine.
|
|
262
|
+
@engine.recap_awaiting_continue = -> { @turn_flow.awaiting_continue? }
|
|
263
263
|
# The seq of the last turn's end event: a command queued before it was
|
|
264
264
|
# queued while that turn ran.
|
|
265
265
|
@turn_end_seq = 0
|
|
@@ -943,14 +943,17 @@ module Samagotchi
|
|
|
943
943
|
# finishes) comes after its command_queued. The cards it shows go out at
|
|
944
944
|
# once (btw's "thinking…" card), after its command_queued, which the
|
|
945
945
|
# UIs draw its line at (Engine#running_anytime). Its handler reads
|
|
946
|
-
# copies (ctx.messages) and shows things through ctx only. One
|
|
947
|
-
# /title,
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
#
|
|
946
|
+
# copies (ctx.messages) and shows things through ctx only. One that
|
|
947
|
+
# changed the session (/title, /recap off: fields no turn writes) is
|
|
948
|
+
# saved here, as a queued one is (#run_command), through Session#save,
|
|
949
|
+
# which takes the session's lock and writes a running turn's checkpoint
|
|
950
|
+
# (as WorkerSignals' save off the turn thread does), so the turn's own
|
|
951
|
+
# saves keep it.
|
|
951
952
|
def start_anytime_command(command)
|
|
952
953
|
@engine.spawn_anytime do
|
|
953
954
|
result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
|
|
955
|
+
# Saved before its command_ran: a client that reads the file on the reply finds it.
|
|
956
|
+
save_or_log(:command) { save_session } unless Array(result.changed).empty?
|
|
954
957
|
announce_command(command, status: result.status.to_s, output: result.output, changed: Array(result.changed),
|
|
955
958
|
anytime: true, confirm: result.confirm)
|
|
956
959
|
rescue StandardError => e
|
|
@@ -10,10 +10,14 @@ module Samagotchi
|
|
|
10
10
|
# Bridge (port, bind), when it started (a new worker behind the same
|
|
11
11
|
# "worker" owner), the chi it runs (chi update, the web's stale-worker
|
|
12
12
|
# badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
|
|
13
|
-
# what else it can do (Bridge::FEATURES)
|
|
13
|
+
# what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
|
|
14
|
+
# one worker identity: the epoch in every event id, so a remote client
|
|
15
|
+
# tells a restarted worker apart; nil from a worker before it). The
|
|
16
|
+
# Bridge writes it on start
|
|
14
17
|
# and removes it on stop; one a dead worker left stays until a client's
|
|
15
18
|
# probe finds its port closed (.live with unlink: true).
|
|
16
|
-
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features
|
|
19
|
+
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
|
|
20
|
+
:epoch)
|
|
17
21
|
FILE = "bridge.json"
|
|
18
22
|
# Seconds a liveness probe waits for the port to take a connect.
|
|
19
23
|
PROBE_TIMEOUT = 0.2
|
|
@@ -29,7 +33,7 @@ module Samagotchi
|
|
|
29
33
|
port = data["port"]
|
|
30
34
|
new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
|
|
31
35
|
started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
|
|
32
|
-
features: Array(data["features"]).map(&:to_s))
|
|
36
|
+
features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s)
|
|
33
37
|
rescue JSON::ParserError, SystemCallError
|
|
34
38
|
nil
|
|
35
39
|
end
|
|
@@ -63,13 +67,16 @@ module Samagotchi
|
|
|
63
67
|
# @return [Integer, nil] .live's port
|
|
64
68
|
def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
|
|
65
69
|
|
|
66
|
-
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []
|
|
70
|
+
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
|
|
71
|
+
epoch: nil) = super
|
|
67
72
|
|
|
68
|
-
# Written whole or not at all; input_format only when
|
|
73
|
+
# Written whole or not at all; input_format and epoch only when they
|
|
74
|
+
# name one.
|
|
69
75
|
def write(session_dir)
|
|
70
76
|
FileUtils.mkdir_p(session_dir)
|
|
71
77
|
record = to_h.transform_keys(&:to_s)
|
|
72
78
|
record.delete("input_format") if input_format.nil?
|
|
79
|
+
record.delete("epoch") if epoch.nil?
|
|
73
80
|
AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
|
|
74
81
|
end
|
|
75
82
|
end
|
|
@@ -39,24 +39,53 @@ module Samagotchi
|
|
|
39
39
|
LIVE = "live"
|
|
40
40
|
STALE = "stale"
|
|
41
41
|
FORGOTTEN = "forgotten"
|
|
42
|
+
# Forgotten, every output of it by the user's drop (WorkingSetSteer).
|
|
43
|
+
DROPPED = "dropped"
|
|
42
44
|
|
|
43
45
|
# One thing the model touched: +kind+ file|memory|url; +key+ a file's
|
|
44
46
|
# absolute path (FileRef), a memory's name, a URL (whitespace
|
|
45
|
-
# collapsed); +state+ live|stale|forgotten; +tokens+ its content
|
|
47
|
+
# collapsed); +state+ live|stale|forgotten|dropped; +tokens+ its content
|
|
46
48
|
# outputs as sent; +ops+ read|edit for a file, read|write for a memory,
|
|
47
49
|
# read for a URL, in first-seen order; +count+ the runs that touched
|
|
48
|
-
# it; +first_id+ the first one's chi id (ToolIds)
|
|
49
|
-
|
|
50
|
+
# it; +first_id+ the first one's chi id (ToolIds); +pinned+ whether
|
|
51
|
+
# the user pinned it (WorkingSetPin: the model's forget_outputs refuses
|
|
52
|
+
# its outputs), whatever the strategy; +has_drop+ whether any of its
|
|
53
|
+
# content outputs holds the user's applied drop (an undrop has
|
|
54
|
+
# something to take back: a forgotten item too, its other outputs
|
|
55
|
+
# stubbed by the model's layers).
|
|
56
|
+
Item = Data.define(:kind, :key, :state, :tokens, :ops, :count, :first_id, :pinned, :has_drop)
|
|
57
|
+
|
|
58
|
+
# An item the user added (/working-set add) that the next turn's note
|
|
59
|
+
# asks the model to read: its identity (+kind+, +key+, as an Item's)
|
|
60
|
+
# and how the note names it (+label+: a file relative to the session's
|
|
61
|
+
# folder, "memory NAME", a URL).
|
|
62
|
+
Add = Data.define(:kind, :key, :label)
|
|
50
63
|
|
|
51
64
|
# A projection and the conversation's size (entries) it was built
|
|
52
65
|
# from: the web keeps the newer of an event's and a GET's by it.
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
# +adds+: the pending note's adds (#pending_adds), not items yet.
|
|
67
|
+
Snapshot = Data.define(:items, :at, :adds) do
|
|
68
|
+
def initialize(items:, at:, adds: [])
|
|
69
|
+
super
|
|
70
|
+
end
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
72
|
+
def empty? = items.empty? && adds.empty?
|
|
73
|
+
|
|
74
|
+
# The JSON the event and the route carry: {items: [...], at:}, and
|
|
75
|
+
# adds: [{kind, key, label}] while any is pending.
|
|
76
|
+
def to_h
|
|
77
|
+
hash = { items: items.map(&:to_h), at: at }
|
|
78
|
+
hash[:adds] = adds.map(&:to_h) unless adds.empty?
|
|
79
|
+
hash
|
|
80
|
+
end
|
|
58
81
|
end
|
|
59
82
|
|
|
83
|
+
# The turn-start note (WorkingSetSteer#record!): a tail system message
|
|
84
|
+
# of this kind, its ops ([{"op", "kind", "key", "label"}]) in NOTE_OPS.
|
|
85
|
+
NOTE_KIND = "working_set"
|
|
86
|
+
NOTE_OPS = :working_set_ops
|
|
87
|
+
ADD = "add"
|
|
88
|
+
|
|
60
89
|
FILE_OPS = { "read" => "read", "edit" => "edit", "write" => "edit" }.freeze
|
|
61
90
|
CONTENT = %w[read memory_read web_fetch].freeze
|
|
62
91
|
MEMORY_READ = Tools::MemoryRead::NAME
|
|
@@ -81,21 +110,95 @@ module Samagotchi
|
|
|
81
110
|
# @param strategy [Symbol, Array<Symbol>] :none or the layers, as
|
|
82
111
|
# LLMContextView takes it (the session's current one)
|
|
83
112
|
# @param stat [#call] File.stat, or a stand-in (specs)
|
|
113
|
+
# @param pins [Array<WorkingSetPin>] the session's pins
|
|
84
114
|
# @return [Snapshot]
|
|
85
|
-
def build(conversation, cwd:, strategy:, stat: File.method(:stat))
|
|
115
|
+
def build(conversation, cwd:, strategy:, stat: File.method(:stat), pins: [])
|
|
116
|
+
conversation = Array(conversation)
|
|
117
|
+
view = LLMContextView.new(strategy: strategy)
|
|
118
|
+
pinned = pins.to_set { |pin| [pin.kind, pin.key] }
|
|
119
|
+
items = all_touches(conversation, cwd, view).group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
|
|
120
|
+
item(kind, key, group, conversation, view, stat, pinned.include?([kind, key]))
|
|
121
|
+
end
|
|
122
|
+
Snapshot.new(items: items, at: conversation.size, adds: pending_adds(conversation))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The index of the note the next turn will send (one after the last
|
|
126
|
+
# turn's entries: only system messages follow it), or nil.
|
|
127
|
+
def pending_note_index(messages)
|
|
128
|
+
(messages.size - 1).downto(0) do |index|
|
|
129
|
+
message = messages[index]
|
|
130
|
+
return nil unless message.is_a?(Hash) && message[:role].to_s == "system"
|
|
131
|
+
return index if message[:kind].to_s == NOTE_KIND
|
|
132
|
+
end
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The ops the pending note lists ([{"op", "kind", "key", "label"}]).
|
|
137
|
+
def pending_ops(messages)
|
|
138
|
+
messages = Array(messages)
|
|
139
|
+
index = pending_note_index(messages)
|
|
140
|
+
index ? Array(messages[index][NOTE_OPS]).select { |op| op.is_a?(Hash) } : []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The items the user added since the last turn, which the next turn's
|
|
144
|
+
# note asks the model to read.
|
|
145
|
+
# @return [Array<Add>]
|
|
146
|
+
def pending_adds(messages)
|
|
147
|
+
pending_ops(messages).select { |op| op["op"] == ADD }.map do |op|
|
|
148
|
+
Add.new(kind: op["kind"].to_s, key: op["key"].to_s, label: op["label"].to_s)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# One content output (read, memory_read, web_fetch) of an item: its
|
|
153
|
+
# chi +id+ (ToolIds), the +index+ of the tool_response entry holding
|
|
154
|
+
# it, whether the id is +derived+ (a legacy entry's: nothing can name
|
|
155
|
+
# it) and whether the model is still +sent+ it under the strategy.
|
|
156
|
+
# +shared_with+: the other names a memory_read of several names holds in the
|
|
157
|
+
# same output (an op on the output acts on them too), else empty.
|
|
158
|
+
Output = Data.define(:id, :index, :derived, :sent, :shared_with)
|
|
159
|
+
|
|
160
|
+
# The content outputs of the item +kind+/+key+ (#build's identity: a
|
|
161
|
+
# file's absolute path, a memory's name, a URL), in conversation
|
|
162
|
+
# order, by the same pairing #build uses, so what acts on an item's
|
|
163
|
+
# outputs and the projection never disagree. Edit-only items have
|
|
164
|
+
# none. A memory_read of several names is one output of each.
|
|
165
|
+
# @return [Array<Output>]
|
|
166
|
+
def outputs_of(conversation, kind:, key:, strategy:, cwd:)
|
|
86
167
|
conversation = Array(conversation)
|
|
87
168
|
view = LLMContextView.new(strategy: strategy)
|
|
169
|
+
all_touches(conversation, cwd, view).filter_map do |touch|
|
|
170
|
+
next unless touch.content && touch.kind == kind && touch.key == key
|
|
171
|
+
|
|
172
|
+
ref = touch.run.ref
|
|
173
|
+
Output.new(id: ref.id, index: touch.run.index, derived: ref.derived?,
|
|
174
|
+
sent: still_sent?(touch, conversation, view), shared_with: shared_with(touch, key))
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The ids of the content outputs of the items +pins+ name (the ones
|
|
179
|
+
# the model's forget_outputs refuses), by the same pairing as #build.
|
|
180
|
+
# @param pins [Array<WorkingSetPin>]
|
|
181
|
+
# @return [Set<String>]
|
|
182
|
+
def pinned_ids(conversation, pins:, cwd:)
|
|
183
|
+
return Set.new if pins.empty?
|
|
184
|
+
|
|
185
|
+
conversation = Array(conversation)
|
|
186
|
+
pinned = pins.to_set { |pin| [pin.kind, pin.key] }
|
|
187
|
+
touches = all_touches(conversation, cwd, LLMContextView.new(strategy: LLMContextView::NONE))
|
|
188
|
+
touches.select { |touch| touch.content && pinned.include?([touch.kind, touch.key]) }.to_set { |touch| touch.run.ref.id }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Every paired run's touches, with what +view+ sends of its output.
|
|
192
|
+
def all_touches(conversation, cwd, view)
|
|
88
193
|
stored = LLMContextStale.paired_runs(conversation)
|
|
89
|
-
|
|
194
|
+
# Under none with no drop the view sends the conversation as it is.
|
|
195
|
+
messages = view.messages(conversation)
|
|
196
|
+
sent = if messages.equal?(conversation)
|
|
90
197
|
{}
|
|
91
198
|
else
|
|
92
|
-
LLMContextStale.paired_runs(
|
|
199
|
+
LLMContextStale.paired_runs(messages).to_h { |run| [run.ref.id, run.text] }
|
|
93
200
|
end
|
|
94
|
-
|
|
95
|
-
items = touches.group_by { |touch| [touch.kind, touch.key] }.map do |(kind, key), group|
|
|
96
|
-
item(kind, key, group, conversation, view, stat)
|
|
97
|
-
end
|
|
98
|
-
Snapshot.new(items: items, at: conversation.size)
|
|
201
|
+
stored.flat_map { |run| touches(run, sent.fetch(run.ref.id, run.text), cwd) }
|
|
99
202
|
end
|
|
100
203
|
|
|
101
204
|
# The items +run+ touched (none when it failed or was interrupted).
|
|
@@ -127,6 +230,9 @@ module Samagotchi
|
|
|
127
230
|
ToolResponse.interrupted_text?(text) || text.sub(ToolResponse::LEAD, "").lstrip.start_with?("Error")
|
|
128
231
|
end
|
|
129
232
|
|
|
233
|
+
# The other names +touch+'s output holds (a memory_read of several).
|
|
234
|
+
def shared_with(touch, key) = touch.share > 1 ? found_names(touch.run) - [key] : []
|
|
235
|
+
|
|
130
236
|
# A memory_read's names (Tools::MemoryRead.parse_names: none for the
|
|
131
237
|
# index listing), less the ones its output says weren't found.
|
|
132
238
|
def found_names(run)
|
|
@@ -136,19 +242,19 @@ module Samagotchi
|
|
|
136
242
|
names - missing
|
|
137
243
|
end
|
|
138
244
|
|
|
139
|
-
def item(kind, key, group, conversation, view, stat)
|
|
245
|
+
def item(kind, key, group, conversation, view, stat, pinned)
|
|
140
246
|
first = group.first
|
|
141
247
|
contents = group.select(&:content)
|
|
142
248
|
tokens = contents.sum { |touch| touch.sent.length / TokenUsage::CHARS_PER_TOKEN / touch.share }.ceil
|
|
143
249
|
state = if contents.any? && contents.none? { |touch| still_sent?(touch, conversation, view) }
|
|
144
|
-
FORGOTTEN
|
|
250
|
+
contents.all? { |touch| dropped?(touch, conversation) } ? DROPPED : FORGOTTEN
|
|
145
251
|
elsif kind == FILE && changed?(key, group.last.run, conversation, stat)
|
|
146
252
|
STALE
|
|
147
253
|
else
|
|
148
254
|
LIVE
|
|
149
255
|
end
|
|
150
256
|
Item.new(kind: kind, key: key, state: state, tokens: tokens, ops: group.map(&:op).uniq, count: group.size,
|
|
151
|
-
first_id: first.run.ref.id)
|
|
257
|
+
first_id: first.run.ref.id, pinned: pinned, has_drop: contents.any? { |touch| dropped?(touch, conversation) })
|
|
152
258
|
end
|
|
153
259
|
|
|
154
260
|
# Whether the model is still sent +touch+'s output: whole (with the
|
|
@@ -161,11 +267,17 @@ module Samagotchi
|
|
|
161
267
|
return true if sent == stored || sent == with_id(stored, touch.run.ref.id)
|
|
162
268
|
|
|
163
269
|
edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
|
|
164
|
-
return true unless edit
|
|
270
|
+
return true unless edit && view.sends?(edit)
|
|
165
271
|
|
|
166
272
|
edit.kind == :forget ? edit.keep? : edit.note.match?(BY_EDIT)
|
|
167
273
|
end
|
|
168
274
|
|
|
275
|
+
# Whether +touch+'s output holds the user's applied drop.
|
|
276
|
+
def dropped?(touch, conversation)
|
|
277
|
+
edit = LLMContextEdit.on(conversation[touch.run.index])[touch.run.ref.id]
|
|
278
|
+
!edit.nil? && edit.kind == LLMContextEdit::DROP && edit.applied?
|
|
279
|
+
end
|
|
280
|
+
|
|
169
281
|
def with_id(text, id)
|
|
170
282
|
match = text.match(ToolResponse::LEAD)
|
|
171
283
|
match ? "#{match[0]}[##{id}] #{match.post_match}" : nil
|
|
@@ -195,6 +307,6 @@ module Samagotchi
|
|
|
195
307
|
|
|
196
308
|
def field(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
197
309
|
|
|
198
|
-
private_class_method :touches, :failed?, :found_names, :item, :still_sent?, :with_id, :changed?, :stamp_of, :field
|
|
310
|
+
private_class_method :all_touches, :touches, :failed?, :found_names, :shared_with, :item, :still_sent?, :dropped?, :with_id, :changed?, :stamp_of, :field
|
|
199
311
|
end
|
|
200
312
|
end
|