samagotchi 0.42.0 → 0.44.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 +47 -1
- data/docs/configuration.md +26 -0
- data/docs/context.md +46 -0
- data/docs/internals/context-telemetry.md +4 -1
- data/docs/internals/engine.md +1 -0
- data/docs/plugins.md +27 -5
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/card_store.rb +20 -9
- data/lib/samagotchi/bridge/turn_notice.rb +3 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
- data/lib/samagotchi/bundles/github-pr/plugin.rb +177 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_sources.rb +40 -0
- data/lib/samagotchi/context_status.rb +18 -5
- data/lib/samagotchi/kernel_loop.rb +24 -9
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/llm_context_forget.rb +6 -2
- data/lib/samagotchi/llm_context_notice.rb +96 -0
- data/lib/samagotchi/plugin/attached_context.rb +75 -11
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/tool_declarations.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +47 -7
- data/lib/samagotchi/web/message_parts.rb +90 -11
- data/lib/samagotchi/web/public/app.js +80 -13
- data/lib/samagotchi/web/public/ctx.js +27 -2
- data/lib/samagotchi/web/public/data.js +6 -3
- data/lib/samagotchi/web/public/index.html +12 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +151 -21
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/model_picker.js +2 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/turn_events.js +34 -0
- data/lib/samagotchi/web/public/turn_html.js +33 -2
- data/lib/samagotchi/web/public/turn_view.js +4 -2
- metadata +2 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
|
+
require "time"
|
|
6
7
|
require "tmpdir"
|
|
7
8
|
|
|
8
9
|
# github-pr: a session on a branch with an open GitHub PR gets the PR
|
|
@@ -24,15 +25,31 @@ require "tmpdir"
|
|
|
24
25
|
# done); before_turn, before_generation and the init task fill the cache
|
|
25
26
|
# off the turn's thread. `bundles: github-pr: line_links: false` turns it
|
|
26
27
|
# off.
|
|
28
|
+
#
|
|
29
|
+
# `bundles: github-pr: auto_attach:` says what the init task does with the
|
|
30
|
+
# branch's open PR: `attach` (the default), `offer` (a card with Attach and
|
|
31
|
+
# Not here, which run /pr-attach N and /pr-decline N; nothing attached until
|
|
32
|
+
# a click; offered once per session) or `off` (nothing). In offer mode the
|
|
33
|
+
# offers, their outcomes and each session's first prompt go to
|
|
34
|
+
# <data_dir>/offers.ndjson (OffersLog), to find heuristics later.
|
|
27
35
|
class Plugin
|
|
28
36
|
# gh and git each get this long (a network hang must not keep a thread).
|
|
29
37
|
COMMAND_SECONDS = 20
|
|
30
38
|
# before_generation looks for a newly attached PR at most this often.
|
|
31
39
|
GENERATION_CHECK_SECONDS = 30
|
|
40
|
+
# auto_attach's values (YAML's bare true/false: attach/off).
|
|
41
|
+
AUTO_ATTACH_MODES = { "attach" => :attach, "offer" => :offer, "off" => :off, true => :attach, false => :off }.freeze
|
|
42
|
+
# /pr-attach and /pr-decline take a PR number (`42` or `#42`).
|
|
43
|
+
PR_NUMBER = /\A#?(\d+)\z/
|
|
44
|
+
# The offer card's line under the PR's title.
|
|
45
|
+
OFFER_LINE = "Not attached: the agent gets nothing until you attach it."
|
|
46
|
+
# first_prompt keeps this much of the prompt.
|
|
47
|
+
FIRST_PROMPT_CHARS = 160
|
|
32
48
|
|
|
33
49
|
def initialize
|
|
34
50
|
@cache = PrCache.new
|
|
35
51
|
@generation_checked_at = nil
|
|
52
|
+
@warned_mode = false
|
|
36
53
|
end
|
|
37
54
|
|
|
38
55
|
def register(chi)
|
|
@@ -48,6 +65,13 @@ class Plugin
|
|
|
48
65
|
# The prompt too: messages is the history before this turn, and a
|
|
49
66
|
# delegate child's task is its first prompt.
|
|
50
67
|
kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
|
|
68
|
+
log_first_prompt(event, ctx)
|
|
69
|
+
end
|
|
70
|
+
chi.command("/pr-attach", "Attach the PR this session was offered: /pr-attach 42", anytime: true) do |args, ctx|
|
|
71
|
+
offer_command("/pr-attach", args, ctx) { |number, offer| attach_offered(ctx, number, offer) }
|
|
72
|
+
end
|
|
73
|
+
chi.command("/pr-decline", "Don't attach the PR this session was offered: /pr-decline 42", anytime: true) do |args, ctx|
|
|
74
|
+
offer_command("/pr-decline", args, ctx) { |number, offer| decline_offered(ctx, number, offer) }
|
|
51
75
|
end
|
|
52
76
|
chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
|
|
53
77
|
# After source-links (90): its links are in the display this one gets.
|
|
@@ -60,15 +84,19 @@ class Plugin
|
|
|
60
84
|
return "skipped: a delegate child" if ctx.delegate?
|
|
61
85
|
return "no session yet" unless ctx.session_id
|
|
62
86
|
|
|
87
|
+
mode = auto_attach_mode(ctx)
|
|
88
|
+
return "auto_attach: off" if mode == :off
|
|
89
|
+
|
|
63
90
|
branch = run(ctx, "git", "branch", "--show-current").to_s.strip
|
|
64
91
|
return "not on a branch" if branch.empty?
|
|
65
92
|
|
|
66
|
-
json = run(ctx, "gh", "pr", "view", "--json", "number,url,state") or return "no pull request for #{branch}"
|
|
93
|
+
json = run(ctx, "gh", "pr", "view", "--json", "number,url,state,title") or return "no pull request for #{branch}"
|
|
67
94
|
pr = JSON.parse(json)
|
|
68
95
|
return "pull request ##{pr["number"]} isn't open" unless pr["state"] == "OPEN"
|
|
69
96
|
|
|
70
97
|
name = "pr-#{pr["number"]}"
|
|
71
98
|
return "#{name} is attached already" if ctx.context.list.any? { |source| source[:name] == name || source[:hint] == pr["url"] }
|
|
99
|
+
return offer_branch_pr(ctx, branch, pr, name) if mode == :offer
|
|
72
100
|
|
|
73
101
|
attached = ctx.context.attach(url: pr["url"], name: name, why: "branch #{branch} has open PR ##{pr["number"]}")
|
|
74
102
|
attached ? "attached #{name}" : "#{name} was removed from this session; not attached again"
|
|
@@ -77,6 +105,21 @@ class Plugin
|
|
|
77
105
|
"no pull request attached"
|
|
78
106
|
end
|
|
79
107
|
|
|
108
|
+
# `bundles: github-pr: auto_attach:`, read at each worker start.
|
|
109
|
+
# @return [Symbol] :attach, :offer or :off; an unknown value is :attach
|
|
110
|
+
# (logged once)
|
|
111
|
+
def auto_attach_mode(ctx)
|
|
112
|
+
value = ctx.settings["auto_attach"]
|
|
113
|
+
return :attach if value.nil?
|
|
114
|
+
|
|
115
|
+
key = value.is_a?(String) ? value.strip.downcase : value
|
|
116
|
+
AUTO_ATTACH_MODES.fetch(key) do
|
|
117
|
+
ctx.log.warn(:pr_auto_attach_unknown, value: value.to_s[0, 40]) unless @warned_mode
|
|
118
|
+
@warned_mode = true
|
|
119
|
+
:attach
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
80
123
|
# The after_turn handler: links the answer's `path:line` refs from the
|
|
81
124
|
# PRs' cached data. Never runs gh: nothing cached yet links nothing.
|
|
82
125
|
def link_answer(event, ctx)
|
|
@@ -109,6 +152,106 @@ class Plugin
|
|
|
109
152
|
|
|
110
153
|
def line_links?(ctx) = ctx.settings["line_links"] != false
|
|
111
154
|
|
|
155
|
+
def offer_card_id(number) = "github-pr-offer-#{number}"
|
|
156
|
+
|
|
157
|
+
# Offer mode: a card instead of the attach, once per session per PR (a
|
|
158
|
+
# worker restart finds the offered marker). The marker is written after
|
|
159
|
+
# the card, so a card that failed leaves none.
|
|
160
|
+
def offer_branch_pr(ctx, branch, pr, name)
|
|
161
|
+
return "#{name} was removed from this session; not offered" if ctx.context.declined?(name, url: pr["url"])
|
|
162
|
+
return "#{name} was offered already" if ctx.context.offered(name)
|
|
163
|
+
|
|
164
|
+
number = pr["number"]
|
|
165
|
+
body = [pr["title"].to_s.gsub(/\s+/, " ").strip, OFFER_LINE].reject(&:empty?).join("\n\n")
|
|
166
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number} for branch #{branch}", body: body, level: :info,
|
|
167
|
+
actions: [{ label: "Attach", command: "/pr-attach #{number}" },
|
|
168
|
+
{ label: "Not here", command: "/pr-decline #{number}" }])
|
|
169
|
+
ctx.context.mark_offered(name, pr["url"], why: "branch #{branch} has open PR ##{number}")
|
|
170
|
+
offers_log(ctx).write("offered", session: ctx.session_id, project_root: project_root(ctx), cwd: ctx.cwd,
|
|
171
|
+
branch: branch, pr: pr["url"], checkout: checkout_kind(ctx), model: ctx.model)
|
|
172
|
+
"offered #{name}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# /pr-attach and /pr-decline: the PR number and its offer, else the
|
|
176
|
+
# answer (a usage line, no offer).
|
|
177
|
+
def offer_command(command, args, ctx)
|
|
178
|
+
number = args.to_s.strip[PR_NUMBER, 1] or return "usage: #{command} <PR number>, as the offer card names it"
|
|
179
|
+
number = number.to_i
|
|
180
|
+
offer = ctx.context.offered("pr-#{number}")
|
|
181
|
+
return "no offered PR ##{number} here; use `chi context add <PR URL>`" unless offer
|
|
182
|
+
|
|
183
|
+
yield number, offer
|
|
184
|
+
rescue Samagotchi::Plugin::AttachedContext::Error => e
|
|
185
|
+
"pr-#{number} not changed: #{e.message}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The user's click: attached as auto-attach would, past a decline, with
|
|
189
|
+
# the offer's why (no git or gh here).
|
|
190
|
+
def attach_offered(ctx, number, offer)
|
|
191
|
+
why = "#{offer.why || "open PR ##{number}"} (attached from the offer)"
|
|
192
|
+
attached = ctx.context.attach(url: offer.hint, name: offer.name, why: why, force: true)
|
|
193
|
+
return "#{offer.name} wasn't attached" unless attached
|
|
194
|
+
|
|
195
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Attached #{attached}")
|
|
196
|
+
offers_log(ctx).write("attached", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Not for a PR attached here already (a stale card): that is
|
|
201
|
+
# `chi context rm`'s, and a declined marker would leave it attached.
|
|
202
|
+
def decline_offered(ctx, number, offer)
|
|
203
|
+
attached = ctx.context.list.find { |source| source[:name] == offer.name || source[:hint] == offer.hint }
|
|
204
|
+
return "#{attached[:name]} is attached here; `chi context rm #{attached[:name]}` removes it" if attached
|
|
205
|
+
|
|
206
|
+
ctx.context.decline(url: offer.hint, name: offer.name)
|
|
207
|
+
ctx.card(id: offer_card_id(number), title: "PR ##{number}", body: "Not attached here; + URL attaches it")
|
|
208
|
+
offers_log(ctx).write("declined", session: ctx.session_id, pr: offer.hint, seconds: seconds_since(offer))
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def seconds_since(offer)
|
|
213
|
+
(Time.now - Time.iso8601(offer.at.to_s)).round
|
|
214
|
+
rescue ArgumentError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Offer mode: every session's first prompt (offered or not; a web chat's
|
|
219
|
+
# first turn starts before the init task's gh returns), joined to the
|
|
220
|
+
# offers by session at analysis time. First = no user message yet (a
|
|
221
|
+
# context note may be in messages already).
|
|
222
|
+
def log_first_prompt(event, ctx)
|
|
223
|
+
return if event[:prompt].nil? || auto_attach_mode(ctx) != :offer
|
|
224
|
+
return if Array(event[:messages]).any? { |message| message_role(message) == "user" }
|
|
225
|
+
return if ctx.scratch? || ctx.delegate? || ctx.session_id.nil?
|
|
226
|
+
|
|
227
|
+
offers_log(ctx).write("first_prompt", session: ctx.session_id, prompt: event[:prompt].to_s[0, FIRST_PROMPT_CHARS])
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
ctx.log.debug(:pr_offers_log_failed, error: e.class.name)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def message_role(message)
|
|
233
|
+
return nil unless message.is_a?(Hash)
|
|
234
|
+
|
|
235
|
+
(message.key?(:role) ? message[:role] : message["role"]).to_s
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def offers_log(ctx) = OffersLog.new(File.join(ctx.data_dir, OffersLog::FILE), ctx.log)
|
|
239
|
+
|
|
240
|
+
def project_root(ctx)
|
|
241
|
+
ctx.repo_root
|
|
242
|
+
rescue StandardError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# "main" for the main checkout, "worktree" for a linked one (its git dir
|
|
247
|
+
# isn't the common one), nil outside a repo.
|
|
248
|
+
def checkout_kind(ctx)
|
|
249
|
+
dirs = run(ctx, "git", "rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").to_s.split("\n")
|
|
250
|
+
return nil unless dirs.size == 2
|
|
251
|
+
|
|
252
|
+
dirs[0] == dirs[1] ? "main" : "worktree"
|
|
253
|
+
end
|
|
254
|
+
|
|
112
255
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
256
|
|
|
114
257
|
# #refresh on its own thread (a raw one, docs/plugins.md), when there is
|
|
@@ -191,6 +334,39 @@ class Plugin
|
|
|
191
334
|
end
|
|
192
335
|
end
|
|
193
336
|
|
|
337
|
+
# <data_dir>/offers.ndjson: one JSON object a line, {event:, ts:, …},
|
|
338
|
+
# private to the user (0600: it holds prompts). Every worker shares it, so
|
|
339
|
+
# each write holds offers.ndjson.lock (flock) around the size check, the
|
|
340
|
+
# rotation and the append; over MAX_BYTES the file becomes
|
|
341
|
+
# offers.ndjson.1 (one old file kept). A failure is logged at debug: the
|
|
342
|
+
# log never breaks an attach.
|
|
343
|
+
class OffersLog
|
|
344
|
+
FILE = "offers.ndjson"
|
|
345
|
+
MAX_BYTES = 1024 * 1024
|
|
346
|
+
LINE_MAX_BYTES = 4000
|
|
347
|
+
MODE = 0o600
|
|
348
|
+
|
|
349
|
+
def initialize(path, log)
|
|
350
|
+
@path = path
|
|
351
|
+
@log = log
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def write(event, **fields)
|
|
355
|
+
line = "#{JSON.generate({ event: event, ts: Time.now.utc.iso8601 }.merge(fields))}\n"
|
|
356
|
+
return @log.debug(:pr_offers_line_too_long, event: event) if line.bytesize > LINE_MAX_BYTES
|
|
357
|
+
|
|
358
|
+
File.open("#{@path}.lock", File::RDWR | File::CREAT, MODE) do |lock|
|
|
359
|
+
lock.flock(File::LOCK_EX)
|
|
360
|
+
File.rename(@path, "#{@path}.1") if File.size?(@path).to_i > MAX_BYTES
|
|
361
|
+
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, MODE) { |file| file.write(line) }
|
|
362
|
+
end
|
|
363
|
+
nil
|
|
364
|
+
rescue StandardError => e
|
|
365
|
+
@log.debug(:pr_offers_log_failed, error: e.class.name)
|
|
366
|
+
nil
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
194
370
|
# A GitHub pull request, by its URL.
|
|
195
371
|
PrRef = Data.define(:owner, :repo, :number)
|
|
196
372
|
|
|
@@ -22,6 +22,7 @@ module Samagotchi
|
|
|
22
22
|
# sessions/<id>/subscriptions.json {name => Subscription}; the worker writes it
|
|
23
23
|
# sessions/<id>/muted/<name> a marker: this session ignores <name>
|
|
24
24
|
# sessions/<id>/declined/<name> a marker (its hint inside): the user removed <name>
|
|
25
|
+
# sessions/<id>/offered/<name> a marker ({hint, why, at}): a plugin offered <name> (ctx.context.mark_offered)
|
|
25
26
|
# The guardrails protect the root (ProtectedPaths, CHI_TEXT): a source's
|
|
26
27
|
# command runs later, outside the gate.
|
|
27
28
|
module ContextSources
|
|
@@ -39,6 +40,7 @@ module Samagotchi
|
|
|
39
40
|
SUBSCRIPTIONS_FILE = "subscriptions.json"
|
|
40
41
|
MUTED_DIR = "muted"
|
|
41
42
|
DECLINED_DIR = "declined"
|
|
43
|
+
OFFERED_DIR = "offered"
|
|
42
44
|
|
|
43
45
|
# A name, a command, a value the store can't take.
|
|
44
46
|
class Invalid < ArgumentError; end
|
|
@@ -131,6 +133,11 @@ module Samagotchi
|
|
|
131
133
|
# What a command printed or a push sent (the contract, ContextSources.parse_output).
|
|
132
134
|
Fetched = Data.define(:text, :summary, :wake, :hint)
|
|
133
135
|
|
|
136
|
+
# A plugin's offer of a source to a session (ctx.context.mark_offered):
|
|
137
|
+
# +hint+ (often the URL), +why+ (the plugin's reason, for the attach
|
|
138
|
+
# that may follow; nil for none) and +at+, when it was offered (ISO 8601).
|
|
139
|
+
Offer = Data.define(:name, :hint, :why, :at)
|
|
140
|
+
|
|
134
141
|
# A source as one session sees it: the definition, where it lives, and
|
|
135
142
|
# +shadowed+ for a project source hidden by a session one of its name.
|
|
136
143
|
Attached = Data.define(:source, :location, :shadowed) do
|
|
@@ -273,6 +280,39 @@ module Samagotchi
|
|
|
273
280
|
AtomicFile.write(declined_path(name), "#{hint}\n")
|
|
274
281
|
end
|
|
275
282
|
|
|
283
|
+
# Clears the declined marker of +name+ and any whose hint is +hint+
|
|
284
|
+
# (the user's explicit attach: a URL declined under another name).
|
|
285
|
+
def undecline(name, hint: nil)
|
|
286
|
+
FileUtils.rm_f(declined_path(name))
|
|
287
|
+
folder = File.join(dir, DECLINED_DIR)
|
|
288
|
+
return if hint.to_s.empty? || !Dir.exist?(folder)
|
|
289
|
+
|
|
290
|
+
Dir.children(folder).each do |file|
|
|
291
|
+
path = File.join(folder, file)
|
|
292
|
+
FileUtils.rm_f(path) if File.read(path).strip == hint
|
|
293
|
+
rescue SystemCallError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def offered_path(name) = File.join(dir, OFFERED_DIR, ContextSources.check_name!(name))
|
|
299
|
+
|
|
300
|
+
# Session only: a plugin offered +name+ here (so it doesn't offer it
|
|
301
|
+
# again). Neither an add nor a decline clears it.
|
|
302
|
+
# @return [Offer]
|
|
303
|
+
def mark_offered(name, hint, why: nil, at: Time.now)
|
|
304
|
+
offer = Offer.new(name: ContextSources.check_name!(name), hint: hint, why: why, at: at.utc.iso8601)
|
|
305
|
+
FileUtils.mkdir_p(File.join(dir, OFFERED_DIR))
|
|
306
|
+
AtomicFile.write(offered_path(name), "#{JSON.generate({ "hint" => offer.hint, "why" => offer.why, "at" => offer.at }.compact)}\n")
|
|
307
|
+
offer
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# @return [Offer, nil]
|
|
311
|
+
def offered(name)
|
|
312
|
+
data = ContextSources.read_json(offered_path(name))
|
|
313
|
+
data.is_a?(Hash) ? Offer.new(name: name, hint: data["hint"], why: data["why"], at: data["at"]) : nil
|
|
314
|
+
end
|
|
315
|
+
|
|
276
316
|
# Session only: the markers and the worker's subscription file.
|
|
277
317
|
def muted?(name) = File.exist?(File.join(dir, MUTED_DIR, ContextSources.check_name!(name)))
|
|
278
318
|
|
|
@@ -63,13 +63,21 @@ module Samagotchi
|
|
|
63
63
|
"context elevated — be concise, prefer range reads, avoid re-reading large files",
|
|
64
64
|
"context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
|
|
65
65
|
].freeze
|
|
66
|
+
# The top level's words when the buckets count against a budget
|
|
67
|
+
# (llm_context.budget_tokens) and no forget layer: nothing in the
|
|
68
|
+
# model's hands shrinks the context, so no "summarize".
|
|
69
|
+
BUDGET_CRITICAL = "context budget critical — avoid large outputs and re-reads, delegate broad work to subagents"
|
|
66
70
|
|
|
67
71
|
# @param bucket [String] "under<N>" or "<N>plus"
|
|
68
72
|
# @param thresholds [Array<Integer>] the configured ones, sorted
|
|
69
|
-
|
|
73
|
+
# @param budget [Boolean] the buckets count against a budget
|
|
74
|
+
def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS, budget: false)
|
|
70
75
|
rank = bucket_rank(bucket, thresholds)
|
|
71
76
|
level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
|
|
72
|
-
|
|
77
|
+
level = level.clamp(0, GUIDANCE.size - 1)
|
|
78
|
+
return BUDGET_CRITICAL if budget && level == GUIDANCE.size - 1
|
|
79
|
+
|
|
80
|
+
GUIDANCE[level]
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
# 0 for the bucket under the first threshold, then one per threshold.
|
|
@@ -331,11 +339,16 @@ module Samagotchi
|
|
|
331
339
|
|
|
332
340
|
def k(tokens) = tokens >= 1000 ? "#{(tokens / 1000.0).round}k" : tokens.to_s
|
|
333
341
|
|
|
342
|
+
# The buckets count against the budget (set, and not over the window).
|
|
343
|
+
def budget_counted?(usage) = !@budget.nil? && usage[:window_tokens] == @budget
|
|
344
|
+
|
|
334
345
|
def guidance_message(usage:, bucket:)
|
|
335
346
|
how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
|
|
347
|
+
budget = budget_counted?(usage)
|
|
348
|
+
of = budget ? "the context budget (#{k(@budget)} tokens)" : "the context window"
|
|
336
349
|
{ role: "system", kind: LINE_KIND,
|
|
337
|
-
content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of
|
|
338
|
-
"(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
|
|
350
|
+
content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of #{of} is in use " \
|
|
351
|
+
"(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds, budget: budget)}]" }
|
|
339
352
|
end
|
|
340
353
|
|
|
341
354
|
def status_message(usage:, bucket:)
|
|
@@ -350,7 +363,7 @@ module Samagotchi
|
|
|
350
363
|
bucket: bucket,
|
|
351
364
|
thresholds: @thresholds.join(","),
|
|
352
365
|
src: usage[:source],
|
|
353
|
-
guidance: self.class.guidance(bucket, @thresholds)
|
|
366
|
+
guidance: self.class.guidance(bucket, @thresholds, budget: budget_counted?(usage))
|
|
354
367
|
)
|
|
355
368
|
end
|
|
356
369
|
end
|
|
@@ -28,6 +28,7 @@ require_relative "tool_ids"
|
|
|
28
28
|
require_relative "llm_context_view"
|
|
29
29
|
require_relative "llm_context_apply"
|
|
30
30
|
require_relative "llm_context_forget"
|
|
31
|
+
require_relative "llm_context_notice"
|
|
31
32
|
require_relative "answer_display"
|
|
32
33
|
require_relative "llm/model_result"
|
|
33
34
|
require_relative "llm/turn_settings"
|
|
@@ -279,7 +280,8 @@ module Samagotchi
|
|
|
279
280
|
def start_turn(messages, on_stream_event:, cancel_controller:, pending_input:, cap:)
|
|
280
281
|
conversation = prepare_conversation(messages)
|
|
281
282
|
context = ContextStatus.new(conversation: conversation, llm_context: @turn_settings.llm_context)
|
|
282
|
-
|
|
283
|
+
emit = ->(event) { emit_stream_event(on_stream_event, event) }
|
|
284
|
+
@llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: context, emit: emit)
|
|
283
285
|
Turn.new(
|
|
284
286
|
conversation: conversation, context: context,
|
|
285
287
|
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
@@ -290,7 +292,7 @@ module Samagotchi
|
|
|
290
292
|
prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
|
|
291
293
|
pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
|
|
292
294
|
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
293
|
-
emit:
|
|
295
|
+
emit: emit
|
|
294
296
|
)
|
|
295
297
|
end
|
|
296
298
|
|
|
@@ -321,7 +323,7 @@ module Samagotchi
|
|
|
321
323
|
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
322
324
|
# with it.
|
|
323
325
|
def prepare_request(turn)
|
|
324
|
-
apply_llm_context!(turn.conversation, context: turn.context)
|
|
326
|
+
apply_llm_context!(turn.conversation, context: turn.context, emit: turn.emit)
|
|
325
327
|
prompt, images = format_prompt(turn)
|
|
326
328
|
image_tokens = images.empty? ? 0 : ImagePlan.estimated_tokens(turn.conversation)
|
|
327
329
|
window = ContextWindow.resolve(client: @client, model: turn.model_name, setting: @turn_settings.window_setting)
|
|
@@ -580,7 +582,7 @@ module Samagotchi
|
|
|
580
582
|
# The model answered: the staged edits reach the session now (a turn
|
|
581
583
|
# that ran out of steps, on tool results or on a retry's nudge, is
|
|
582
584
|
# mid-task; they wait).
|
|
583
|
-
apply_llm_context!(turn.conversation, moment: :turn_end) if turn.answered
|
|
585
|
+
apply_llm_context!(turn.conversation, moment: :turn_end, emit: turn.emit) if turn.answered
|
|
584
586
|
# An empty answer left the conversation: an earlier model message
|
|
585
587
|
# isn't this turn's answer.
|
|
586
588
|
output = turn.ended_empty ? "" : strip_thought_blocks(last_model_content(turn.conversation))
|
|
@@ -784,8 +786,8 @@ module Samagotchi
|
|
|
784
786
|
return "Error: forget_outputs is off (the llm_context strategy has no forget layer)"
|
|
785
787
|
end
|
|
786
788
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
+
apply = -> { apply_llm_context!(turn.conversation, llm_context, context: turn.context, emit: turn.emit) }
|
|
790
|
+
LLMContextForget.call(turn, call, llm_context: llm_context, apply: apply)
|
|
789
791
|
end
|
|
790
792
|
|
|
791
793
|
# Before each request of both loops (+moment+ :request; the warm-up
|
|
@@ -797,10 +799,12 @@ module Samagotchi
|
|
|
797
799
|
# first, and the warm-up) is the first with the stubs. +context+: the
|
|
798
800
|
# turn's ContextStatus (payoff applies in its top bucket; an applied
|
|
799
801
|
# batch rebases its estimate, ContextStatus#edited!). none changes
|
|
800
|
-
# nothing. Logged as llm_context_apply when a batch is applied
|
|
802
|
+
# nothing. Logged as llm_context_apply when a batch is applied, and
|
|
803
|
+
# shown to the user as a ✂ row (+emit+ takes the :llm_context_edited
|
|
804
|
+
# event, LLMContextNotice; the warm-up's own copy has none).
|
|
801
805
|
# @return [LLMContextApply::Outcome]
|
|
802
806
|
def apply_llm_context!(conversation, llm_context = @turn_settings&.llm_context, moment: :request, context: nil,
|
|
803
|
-
warmup: false)
|
|
807
|
+
warmup: false, emit: nil)
|
|
804
808
|
view = llm_context_view(llm_context)
|
|
805
809
|
return LLMContextApply::Outcome.none if view.none?
|
|
806
810
|
|
|
@@ -811,7 +815,10 @@ module Samagotchi
|
|
|
811
815
|
if outcome.applied?
|
|
812
816
|
# The prompt just shrank: the context estimate counts from it.
|
|
813
817
|
context&.edited!
|
|
814
|
-
|
|
818
|
+
unless warmup
|
|
819
|
+
log_llm_context(outcome, llm_context, moment)
|
|
820
|
+
emit_llm_context(emit, conversation, outcome, moment) if emit
|
|
821
|
+
end
|
|
815
822
|
end
|
|
816
823
|
outcome
|
|
817
824
|
end
|
|
@@ -825,6 +832,14 @@ module Samagotchi
|
|
|
825
832
|
end
|
|
826
833
|
private :log_llm_context
|
|
827
834
|
|
|
835
|
+
# The batch's ✂ row; a row that can't be built never breaks the turn.
|
|
836
|
+
def emit_llm_context(emit, conversation, outcome, moment)
|
|
837
|
+
emit.call(LLMContextNotice.event(conversation, outcome, moment: moment))
|
|
838
|
+
rescue StandardError => e
|
|
839
|
+
Log.warn(:model, "llm_context_notice_failed", error: e.class.name, message: e.message)
|
|
840
|
+
end
|
|
841
|
+
private :emit_llm_context
|
|
842
|
+
|
|
828
843
|
# A request's re-prefilled tokens, for both loops: of the prompt the
|
|
829
844
|
# last request on +model+ sent (this kernel's, any turn), what the
|
|
830
845
|
# server didn't reuse from its cache this time (that prompt less
|
|
@@ -358,7 +358,8 @@ module Samagotchi
|
|
|
358
358
|
# steps from them (ModelResult#empty_steps).
|
|
359
359
|
@empty_steps = []
|
|
360
360
|
@context = ContextStatus.new(conversation: conversation, llm_context: loop.llm_context)
|
|
361
|
-
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context
|
|
361
|
+
loop.llm_context_turn = LLMContextForget::Turn.new(conversation: conversation, context: @context,
|
|
362
|
+
emit: method(:emit))
|
|
362
363
|
end
|
|
363
364
|
|
|
364
365
|
def call(max_iterations:, cap:)
|
|
@@ -455,7 +456,7 @@ module Samagotchi
|
|
|
455
456
|
def generate(iteration)
|
|
456
457
|
# The strategy's edits the apply rule lets through first (stale):
|
|
457
458
|
# this request sends them.
|
|
458
|
-
@loop.apply_llm_context!(@conversation, context: @context)
|
|
459
|
+
@loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
|
|
459
460
|
window = @window = @loop.context_window(@model_name)
|
|
460
461
|
observe_context(iteration, window)
|
|
461
462
|
retry_generation = @empty_retry.take_sampling!
|
|
@@ -640,7 +641,7 @@ module Samagotchi
|
|
|
640
641
|
def result(text, exhausted:)
|
|
641
642
|
# The model answered: the staged edits reach the session now (a
|
|
642
643
|
# turn that ran out of steps is mid-task; they wait).
|
|
643
|
-
@loop.apply_llm_context!(@conversation, moment: :turn_end) unless exhausted
|
|
644
|
+
@loop.apply_llm_context!(@conversation, moment: :turn_end, emit: method(:emit)) unless exhausted
|
|
644
645
|
ModelResult.new(text: text, conversation: @loop.plain(@conversation), exhausted: exhausted,
|
|
645
646
|
tool_activity: @tool_activity, empty_steps: @empty_steps, empty_retries: @empty_retry.attempts,
|
|
646
647
|
context_status: @context.display)
|
|
@@ -44,8 +44,12 @@ module Samagotchi
|
|
|
44
44
|
SMALL = 80
|
|
45
45
|
|
|
46
46
|
# The turn a forget_outputs call runs in: its conversation (the
|
|
47
|
-
# entries the edits are saved on)
|
|
48
|
-
|
|
47
|
+
# entries the edits are saved on), its ContextStatus (nil: none) and
|
|
48
|
+
# its stream (+emit+, nil: none), which gets an applied batch's ✂ row
|
|
49
|
+
# (LLMContextNotice).
|
|
50
|
+
Turn = Data.define(:conversation, :context, :emit) do
|
|
51
|
+
def initialize(emit: nil, **fields) = super
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
# One output a call may name: its entry, its ToolIds ref, its tool,
|
|
51
55
|
# its text (without the "[name]" lead) and its step; +offset+: what
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_context_stale"
|
|
4
|
+
require_relative "tool_activity"
|
|
5
|
+
require_relative "token_usage"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The ✂ row a turn gets for a batch of LLM context edits the apply rule
|
|
9
|
+
# let through (LLMContextApply::Outcome#applied?): the :llm_context_edited
|
|
10
|
+
# event both loops emit (KernelLoop#apply_llm_context!, its +emit+), at a
|
|
11
|
+
# request (+moment+ :request; a forget_outputs call's batch too) or at the
|
|
12
|
+
# end of a turn (:turn_end); never for the warm-up, a held or a staged
|
|
13
|
+
# batch, or a restore (the forget_outputs row says it). The web and the
|
|
14
|
+
# TUI print +text+ as it is; the web's hover lists +groups+.
|
|
15
|
+
#
|
|
16
|
+
# {type: :llm_context_edited, moment:, why:, freed_tokens:, tail_tokens:,
|
|
17
|
+
# staged:, text: "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k
|
|
18
|
+
# tokens (paid off)", groups: [{kind: "stale", items: [{id, tool, title,
|
|
19
|
+
# note}]}, {kind: "forget", note:, by:, items: [{id, tool, title, kept}]}]}
|
|
20
|
+
#
|
|
21
|
+
# The stale items share one group, each with its own note; a forget group
|
|
22
|
+
# is one forget_outputs call's (the view's key: note, by and staged_at),
|
|
23
|
+
# its note once. Tokens are chars/4, so "~".
|
|
24
|
+
module LLMContextNotice
|
|
25
|
+
TYPE = :llm_context_edited
|
|
26
|
+
# A forget's note on the row, at most (the stub keeps all of it).
|
|
27
|
+
NOTE_MAX = 300
|
|
28
|
+
WHY = { payoff: "paid off", top_bucket: "the context is nearly full", turn_end: "at turn end" }.freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param outcome [LLMContextApply::Outcome] an applied one
|
|
33
|
+
# @param moment [Symbol] :request or :turn_end
|
|
34
|
+
# @return [Hash] the event
|
|
35
|
+
def event(conversation, outcome, moment:, cwd: Dir.pwd)
|
|
36
|
+
calls = LLMContextStale.paired_runs(conversation).to_h { |run| [run.ref.id, run.call] }
|
|
37
|
+
groups = groups(outcome.applied, calls, cwd)
|
|
38
|
+
freed = tokens(outcome.freed_chars)
|
|
39
|
+
{ type: TYPE, moment: moment.to_s, why: outcome.why.to_s, freed_tokens: freed,
|
|
40
|
+
tail_tokens: tokens(outcome.tail_chars), staged: outcome.staged,
|
|
41
|
+
text: text(groups, freed, outcome.why, outcome.staged), groups: groups }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def groups(edits, calls, cwd)
|
|
45
|
+
stale = edits.select { |edit| edit.kind == :stale }
|
|
46
|
+
forgets = edits.select { |edit| edit.kind == :forget }.group_by { |edit| [edit.note, edit.by, edit.staged_at] }
|
|
47
|
+
groups = []
|
|
48
|
+
unless stale.empty?
|
|
49
|
+
groups << { kind: "stale", items: stale.map { |edit| item(edit, calls, cwd).merge(note: edit.note) } }
|
|
50
|
+
end
|
|
51
|
+
forgets.each do |(note, by, _), group|
|
|
52
|
+
items = group.map do |edit|
|
|
53
|
+
kept = edit.keep.map { |first, last| first == last ? first.to_s : "#{first}-#{last}" }
|
|
54
|
+
item(edit, calls, cwd).merge(kept: kept.empty? ? nil : kept.join(", ")).compact
|
|
55
|
+
end
|
|
56
|
+
groups << { kind: "forget", note: cut(note.to_s), by: by, items: items }
|
|
57
|
+
end
|
|
58
|
+
groups
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# An output's id, its tool and its row's title (ToolActivity.tool_title;
|
|
62
|
+
# none when its call can't be told).
|
|
63
|
+
def item(edit, calls, cwd)
|
|
64
|
+
call = calls[edit.id]
|
|
65
|
+
return { id: edit.id } unless call
|
|
66
|
+
|
|
67
|
+
name = call[:name].to_s
|
|
68
|
+
{ id: edit.id, tool: name, title: ToolActivity.tool_title(name, call, cwd: cwd) }.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
72
|
+
# off) · 1 more staged".
|
|
73
|
+
def text(groups, freed, why, staged)
|
|
74
|
+
forgot = groups.select { |group| group[:kind] == "forget" }.sum { |group| group[:items].size }
|
|
75
|
+
stale = groups.select { |group| group[:kind] == "stale" }.sum { |group| group[:items].size }
|
|
76
|
+
done = []
|
|
77
|
+
done << "forgot #{count(forgot, "output")}" if forgot.positive?
|
|
78
|
+
done << "stubbed #{count(stale, "stale read")}" if stale.positive?
|
|
79
|
+
frees = freed.positive? ? "frees ~#{k(freed)}" : "frees nothing"
|
|
80
|
+
frees += " (#{WHY[why]})" if WHY.key?(why)
|
|
81
|
+
line = "✂ #{done.join(", ")} · #{frees}"
|
|
82
|
+
staged.positive? ? "#{line} · #{staged} more staged" : line
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def count(number, noun) = "#{number} #{noun}#{"s" unless number == 1}"
|
|
86
|
+
|
|
87
|
+
def tokens(chars) = [(chars / TokenUsage::CHARS_PER_TOKEN).ceil, 0].max
|
|
88
|
+
|
|
89
|
+
# "4.1k tokens", "640 tokens".
|
|
90
|
+
def k(tokens) = tokens >= 1000 ? format("%.1fk tokens", tokens / 1000.0) : "#{tokens} tokens"
|
|
91
|
+
|
|
92
|
+
def cut(note) = note.length > NOTE_MAX ? "#{note[0, NOTE_MAX - 1]}…" : note
|
|
93
|
+
|
|
94
|
+
private_class_method :groups, :item, :text, :count, :tokens, :k, :cut
|
|
95
|
+
end
|
|
96
|
+
end
|