samagotchi 0.6.0 → 0.8.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 +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
|
@@ -94,6 +94,9 @@ module Samagotchi
|
|
|
94
94
|
return 1
|
|
95
95
|
end
|
|
96
96
|
expanded_source = expand_source(source)
|
|
97
|
+
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
98
|
+
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
99
|
+
end
|
|
97
100
|
bundle_name = begin
|
|
98
101
|
bundle_name_for(expanded_source)
|
|
99
102
|
rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
|
|
@@ -139,6 +142,9 @@ module Samagotchi
|
|
|
139
142
|
return 1
|
|
140
143
|
end
|
|
141
144
|
expanded_source = expand_source(source)
|
|
145
|
+
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
146
|
+
return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
|
|
147
|
+
end
|
|
142
148
|
bundle_name = begin
|
|
143
149
|
bundle_name_for(expanded_source)
|
|
144
150
|
rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError => e
|
|
@@ -238,6 +244,9 @@ module Samagotchi
|
|
|
238
244
|
# Propagate overrides
|
|
239
245
|
Samagotchi::MemoryBundle::Uninstaller.system_dir_override = Samagotchi::MemoryBundle::Installer.system_dir_override
|
|
240
246
|
Samagotchi::MemoryBundle::Uninstaller.project_dir_base_override = Samagotchi::MemoryBundle::Installer.project_dir_base_override
|
|
247
|
+
data = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).read
|
|
248
|
+
return uninstall_profile(bundle_name, force: force) if Samagotchi::MemoryBundle::Profile.installed_meta?(bundle_name, data)
|
|
249
|
+
|
|
241
250
|
uninstaller = Samagotchi::MemoryBundle::Uninstaller.new(name: bundle_name, scope: scope, force: force)
|
|
242
251
|
begin
|
|
243
252
|
uninstaller.run
|
|
@@ -252,6 +261,47 @@ module Samagotchi
|
|
|
252
261
|
end
|
|
253
262
|
end
|
|
254
263
|
|
|
264
|
+
# install/upgrade of a shipped profile: its new members (Profile).
|
|
265
|
+
def install_profile(dir, scope:, force:, dry_run:, word:)
|
|
266
|
+
name = File.basename(dir)
|
|
267
|
+
if scope.to_s.strip.downcase == "project"
|
|
268
|
+
@stderr.puts "#{word} failed: #{name} is a profile: its bundles install system-wide (drop --scope project)"
|
|
269
|
+
return 1
|
|
270
|
+
end
|
|
271
|
+
result = Samagotchi::MemoryBundle::Profile.install(dir, dry_run: dry_run, force: force)
|
|
272
|
+
@stdout.puts "#{result.name} v#{result.version} (profile)"
|
|
273
|
+
lines = []
|
|
274
|
+
lines << "#{dry_run ? "would install" : "installed"}: #{result.installed.join(", ")}" unless result.installed.empty?
|
|
275
|
+
lines << "already installed: #{result.already.join(", ")}" unless result.already.empty?
|
|
276
|
+
result.skipped.each { |member, why| lines << "skipped #{member}: #{why}" }
|
|
277
|
+
result.failed.each { |member, why| lines << "failed #{member}: #{why}" }
|
|
278
|
+
lines << "nothing new to install" if lines.empty?
|
|
279
|
+
lines.each { |line| @stdout.puts " #{line}" }
|
|
280
|
+
@stdout.puts "(dry-run: no changes written)" if dry_run
|
|
281
|
+
result.failed? ? 1 : 0
|
|
282
|
+
rescue Samagotchi::MemoryBundle::Manifest::ValidationError => e
|
|
283
|
+
@stderr.puts "#{word} failed: #{e.message}"
|
|
284
|
+
1
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# uninstall of a profile: its recorded members, then itself (Profile).
|
|
288
|
+
def uninstall_profile(name, force:)
|
|
289
|
+
result = Samagotchi::MemoryBundle::Profile.uninstall(name, force: force)
|
|
290
|
+
if result.gone
|
|
291
|
+
@stdout.puts "Uninstalled bundle '#{name}'"
|
|
292
|
+
@stdout.puts "Removed: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
293
|
+
return 0
|
|
294
|
+
end
|
|
295
|
+
@stdout.puts "Removed from #{name}: #{result.removed.join(", ")}" unless result.removed.empty?
|
|
296
|
+
result.blocked.each { |member, why| @stdout.puts "Kept #{member}: #{why}" }
|
|
297
|
+
@stderr.puts "Uninstall failed: #{name} stays installed until #{result.blocked.keys.join(", ")} goes " \
|
|
298
|
+
"(chi bundle uninstall #{name} --force)"
|
|
299
|
+
1
|
|
300
|
+
rescue Samagotchi::MemoryBundle::Uninstaller::UninstallError => e
|
|
301
|
+
@stderr.puts "Uninstall failed: #{e.message}"
|
|
302
|
+
1
|
|
303
|
+
end
|
|
304
|
+
|
|
255
305
|
def status(rest)
|
|
256
306
|
name = rest.find { |a| !a.start_with?("--") }
|
|
257
307
|
require "json"
|
|
@@ -297,26 +347,26 @@ module Samagotchi
|
|
|
297
347
|
@stdout.puts " not loaded: #{plugin[:requires_failure]}" if plugin[:requires_failure]
|
|
298
348
|
end
|
|
299
349
|
st[:needs].each { |need| @stdout.puts " #{Samagotchi::MemoryBundle::Status.need_line(need)}" }
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
@stdout.puts "No installed bundles."; return 0
|
|
350
|
+
(st[:provenance][:includes] || []).each do |member|
|
|
351
|
+
state = Samagotchi::MemoryBundle::Provenance.new(name: member.to_s).installed? ? "installed" : "not installed: chi bundle install #{member}"
|
|
352
|
+
@stdout.puts " includes #{member} [#{state}]"
|
|
304
353
|
end
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
354
|
+
else
|
|
355
|
+
bundles = Samagotchi::MemoryBundle::Provenance.each_installed.to_a
|
|
356
|
+
@stdout.puts "No installed bundles." if bundles.empty?
|
|
357
|
+
bundles.each do |bname, data|
|
|
358
|
+
next @stdout.puts(" #{bname} (manifest.json unreadable)") if data[:error]
|
|
359
|
+
|
|
360
|
+
st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
|
|
361
|
+
mods = st[:files].values.count { |v| v[:modified] || v[:missing] }
|
|
362
|
+
hooks_count = (st[:provenance][:hooks] || {}).size
|
|
363
|
+
hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
|
|
364
|
+
plugin = st[:plugin]
|
|
365
|
+
mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
|
|
366
|
+
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
367
|
+
includes = st[:provenance][:includes]
|
|
368
|
+
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
369
|
+
@stdout.puts " #{bname} v#{st[:provenance][:version]} scope=#{st[:scope]} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
320
370
|
end
|
|
321
371
|
end
|
|
322
372
|
return 0
|
|
@@ -432,7 +482,8 @@ module Samagotchi
|
|
|
432
482
|
@stdout.puts "Installed:"
|
|
433
483
|
installed.each do |b|
|
|
434
484
|
next @stdout.puts(" #{b.name.ljust(name_w)} (#{b.error})") if b.error
|
|
435
|
-
line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"}
|
|
485
|
+
line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} " \
|
|
486
|
+
"#{b.includes ? profile_members(b.includes, installed) : "files=#{b.files}"} installed=#{b.installed_at || "?"}"
|
|
436
487
|
line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
|
|
437
488
|
@stdout.puts line
|
|
438
489
|
end
|
|
@@ -442,12 +493,21 @@ module Samagotchi
|
|
|
442
493
|
@stdout.puts ""
|
|
443
494
|
@stdout.puts "Available (shipped with chi, install with: chi bundle install <name>):"
|
|
444
495
|
available.each do |s|
|
|
445
|
-
|
|
496
|
+
members = s.includes.empty? ? "" : " (#{s.includes.join(", ")})"
|
|
497
|
+
@stdout.puts " #{s.source.ljust(name_w)} #{"v#{s.version}".ljust(ver_w)} #{s.description}#{members}".rstrip
|
|
446
498
|
end
|
|
447
499
|
end
|
|
448
500
|
return 0
|
|
449
501
|
end
|
|
450
502
|
|
|
503
|
+
# An installed profile's list cell: its recorded members still
|
|
504
|
+
# installed, and the ones left out (uninstalled by the user).
|
|
505
|
+
def profile_members(members, installed)
|
|
506
|
+
names = installed.map(&:name)
|
|
507
|
+
here, gone = members.partition { |m| names.include?(m) }
|
|
508
|
+
"includes=#{here.join(",")}#{gone.empty? ? "" : " left out=#{gone.join(",")}"}"
|
|
509
|
+
end
|
|
510
|
+
|
|
451
511
|
def build(rest)
|
|
452
512
|
scope = nil
|
|
453
513
|
name = nil
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
3
|
module Samagotchi
|
|
6
4
|
autoload :MutedMemories, File.expand_path("muted_memories", __dir__)
|
|
7
5
|
autoload :Log, File.expand_path("log", __dir__)
|
|
@@ -67,15 +65,8 @@ module Samagotchi
|
|
|
67
65
|
# entry name → marker, for the installed bundles of +scope+ with a
|
|
68
66
|
# missing need. A manifest.json or needs: that doesn't parse is skipped.
|
|
69
67
|
def self.entry_markers(scope, path:, bundles_dir:)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Dir[File.join(bundles_dir, "*", "manifest.json")].sort.each_with_object({}) do |mjson, acc|
|
|
73
|
-
data = begin
|
|
74
|
-
JSON.parse(File.read(mjson), symbolize_names: true)
|
|
75
|
-
rescue JSON::ParserError, SystemCallError
|
|
76
|
-
next
|
|
77
|
-
end
|
|
78
|
-
next unless data.is_a?(Hash) && data[:scope].to_s == scope.to_s
|
|
68
|
+
MemoryBundle::Provenance.each_installed(dir: bundles_dir).each_with_object({}) do |(_name, data), acc|
|
|
69
|
+
next if data[:error] || data[:scope].to_s != scope.to_s
|
|
79
70
|
next unless data[:needs].is_a?(Array) && !data[:needs].empty?
|
|
80
71
|
|
|
81
72
|
needs = begin
|
|
@@ -10,3 +10,4 @@
|
|
|
10
10
|
Before a task that a `skill_*` in the memory index matches, read it and follow it.
|
|
11
11
|
When a step turned out different (a renamed command, an extra step, a gotcha), update the skill in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line.
|
|
12
12
|
When a skill's step fails or its file/command is missing, find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask.
|
|
13
|
+
While following a skill, when anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask how to go on. Read each command's whole output, including warnings, before the next step.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.8.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
|
-
identity.md: sha256:
|
|
7
|
+
identity.md: sha256:d6f40d5e44096537d337c4ffda86823446f8b59eb8b8cc47014e97ec16f019c9
|
|
8
8
|
self_map.md: sha256:93c788fadaf98adeb8946b4a357d2372bd6ebb439042d7cef8dda8fb44187315
|
|
9
9
|
config_modification_protocol.md: sha256:24e95727488daa80ac3008e020d8be098c7a1882153b7a61aba35450f72b86b7
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:50c076fb6bda7ed010877ff664bb2e9e904418fe08b058ebaa911d4e82e9a683
|
|
11
11
|
delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
|
|
@@ -66,7 +66,7 @@ Shape (plain Markdown, no frontmatter):
|
|
|
66
66
|
- **When to use it** is the `description:` of `memory_write`: one line starting with the task ("Release a new version of this repo: verify, tag, push"). It is what the index shows, so it is how you find the skill later.
|
|
67
67
|
- **Scope**: `project` by default; `system` when the user asks, or when the skill is clearly not about this project.
|
|
68
68
|
- **Saving**: on a request to keep how something was done ("let's memorize this", "save this as a skill", `/skill save`), write it at once, then show it briefly. On an ambiguous one ("I like how we did that") you may ask whether to save it.
|
|
69
|
-
- **Following**: before a task a `skill_*` index line matches, `memory_read` it and follow its steps. A step that fails or names a missing file or command: find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask.
|
|
69
|
+
- **Following**: before a task a `skill_*` index line matches, `memory_read` it and follow its steps. A step that fails or names a missing file or command: find out why (look around, read nearby READMEs) before skipping it; a step that says stop means stop and ask. When anything looks unexpected (a check fails, output differs from what a step says, a warning the skill doesn't mention): stop, don't improvise a fix, tell the user what you saw and ask. Read each command's whole output, including warnings, before the next step.
|
|
70
70
|
- **Updating**: when a step turned out different, rewrite the skill with `memory_write` in the same turn: fix those steps, keep the rest as it was, add a dated Changelog line. No confirmation needed.
|
|
71
71
|
- **The `skills` bundle** (`chi bundle install skills`, optional) adds `/skill save [name] [--system]`, `/skill list`, `/skill show <name>`, `/skill diff <name> [N]`; it keeps older versions and shows a short diff line after each update.
|
|
72
72
|
|
|
@@ -78,6 +78,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
78
78
|
- Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
|
|
79
79
|
- Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
|
|
80
80
|
- A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
|
|
81
|
+
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
81
82
|
|
|
82
83
|
### CLI — `chi bundle`
|
|
83
84
|
|
|
@@ -88,7 +89,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
88
89
|
| `uninstall <bundle> [--force]` | Removes bundle files (skips locally edited files unless `--force`) and `index.md` lines, deletes provenance dir. |
|
|
89
90
|
| `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot. |
|
|
90
91
|
| `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
|
|
91
|
-
| `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one)
|
|
92
|
+
| `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
|
|
92
93
|
| `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
|
|
93
94
|
|
|
94
95
|
**Scope resolution for install/build:**
|
data/lib/samagotchi/client.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "uri"
|
|
|
6
6
|
require_relative "config"
|
|
7
7
|
require_relative "cancellation_controller"
|
|
8
8
|
require_relative "llm/http"
|
|
9
|
+
require_relative "llm/utf8_scrub"
|
|
9
10
|
require_relative "vision_context"
|
|
10
11
|
require_relative "vision_support"
|
|
11
12
|
require_relative "sampling_settings"
|
|
@@ -203,7 +204,7 @@ module Samagotchi
|
|
|
203
204
|
images: [], sampling: {})
|
|
204
205
|
images = Array(images)
|
|
205
206
|
request = { stop: stop, n_predict: n_predict, model: model, sampling: sampling }
|
|
206
|
-
return stream_completion(
|
|
207
|
+
return stream_completion(LLM::Utf8Scrub.call(prompt), request, on_chunk, cancel_controller, on_retry) if images.empty?
|
|
207
208
|
|
|
208
209
|
# The media marker is random per server process: a restart between the
|
|
209
210
|
# /props read and the request makes the prompt fail to tokenize, so
|
|
@@ -211,7 +212,7 @@ module Samagotchi
|
|
|
211
212
|
attempts = 0
|
|
212
213
|
begin
|
|
213
214
|
attempts += 1
|
|
214
|
-
payload_prompt = { prompt_string:
|
|
215
|
+
payload_prompt = { prompt_string: LLM::Utf8Scrub.call(prompt.gsub(ImagePlan::NATIVE_PLACEHOLDER, media_marker!(model))),
|
|
215
216
|
multimodal_data: images }
|
|
216
217
|
stream_completion(payload_prompt, request, on_chunk, cancel_controller, on_retry)
|
|
217
218
|
rescue LLM::BadRequest => e
|
|
@@ -403,27 +404,6 @@ module Samagotchi
|
|
|
403
404
|
(sampling || {}).reject { |key, value| value.nil? || ConfigFile::SAMPLING_RESERVED_KEYS.include?(key.to_s) }
|
|
404
405
|
end
|
|
405
406
|
|
|
406
|
-
# Conversation content (system prompt + tool responses + model output) can
|
|
407
|
-
# contain invalid UTF-8 — e.g. a shell/file op writes a garbled multibyte
|
|
408
|
-
# sequence (a truncated em-dash, a stray replacement byte). `JSON#to_json`
|
|
409
|
-
# raises `JSON::GeneratorError` on such input, which would abort the whole
|
|
410
|
-
# turn. Scrub the payload first: only offending bytes are replaced with "?",
|
|
411
|
-
# every valid UTF-8 string passes through untouched. Non-UTF-8 encodings are
|
|
412
|
-
# left alone because `#to_json` already handles them without raising.
|
|
413
|
-
def scrub_utf8(obj)
|
|
414
|
-
case obj
|
|
415
|
-
when String
|
|
416
|
-
str = obj.to_s
|
|
417
|
-
str.encoding == Encoding::UTF_8 && !str.valid_encoding? ? str.scrub("?") : str
|
|
418
|
-
when Array
|
|
419
|
-
obj.map { |element| scrub_utf8(element) }
|
|
420
|
-
when Hash
|
|
421
|
-
obj.each_with_object({}) { |(key, value), memo| memo[scrub_utf8(key)] = scrub_utf8(value) }
|
|
422
|
-
else
|
|
423
|
-
obj
|
|
424
|
-
end
|
|
425
|
-
end
|
|
426
|
-
|
|
427
407
|
# Resolve a user-facing SAMAGOTCHI_DEFAULT_MODEL selector to the exact id oMLX
|
|
428
408
|
# expects in the request body (an id from its `/v1/models` list).
|
|
429
409
|
#
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -5,7 +5,9 @@ require "json"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "uri"
|
|
7
7
|
require "set"
|
|
8
|
+
require_relative "atomic_file"
|
|
8
9
|
require_relative "log"
|
|
10
|
+
require_relative "paths"
|
|
9
11
|
|
|
10
12
|
module Samagotchi
|
|
11
13
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -88,7 +90,7 @@ module Samagotchi
|
|
|
88
90
|
Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli]),
|
|
89
91
|
# Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
|
|
90
92
|
Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config]),
|
|
91
|
-
# false: a session nothing happened in is deleted when it is left (SessionManager.
|
|
93
|
+
# false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
|
|
92
94
|
Entry.new(key: "session.keep_empty", yaml_path: %w[session keep_empty], type: :bool, default: false, expose: %i[env config]),
|
|
93
95
|
# The most sessions one session may have delegated and still running (the delegate tool); a guard against a runaway model.
|
|
94
96
|
Entry.new(key: "session.max_children", yaml_path: %w[session max_children], type: :integer, default: 4, expose: %i[env config]),
|
|
@@ -135,6 +137,11 @@ module Samagotchi
|
|
|
135
137
|
Entry.new(key: "update.gem", yaml_path: %w[update gem], type: :bool, default: true, expose: %i[env config]),
|
|
136
138
|
Entry.new(key: "update.bundles", yaml_path: %w[update bundles], type: :bool, default: true, expose: %i[env config]),
|
|
137
139
|
Entry.new(key: "update.desktop", yaml_path: %w[update desktop], type: :bool, default: true, expose: %i[env config]),
|
|
140
|
+
# The desktop helper's kitty targets (Desktop::MacOS.kitty_settings): listen_on copied from kitty.conf (unset = none),
|
|
141
|
+
# the kitty binary, and the foreground programs listed, "|"-separated (a YAML list works too; "*" = every window).
|
|
142
|
+
Entry.new(key: "kitty.listen_on", yaml_path: %w[kitty listen_on], type: :string, default: nil, expose: %i[env config]),
|
|
143
|
+
Entry.new(key: "kitty.binary", yaml_path: %w[kitty binary], type: :string, default: "/Applications/kitty.app/Contents/MacOS/kitty", expose: %i[env config]),
|
|
144
|
+
Entry.new(key: "kitty.agents", yaml_path: %w[kitty agents], type: :string, default: "claude|codex|gemini|aider|opencode|cursor-agent|amp|goose", expose: %i[env config]),
|
|
138
145
|
|
|
139
146
|
Entry.new(key: "read.truncate_at_bytes", yaml_path: %w[read truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
140
147
|
Entry.new(key: "read.preview_bytes", yaml_path: %w[read preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
@@ -504,7 +511,6 @@ module Samagotchi
|
|
|
504
511
|
# (hosts, model_aliases) and the recap section resolution. All of it
|
|
505
512
|
# shares Config's precedence (CLI > ENV > file > default) for scalars.
|
|
506
513
|
module ConfigFile
|
|
507
|
-
XDG_CONFIG_HOME_ENV = "XDG_CONFIG_HOME"
|
|
508
514
|
CONFIG_DIR = "samagotchi"
|
|
509
515
|
CONFIG_FILE = "config.yml"
|
|
510
516
|
MODEL_ALIASES_KEY = "model_aliases"
|
|
@@ -630,9 +636,7 @@ module Samagotchi
|
|
|
630
636
|
end
|
|
631
637
|
|
|
632
638
|
def config_dir(env: ENV)
|
|
633
|
-
config_home
|
|
634
|
-
base_dir = config_home.empty? ? File.expand_path("~/.config") : config_home
|
|
635
|
-
File.join(base_dir, CONFIG_DIR)
|
|
639
|
+
File.join(Paths.config_home(env: env), CONFIG_DIR)
|
|
636
640
|
end
|
|
637
641
|
|
|
638
642
|
def global_path(env: ENV)
|
|
@@ -911,9 +915,7 @@ module Samagotchi
|
|
|
911
915
|
raw_data["default"] = { "model" => resolved }
|
|
912
916
|
end
|
|
913
917
|
FileUtils.mkdir_p(File.dirname(path))
|
|
914
|
-
|
|
915
|
-
File.write(tmp, YAML.dump(raw_data))
|
|
916
|
-
File.rename(tmp, path)
|
|
918
|
+
AtomicFile.write(path, YAML.dump(raw_data))
|
|
917
919
|
clear_yaml_cache!(path)
|
|
918
920
|
Samagotchi::Config.reload!(cli_overrides: Samagotchi::Config.cli_overrides)
|
|
919
921
|
true
|
|
@@ -1038,9 +1040,7 @@ module Samagotchi
|
|
|
1038
1040
|
normalized[lowered_key] = resolved_model
|
|
1039
1041
|
|
|
1040
1042
|
FileUtils.mkdir_p(File.dirname(path))
|
|
1041
|
-
|
|
1042
|
-
File.write(tmp, YAML.dump(raw_data))
|
|
1043
|
-
File.rename(tmp, path)
|
|
1043
|
+
AtomicFile.write(path, YAML.dump(raw_data))
|
|
1044
1044
|
clear_yaml_cache!(path)
|
|
1045
1045
|
previous
|
|
1046
1046
|
end
|
|
@@ -15,7 +15,7 @@ module Samagotchi
|
|
|
15
15
|
|
|
16
16
|
module_function
|
|
17
17
|
|
|
18
|
-
# Takes
|
|
18
|
+
# Takes SessionInbox.read_note's keys.
|
|
19
19
|
# @return [Hash] the conversation message
|
|
20
20
|
def message(note_id:, text:, source:, created_at: nil, from_session: nil, from_cwd: nil, **)
|
|
21
21
|
{ role: "system", kind: KIND, note_id: note_id, source: source, from_session: from_session,
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
require_relative "context_usage"
|
|
5
|
+
require_relative "context_window"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# One turn's context tracking for KernelLoop#run: how full the window is
|
|
9
|
+
# before each request (the server's last prompt count plus an estimate for
|
|
10
|
+
# what the turn appended since, else a chars-per-token estimate), the
|
|
11
|
+
# bucket that puts it in, and what comes of that:
|
|
12
|
+
#
|
|
13
|
+
# - the status line's value (#display), every request and again from the
|
|
14
|
+
# server's final counts after each generation;
|
|
15
|
+
# - a :context_status stream event (#observe's return), on a bucket change
|
|
16
|
+
# or every context.status_cadence requests;
|
|
17
|
+
# - the model's own short line (#take_guidance), once per rise into a
|
|
18
|
+
# bucket whose guidance asks for a change (the second threshold up).
|
|
19
|
+
#
|
|
20
|
+
# The context.* settings are read once, when the tracker is built (one per
|
|
21
|
+
# run, so a changed setting counts from the next turn).
|
|
22
|
+
class ContextStatus
|
|
23
|
+
STATUS_PREFIX = "CONTEXT_STATUS"
|
|
24
|
+
# The model's own line about its context (a tail system message, kind
|
|
25
|
+
# LINE_KIND).
|
|
26
|
+
LINE_PREFIX = "[CONTEXT: "
|
|
27
|
+
LINE_KIND = "context"
|
|
28
|
+
GUIDANCE_FROM_RANK = 2
|
|
29
|
+
|
|
30
|
+
DEFAULT_CHARS_PER_TOKEN = 4.0
|
|
31
|
+
DEFAULT_THRESHOLDS = [20, 40, 60, 80].freeze
|
|
32
|
+
|
|
33
|
+
def self.enabled?
|
|
34
|
+
Config.get("context.status") != false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Healthy to critical; the top configured bucket gets the last, the
|
|
38
|
+
# ones below it the ones before, and the first bucket (and the one
|
|
39
|
+
# under it) is always healthy.
|
|
40
|
+
GUIDANCE = [
|
|
41
|
+
"context healthy — proceed normally",
|
|
42
|
+
"context moderate — prefer targeted and range reads over full-file dumps",
|
|
43
|
+
"context elevated — be concise, prefer range reads, avoid re-reading large files",
|
|
44
|
+
"context critical — summarize aggressively, avoid large outputs, delegate broad work to subagents"
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# @param bucket [String] "under<N>" or "<N>plus"
|
|
48
|
+
# @param thresholds [Array<Integer>] the configured ones, sorted
|
|
49
|
+
def self.guidance(bucket, thresholds = DEFAULT_THRESHOLDS)
|
|
50
|
+
rank = bucket_rank(bucket, thresholds)
|
|
51
|
+
level = rank < GUIDANCE_FROM_RANK ? 0 : GUIDANCE.size - 1 - (thresholds.size - rank)
|
|
52
|
+
GUIDANCE[level.clamp(0, GUIDANCE.size - 1)]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 0 for the bucket under the first threshold, then one per threshold.
|
|
56
|
+
def self.bucket_rank(bucket, thresholds)
|
|
57
|
+
return 0 if bucket.nil? || bucket.to_s.start_with?("under")
|
|
58
|
+
|
|
59
|
+
(thresholds.index(bucket.to_s.delete_suffix("plus").to_i) || -1) + 1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The bucket of the last status line +conversation+ holds: the model's
|
|
63
|
+
# own line, or a legacy session's injected telemetry.
|
|
64
|
+
def self.last_bucket(conversation)
|
|
65
|
+
message = conversation.reverse.find do |entry|
|
|
66
|
+
content = entry[:content].to_s
|
|
67
|
+
entry[:role] == "system" && (content.start_with?(STATUS_PREFIX) || content.start_with?(LINE_PREFIX))
|
|
68
|
+
end
|
|
69
|
+
return nil unless message
|
|
70
|
+
|
|
71
|
+
match = message[:content].match(/\bbucket=([a-z0-9_]+)/)
|
|
72
|
+
match && match[1]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param conversation [Array<Hash>] the turn's conversation so far (its
|
|
76
|
+
# last status line's bucket is where rises count from)
|
|
77
|
+
def initialize(conversation: [])
|
|
78
|
+
@enabled = self.class.enabled?
|
|
79
|
+
chars_per_token = Config.get("context.chars_per_token").to_f
|
|
80
|
+
@chars_per_token = chars_per_token.positive? ? chars_per_token : DEFAULT_CHARS_PER_TOKEN
|
|
81
|
+
parsed = Config.get("context.status_thresholds").to_s.split(",").map { |value| value.strip.to_i }
|
|
82
|
+
.select { |value| value.between?(1, 99) }.uniq.sort
|
|
83
|
+
@thresholds = parsed.empty? ? DEFAULT_THRESHOLDS : parsed
|
|
84
|
+
@cadence = [Config.get("context.status_cadence").to_i, 0].max
|
|
85
|
+
@last_bucket = self.class.last_bucket(conversation)
|
|
86
|
+
@server_usage = nil
|
|
87
|
+
@counted = nil
|
|
88
|
+
@display = nil
|
|
89
|
+
@guidance = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Hash, nil] the status line's value ({est_pct:, bucket:});
|
|
93
|
+
# nil with context.status off or before the first request
|
|
94
|
+
attr_reader :display
|
|
95
|
+
|
|
96
|
+
def enabled? = @enabled
|
|
97
|
+
|
|
98
|
+
# Before a request: estimate +prompt+'s usage against +window+ (this
|
|
99
|
+
# request's ContextWindow::Resolved) and update the status line's
|
|
100
|
+
# value. A rise into a bucket that asks the model for a change leaves
|
|
101
|
+
# a line for #take_guidance.
|
|
102
|
+
# +image_tokens+: the images' estimate (their base64 is not in +prompt+).
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash, nil] the :context_status event's fields (without
|
|
105
|
+
# :type/:iteration) when the emit gate fires, else nil
|
|
106
|
+
def observe(prompt, iteration_index:, window:, image_tokens: 0)
|
|
107
|
+
return nil unless @enabled
|
|
108
|
+
|
|
109
|
+
usage = estimate(prompt, server_usage: @server_usage, window: window, image_tokens: image_tokens, counted: @counted)
|
|
110
|
+
bucket = bucket_for(usage[:estimated_pct])
|
|
111
|
+
# The status line's value, every iteration; the gate below decides
|
|
112
|
+
# only the event and the model's guidance line.
|
|
113
|
+
@display = { est_pct: usage[:estimated_pct], bucket: bucket }
|
|
114
|
+
emit = emit?(bucket: bucket, iteration_index: iteration_index)
|
|
115
|
+
previous_bucket = @last_bucket
|
|
116
|
+
@last_bucket = bucket
|
|
117
|
+
return nil unless emit
|
|
118
|
+
|
|
119
|
+
@guidance = guidance_message(usage: usage, bucket: bucket) if guidance_due?(previous_bucket, bucket)
|
|
120
|
+
{ status: status_message(usage: usage, bucket: bucket), usage: usage, bucket: bucket, source: usage[:source] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Hash, nil] the model's line from the last #observe (once)
|
|
124
|
+
def take_guidance
|
|
125
|
+
line = @guidance
|
|
126
|
+
@guidance = nil
|
|
127
|
+
line
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A stream payload's counts, kept for the next estimate.
|
|
131
|
+
# @return [Hash, nil] the payload's normalized counts, when it has any
|
|
132
|
+
def capture(payload)
|
|
133
|
+
normalized = ContextUsage.normalize(payload)
|
|
134
|
+
@server_usage = normalized if normalized
|
|
135
|
+
normalized
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# After a generation: +usage+ is its own server counts (nil: none), for
|
|
139
|
+
# +prompt_chars+/+image_tokens+ as sent. The next estimate adds only
|
|
140
|
+
# what the turn appended since (answer, tool results), and the status
|
|
141
|
+
# line's value counts the answer. Without counts the pre-generation
|
|
142
|
+
# estimate stays.
|
|
143
|
+
def generation_done(usage, prompt_chars:, image_tokens:, window:)
|
|
144
|
+
return unless usage
|
|
145
|
+
|
|
146
|
+
@counted = { chars: prompt_chars, image_tokens: image_tokens }
|
|
147
|
+
value = display_for(used_tokens: usage[:total_tokens], window_tokens: usage[:context_window_tokens] || window&.tokens)
|
|
148
|
+
@display = value if value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The status line's value ({est_pct:, bucket:}) for +used_tokens+ of
|
|
152
|
+
# +window_tokens+; nil without both, or with context.status off.
|
|
153
|
+
def display_for(used_tokens:, window_tokens:)
|
|
154
|
+
return nil unless @enabled
|
|
155
|
+
return nil unless ContextWindow.positive_integer?(used_tokens) && ContextWindow.positive_integer?(window_tokens)
|
|
156
|
+
|
|
157
|
+
pct = (used_tokens.to_f / window_tokens) * 100.0
|
|
158
|
+
{ est_pct: pct, bucket: bucket_for(pct) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `window` is this request's ContextWindow::Resolved. A window the
|
|
162
|
+
# stream payload reports itself still wins.
|
|
163
|
+
# +counted+: the prompt the server's prompt_tokens counted ({chars:,
|
|
164
|
+
# image_tokens:}); what +prompt+ has on top of it (the answer, tool
|
|
165
|
+
# results since) is added as an estimate, so the value doesn't read low
|
|
166
|
+
# during a long tool loop. A prompt shorter than that one (trimmed) or
|
|
167
|
+
# none known: the server's count alone.
|
|
168
|
+
def estimate(prompt, window:, server_usage: nil, image_tokens: 0, counted: nil)
|
|
169
|
+
window_source = window.source
|
|
170
|
+
window_source = :server if server_usage && server_usage[:context_window_tokens]
|
|
171
|
+
|
|
172
|
+
if server_usage && server_usage[:prompt_tokens]
|
|
173
|
+
window_tokens = server_usage[:context_window_tokens] || window.tokens
|
|
174
|
+
used_tokens = server_usage[:prompt_tokens] + appended_tokens(prompt, image_tokens, counted)
|
|
175
|
+
source = "server"
|
|
176
|
+
else
|
|
177
|
+
window_tokens = window.tokens
|
|
178
|
+
used_tokens = (prompt.length / @chars_per_token).ceil + image_tokens
|
|
179
|
+
source = "estimate"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
window_tokens: window_tokens,
|
|
184
|
+
window_source: window_source,
|
|
185
|
+
estimated_used_tokens: used_tokens,
|
|
186
|
+
estimated_remaining_tokens: [window_tokens - used_tokens, 0].max,
|
|
187
|
+
estimated_pct: (used_tokens.to_f / window_tokens) * 100.0,
|
|
188
|
+
source: source
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
# The estimate for what +prompt+ added since the +counted+ one.
|
|
195
|
+
def appended_tokens(prompt, image_tokens, counted)
|
|
196
|
+
return 0 unless counted
|
|
197
|
+
|
|
198
|
+
chars = prompt.length - counted[:chars]
|
|
199
|
+
return 0 unless chars.positive?
|
|
200
|
+
|
|
201
|
+
(chars / @chars_per_token).ceil + [image_tokens - counted[:image_tokens].to_i, 0].max
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def bucket_for(estimated_pct)
|
|
205
|
+
bucket = "under#{@thresholds.first}"
|
|
206
|
+
@thresholds.each do |threshold|
|
|
207
|
+
bucket = "#{threshold}plus" if estimated_pct >= threshold
|
|
208
|
+
end
|
|
209
|
+
bucket
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def bucket_rank(bucket) = self.class.bucket_rank(bucket, @thresholds)
|
|
213
|
+
|
|
214
|
+
# A rise (never a fall or a cadence tick) into a bucket whose guidance
|
|
215
|
+
# asks for a change. With no previous bucket (a first turn, a resumed
|
|
216
|
+
# session with no line yet), the first bucket counts as a rise from 0.
|
|
217
|
+
def guidance_due?(previous, bucket)
|
|
218
|
+
rank = bucket_rank(bucket)
|
|
219
|
+
rank >= GUIDANCE_FROM_RANK && rank > bucket_rank(previous)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def emit?(bucket:, iteration_index:)
|
|
223
|
+
bucket_changed = if @last_bucket.nil?
|
|
224
|
+
bucket != "under#{@thresholds.first}"
|
|
225
|
+
else
|
|
226
|
+
bucket != @last_bucket
|
|
227
|
+
end
|
|
228
|
+
cadence_due = @cadence.positive? && ((iteration_index + 1) % @cadence).zero?
|
|
229
|
+
bucket_changed || cadence_due
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def guidance_message(usage:, bucket:)
|
|
233
|
+
how = usage[:source].to_s == "server" ? "as the server reports" : "estimated"
|
|
234
|
+
{ role: "system", kind: LINE_KIND,
|
|
235
|
+
content: "#{LINE_PREFIX}about #{usage[:estimated_pct].to_f.round}% of the context window is in use " \
|
|
236
|
+
"(#{how}; bucket=#{bucket}). #{self.class.guidance(bucket, @thresholds)}]" }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def status_message(usage:, bucket:)
|
|
240
|
+
format(
|
|
241
|
+
"%<prefix>s window_tokens=%<window>d window_src=%<window_src>s est_used_tokens=%<used>d est_remaining_tokens=%<remaining>d est_pct=%<pct>.1f bucket=%<bucket>s thresholds=%<thresholds>s src=%<src>s guidance=%<guidance>s",
|
|
242
|
+
prefix: STATUS_PREFIX,
|
|
243
|
+
window: usage[:window_tokens],
|
|
244
|
+
window_src: usage[:window_source] || "default",
|
|
245
|
+
used: usage[:estimated_used_tokens],
|
|
246
|
+
remaining: usage[:estimated_remaining_tokens],
|
|
247
|
+
pct: usage[:estimated_pct],
|
|
248
|
+
bucket: bucket,
|
|
249
|
+
thresholds: @thresholds.join(","),
|
|
250
|
+
src: usage[:source],
|
|
251
|
+
guidance: self.class.guidance(bucket, @thresholds)
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|