samagotchi 0.75.0 → 0.77.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/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +20 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +15 -4
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +34 -0
- data/docs/plugins.md +43 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +3 -1
- data/lib/samagotchi/answer_command.rb +5 -5
- data/lib/samagotchi/bridge.rb +3 -3
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +105 -16
- data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
- data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +13 -14
- data/lib/samagotchi/memory_bundle/installer.rb +122 -5
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/message_parts.rb +272 -0
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/parent_report.rb +30 -22
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/pending_question.rb +49 -0
- data/lib/samagotchi/question_desk.rb +39 -32
- data/lib/samagotchi/relay.rb +8 -9
- data/lib/samagotchi/relay_verifier.rb +2 -2
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +4 -4
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/session.rb +22 -14
- data/lib/samagotchi/session_commands.rb +5 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/terminal_ui.rb +5 -3
- data/lib/samagotchi/tool_output_cut.rb +1 -1
- data/lib/samagotchi/tool_response.rb +13 -13
- data/lib/samagotchi/tool_row_fields.rb +1 -1
- data/lib/samagotchi/tool_runner.rb +16 -9
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/tools/delegate_relay.rb +7 -7
- data/lib/samagotchi/tools/delegate_wait.rb +1 -1
- data/lib/samagotchi/tools/web_fetch.rb +3 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -2
- data/lib/samagotchi/web/public/app.js +21 -58
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +84 -11
- data/lib/samagotchi/web/public/index.html +32 -36
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +53 -0
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +38 -3
- data/lib/samagotchi/web/public/timing.js +11 -2
- data/lib/samagotchi/web/public/toasts.js +69 -0
- metadata +8 -2
- data/lib/samagotchi/web/message_parts.rb +0 -274
|
@@ -117,7 +117,7 @@ module Samagotchi
|
|
|
117
117
|
# turn queued behind it; nil (an older worker): the next reply.
|
|
118
118
|
turn = ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
|
|
119
119
|
|
|
120
|
-
if pending && pending
|
|
120
|
+
if pending && pending.id.to_s == qid
|
|
121
121
|
return failure(not_dismissable(pending)) if options[:dismiss] && ParentReport.continue?(pending)
|
|
122
122
|
|
|
123
123
|
selected = selection(pending, options) or return @selection_status
|
|
@@ -140,7 +140,7 @@ module Samagotchi
|
|
|
140
140
|
# exact label wins over a number.
|
|
141
141
|
# @return [Array<String>, nil] nil after a usage error
|
|
142
142
|
def selection(pending, options)
|
|
143
|
-
offered = Array(pending
|
|
143
|
+
offered = Array(pending.options).map(&:to_s)
|
|
144
144
|
labels = options[:options].map do |given|
|
|
145
145
|
next given if offered.include?(given)
|
|
146
146
|
|
|
@@ -151,7 +151,7 @@ module Samagotchi
|
|
|
151
151
|
@selection_status = usage_failure("no option #{given}; the options: #{listed}")
|
|
152
152
|
return nil
|
|
153
153
|
end
|
|
154
|
-
if labels.uniq.size > 1 && !pending
|
|
154
|
+
if labels.uniq.size > 1 && !pending.multi_select
|
|
155
155
|
@selection_status = usage_failure("one option only: the question is single-select")
|
|
156
156
|
return nil
|
|
157
157
|
end
|
|
@@ -163,7 +163,7 @@ module Samagotchi
|
|
|
163
163
|
# Deny, text alone or a dismissal always passes: each one denies. The
|
|
164
164
|
# worker checks it again with its own config.
|
|
165
165
|
def approval_refusal(pending, selected, _id)
|
|
166
|
-
offered = Array(pending
|
|
166
|
+
offered = Array(pending.options).map(&:to_s)
|
|
167
167
|
indices = selected.map { |label| offered.index(label) }
|
|
168
168
|
reason = Guardrails::ParentApprovals.refusal(pending, indices, setting: Guardrails::ParentApprovals.setting)
|
|
169
169
|
reason && Guardrails::ParentApprovals.message(reason)
|
|
@@ -177,7 +177,7 @@ module Samagotchi
|
|
|
177
177
|
|
|
178
178
|
# A step-limit question can't be left unanswered: the turn waits on it.
|
|
179
179
|
def not_dismissable(pending)
|
|
180
|
-
"the step-limit question can't be dismissed: answer #{Array(pending
|
|
180
|
+
"the step-limit question can't be dismissed: answer #{Array(pending.options).join(" or ")} " \
|
|
181
181
|
"(--option Stop --text WHY stops it)"
|
|
182
182
|
end
|
|
183
183
|
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -260,7 +260,7 @@ module Samagotchi
|
|
|
260
260
|
recap: @accumulator.recap,
|
|
261
261
|
saved_recap: @engine.saved_recap,
|
|
262
262
|
continue_offer: @accumulator.continue_offer,
|
|
263
|
-
pending_question: @engine.pending_question,
|
|
263
|
+
pending_question: @engine.pending_question&.to_h,
|
|
264
264
|
guardrail_warning: @engine.guardrail_warning,
|
|
265
265
|
plugin_warning: @engine.plugin_warning,
|
|
266
266
|
init_tasks: @engine.init_tasks,
|
|
@@ -1178,8 +1178,8 @@ module Samagotchi
|
|
|
1178
1178
|
# still that relay's.
|
|
1179
1179
|
def relay_closed(qid, relay_id, reason)
|
|
1180
1180
|
pending = @engine.pending_question
|
|
1181
|
-
relay = pending
|
|
1182
|
-
unless pending && pending
|
|
1181
|
+
relay = pending&.relayed_to
|
|
1182
|
+
unless pending && pending.id.to_s == qid && relay && (relay[:relay_id] || relay["relay_id"]).to_s == relay_id
|
|
1183
1183
|
return [{}, 409, { error: "question_not_pending", detail: "no question #{qid} relayed by #{relay_id}" }]
|
|
1184
1184
|
end
|
|
1185
1185
|
return [{}, 409, { error: "question_not_pending", detail: "no pending question #{qid}" }] unless @engine.annotate_question(qid, relayed_to: nil, reason: reason)
|
|
@@ -146,8 +146,12 @@ module Samagotchi
|
|
|
146
146
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
bundle_name =
|
|
150
|
-
|
|
149
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Install")
|
|
150
|
+
return 1 unless prepared
|
|
151
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
|
|
152
|
+
|
|
153
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
|
|
154
|
+
run_installer(installer, failed: "Install") do |manifest|
|
|
151
155
|
print_hooks_and_plugin(manifest)
|
|
152
156
|
print_provenance(bundle_name, manifest)
|
|
153
157
|
end
|
|
@@ -164,7 +168,7 @@ module Samagotchi
|
|
|
164
168
|
dry_run = opts.fetch(:dry_run, false)
|
|
165
169
|
agent = opts[:agent]
|
|
166
170
|
if source.nil? || source.empty?
|
|
167
|
-
@stderr.puts
|
|
171
|
+
@stderr.puts UPGRADE_HELP
|
|
168
172
|
return CLI::Exit::USAGE
|
|
169
173
|
end
|
|
170
174
|
expanded_source = expand_source(source)
|
|
@@ -172,18 +176,21 @@ module Samagotchi
|
|
|
172
176
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
|
|
173
177
|
end
|
|
174
178
|
|
|
175
|
-
bundle_name =
|
|
179
|
+
prepared, bundle_name = prepare_or_fail(expanded_source, "Upgrade")
|
|
180
|
+
return 1 unless prepared
|
|
181
|
+
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Upgrade")
|
|
182
|
+
|
|
176
183
|
unless Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
177
184
|
@stderr.puts "Bundle '#{bundle_name}' not installed — falling back to install"
|
|
178
185
|
# (no Hooks/Plugin lines here)
|
|
179
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, dry_run: dry_run)
|
|
186
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dry_run: dry_run)
|
|
180
187
|
return run_installer(installer, failed: "Install") do |manifest|
|
|
181
188
|
next dry_run_done if dry_run
|
|
182
189
|
|
|
183
190
|
print_provenance(bundle_name, manifest)
|
|
184
191
|
end
|
|
185
192
|
end
|
|
186
|
-
installer = installer_for(expanded_source, bundle_name, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
193
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, upgrade: true, dry_run: dry_run)
|
|
187
194
|
run_installer(installer, failed: "Upgrade") do |manifest|
|
|
188
195
|
print_hooks_and_plugin(manifest, plugin: !dry_run)
|
|
189
196
|
next dry_run_done if dry_run
|
|
@@ -193,12 +200,44 @@ module Samagotchi
|
|
|
193
200
|
end
|
|
194
201
|
end
|
|
195
202
|
|
|
196
|
-
# The
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
203
|
+
# The source normalized once (a git clone, a zip or tar extract) and the
|
|
204
|
+
# bundle's name, its manifest's name: for every kind of source, a git URL
|
|
205
|
+
# too. The Installer installs from that extract and removes it. Nil
|
|
206
|
+
# after "<word> failed: …" (an unknown source, or a missing or invalid
|
|
207
|
+
# manifest: an install needs one anyway), the extract removed.
|
|
208
|
+
# @return [Array(MemoryBundle::PreparedSource, String), nil]
|
|
209
|
+
def prepare_or_fail(expanded_source, word)
|
|
210
|
+
prepared = Samagotchi::MemoryBundle::PreparedSource.prepare(expanded_source)
|
|
211
|
+
named = [prepared, Samagotchi::MemoryBundle::Manifest.read(dir: prepared.dir).name]
|
|
212
|
+
rescue Samagotchi::MemoryBundle::SourceNormalizer::UnknownSourceError,
|
|
213
|
+
Samagotchi::MemoryBundle::Manifest::ValidationError => e
|
|
200
214
|
@stderr.puts "#{word} failed: #{e.message}"
|
|
201
|
-
|
|
215
|
+
ensure
|
|
216
|
+
# Any failure (an unexpected error too) removes the extract.
|
|
217
|
+
prepared&.cleanup unless named
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# A bundle installed from this source under another name: a git URL
|
|
221
|
+
# bundle recorded before chi named it by its manifest (it took the
|
|
222
|
+
# repo's name, chi-foo for name: foo). Installing it again would load
|
|
223
|
+
# both copies' plugin and hooks, so "<word> failed: … installed as
|
|
224
|
+
# chi-foo" instead (the extract removed). Only when no bundle has the
|
|
225
|
+
# manifest's name; sources compare by SourceIdentity.
|
|
226
|
+
# @return [Boolean] refused
|
|
227
|
+
def installed_under_another_name?(expanded_source, bundle_name, prepared, word)
|
|
228
|
+
return false if Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).installed?
|
|
229
|
+
|
|
230
|
+
identity = Samagotchi::MemoryBundle::SourceIdentity.of(expanded_source)
|
|
231
|
+
return false unless identity
|
|
232
|
+
|
|
233
|
+
other, = Samagotchi::MemoryBundle::Provenance.each_installed.find do |name, bundle|
|
|
234
|
+
name != bundle_name && !bundle.error? && Samagotchi::MemoryBundle::SourceIdentity.of(bundle.source) == identity
|
|
235
|
+
end
|
|
236
|
+
return false unless other
|
|
237
|
+
|
|
238
|
+
prepared.cleanup
|
|
239
|
+
@stderr.puts "#{word} failed: #{expanded_source} is installed as #{other}: `chi bundle uninstall #{other}`, then install it again"
|
|
240
|
+
true
|
|
202
241
|
end
|
|
203
242
|
|
|
204
243
|
def installer_for(source, name, scope:, force:, **)
|
|
@@ -728,40 +767,14 @@ module Samagotchi
|
|
|
728
767
|
parsed
|
|
729
768
|
end
|
|
730
769
|
|
|
731
|
-
#
|
|
770
|
+
# The source as given: a git URL as is, a shipped bundle's name as its
|
|
771
|
+
# dir, anything else as an expanded path.
|
|
732
772
|
def expand_source(src)
|
|
733
773
|
return src if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(src)
|
|
734
774
|
|
|
735
775
|
Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
|
|
736
776
|
end
|
|
737
777
|
|
|
738
|
-
def bundle_name_for(expanded_src)
|
|
739
|
-
# Try to derive from manifest if possible (skip git clone for speed if possible)
|
|
740
|
-
if Samagotchi::MemoryBundle::SourceNormalizer.git_url?(expanded_src)
|
|
741
|
-
# For git URLs, avoid double clone: derive name from URL basename if no manifest yet
|
|
742
|
-
# Let Installer do the real manifest read; fallback to URL basename
|
|
743
|
-
base = expanded_src.split("/").last.to_s.split("#").first.to_s
|
|
744
|
-
base = base.sub(/\.git\z/i, "")
|
|
745
|
-
return base.empty? ? "bundle" : base
|
|
746
|
-
end
|
|
747
|
-
require "yaml"
|
|
748
|
-
nd = nil
|
|
749
|
-
owned = false
|
|
750
|
-
begin
|
|
751
|
-
nd, owned = Samagotchi::MemoryBundle::SourceNormalizer.normalize(expanded_src)
|
|
752
|
-
m = Samagotchi::MemoryBundle::Manifest.read(dir: nd)
|
|
753
|
-
m.name
|
|
754
|
-
rescue Samagotchi::MemoryBundle::Manifest::ValidationError
|
|
755
|
-
# Fallback to source basename without archive extensions
|
|
756
|
-
src = expanded_src
|
|
757
|
-
name = File.basename(src)
|
|
758
|
-
name = name.sub(/\.tar\.gz\z/i, "").sub(/\.tgz\z/i, "").sub(/\.zip\z/i, "").sub(/\.tar\z/i, "")
|
|
759
|
-
name.empty? ? File.basename(nd || src) : name
|
|
760
|
-
ensure
|
|
761
|
-
Samagotchi::MemoryBundle::SourceNormalizer.cleanup(nd) if owned
|
|
762
|
-
end
|
|
763
|
-
end
|
|
764
|
-
|
|
765
778
|
def build_conflict_prompt(bundle_name, conflicts, expanded_source)
|
|
766
779
|
lines = []
|
|
767
780
|
lines << "Memory bundle upgrade has conflicts that need interactive resolution."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex-peer
|
|
3
|
-
version: 0.3.
|
|
3
|
+
version: 0.3.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
|
|
6
6
|
trust_level: reviewed
|
|
@@ -10,7 +10,7 @@ files:
|
|
|
10
10
|
description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks, or to reply to its answer in a peer turn; validate, context or question; Codex's answer comes as the codex-outbox context"
|
|
11
11
|
plugin:
|
|
12
12
|
file: plugin.rb
|
|
13
|
-
sha256: sha256:
|
|
13
|
+
sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
|
|
14
14
|
scripts:
|
|
15
15
|
pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
|
|
16
16
|
chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
|
|
@@ -168,10 +168,16 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
168
168
|
# [stdout, stderr, status]; both pipes read on threads, so a full one
|
|
169
169
|
# can't block the command.
|
|
170
170
|
def run(*command)
|
|
171
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + QUEUE_SECONDS
|
|
171
172
|
Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
|
|
172
173
|
stdin.close
|
|
173
174
|
out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
|
|
174
|
-
|
|
175
|
+
threads = [wait, out, err]
|
|
176
|
+
within_deadline = lambda do |thread|
|
|
177
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
178
|
+
thread.join([remaining, 0].max)
|
|
179
|
+
end
|
|
180
|
+
unless threads.all?(&within_deadline)
|
|
175
181
|
begin
|
|
176
182
|
Process.kill("KILL", -wait.pid)
|
|
177
183
|
rescue SystemCallError
|
|
@@ -179,7 +185,18 @@ class Plugin < Samagotchi::Plugin::Peer
|
|
|
179
185
|
end
|
|
180
186
|
# The group is gone, so the pipes end: let the readers finish
|
|
181
187
|
# before popen3 closes them.
|
|
182
|
-
|
|
188
|
+
cleanup_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
|
|
189
|
+
threads.each do |thread|
|
|
190
|
+
remaining = cleanup_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
thread.join([remaining, 0].max)
|
|
192
|
+
end
|
|
193
|
+
# The queue itself finished fine, and only a process it left held
|
|
194
|
+
# the pipes: the message is likely queued, so don't say "try again"
|
|
195
|
+
# (a retry would deliver it twice).
|
|
196
|
+
if wait.join(0) && wait.value.success?
|
|
197
|
+
raise SendError, "codex queue exited but left a process holding its output (stopped after " \
|
|
198
|
+
"#{QUEUE_SECONDS} s): the message may already be queued, check Codex before sending again"
|
|
199
|
+
end
|
|
183
200
|
raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
|
|
184
201
|
"again, or pair again from Codex"
|
|
185
202
|
end
|
|
@@ -76,6 +76,7 @@ class SourceLinks
|
|
|
76
76
|
# A bare URL (scheme-anchored, so it can't start inside a markdown label),
|
|
77
77
|
# and a markdown link (label in group 1, target in group 2).
|
|
78
78
|
URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
|
|
79
|
+
TRAILING_URL_PUNCTUATION = /[.,;:!?'"]+\z/
|
|
79
80
|
MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
|
|
80
81
|
# A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
|
|
81
82
|
FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
@@ -328,35 +329,36 @@ class SourceLinks
|
|
|
328
329
|
|
|
329
330
|
# The [start, end) character ranges of the answer that are a bare URL
|
|
330
331
|
# (scheme-anchored, `[a-z][a-z0-9+.\-]*://\S+`). A ref inside one is not
|
|
331
|
-
# linked again.
|
|
332
|
-
#
|
|
333
|
-
# `…/Foo_(bar)` keeps it.
|
|
332
|
+
# linked again. Trailing punctuation and a trailing `]` without a `[` are
|
|
333
|
+
# excluded; balanced parentheses and brackets stay part of the URL.
|
|
334
334
|
def bare_url_spans(text)
|
|
335
335
|
spans = []
|
|
336
336
|
text.scan(URL_SPAN) do
|
|
337
337
|
match = Regexp.last_match
|
|
338
|
-
finish = trim_url_end(match[0], match.begin(0)
|
|
338
|
+
finish = trim_url_end(match[0], match.begin(0))
|
|
339
339
|
spans << [match.begin(0), finish] if finish > match.begin(0)
|
|
340
340
|
end
|
|
341
341
|
spans
|
|
342
342
|
end
|
|
343
343
|
|
|
344
|
-
# The end offset of a URL match after trimming
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
# The end offset of a URL match after trimming it like notice_links.js.
|
|
345
|
+
def trim_url_end(url, start)
|
|
346
|
+
trimmed = url.sub(TRAILING_URL_PUNCTUATION, "")
|
|
347
347
|
depth = 0
|
|
348
|
-
|
|
348
|
+
trimmed.each_char.with_index do |char, index|
|
|
349
349
|
if char == "("
|
|
350
350
|
depth += 1
|
|
351
351
|
elsif char == ")"
|
|
352
352
|
if depth.zero?
|
|
353
|
-
|
|
353
|
+
trimmed = trimmed[0...index]
|
|
354
354
|
break
|
|
355
355
|
end
|
|
356
356
|
depth -= 1
|
|
357
357
|
end
|
|
358
358
|
end
|
|
359
|
-
|
|
359
|
+
trimmed = trimmed.sub(/\]+\z/, "") if trimmed.end_with?("]") && !trimmed.include?("[")
|
|
360
|
+
trimmed = trimmed.sub(TRAILING_URL_PUNCTUATION, "")
|
|
361
|
+
start + trimmed.length
|
|
360
362
|
end
|
|
361
363
|
|
|
362
364
|
# A markdown link's label and target ranges, with the target text. A ref in
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: source-links
|
|
3
|
-
version: 0.5.
|
|
3
|
+
version: 0.5.1
|
|
4
4
|
scope: system
|
|
5
5
|
description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
|
|
6
6
|
trust_level: reviewed
|
|
@@ -9,7 +9,7 @@ files:
|
|
|
9
9
|
source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
|
|
10
10
|
hooks:
|
|
11
11
|
source_links.rb:
|
|
12
|
-
sha256: sha256:
|
|
12
|
+
sha256: sha256:c41249ec0bbb708ec7827e1d0f66689dfbbc415c1da5fe33f5d3a8ec8e5eaa2f
|
|
13
13
|
event: after_turn
|
|
14
14
|
on_error: log
|
|
15
15
|
priority: 90
|
|
@@ -254,7 +254,7 @@ module Samagotchi
|
|
|
254
254
|
def reportable?(wait)
|
|
255
255
|
case wait.status
|
|
256
256
|
when :done, :no_reply, :error then true
|
|
257
|
-
when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.
|
|
257
|
+
when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
|
|
258
258
|
else false
|
|
259
259
|
end
|
|
260
260
|
end
|
|
@@ -152,8 +152,7 @@ module Samagotchi
|
|
|
152
152
|
def launch_config
|
|
153
153
|
env = { "LANG" => LANG }
|
|
154
154
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
155
|
-
env
|
|
156
|
-
env["XDG_STATE_HOME"] = Paths.state_home(env: @env) if env.key?("XDG_STATE_HOME")
|
|
155
|
+
env.merge!(Paths.absolute_xdg(env: env, set: :key))
|
|
157
156
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
158
157
|
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
159
158
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
@@ -168,6 +168,11 @@ module Samagotchi
|
|
|
168
168
|
unresolved(:window_unresolved, e, nil)
|
|
169
169
|
end
|
|
170
170
|
|
|
171
|
+
# The window config.yml gives this model or its host
|
|
172
|
+
# (ContextWindow.setting), never asking a server: a session loaded on
|
|
173
|
+
# it (Engine#session=, before a worker's Bridge serves). nil when none.
|
|
174
|
+
def configured_context_window = ContextWindow.setting(target, names: names)
|
|
175
|
+
|
|
171
176
|
# The window as its loop would see it (ChatLoop#context_window: a remote
|
|
172
177
|
# chat host has no /props, only its model list); nil when it can't be
|
|
173
178
|
# resolved.
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -242,10 +242,7 @@ module Samagotchi
|
|
|
242
242
|
wire_kernel(kernel, muted_memories)
|
|
243
243
|
Log.debug(:model, "backend", provider: backend.provider) if Log.level?(:debug)
|
|
244
244
|
@resume_session = session_id ? Session.load(session_id) : nil
|
|
245
|
-
@prompt_builder =
|
|
246
|
-
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
247
|
-
model_notes: -> { model_notes }, memories: memories,
|
|
248
|
-
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
|
|
245
|
+
@prompt_builder = build_system_prompt(memories, appended_prompt_files)
|
|
249
246
|
@session = nil
|
|
250
247
|
@session_observer = SessionObserver.new
|
|
251
248
|
@metrics = SessionMetrics.new
|
|
@@ -268,10 +265,7 @@ module Samagotchi
|
|
|
268
265
|
# One shared poller for the whole idle layer (reminders + optional
|
|
269
266
|
# recap). Both jobs read the activity seam above; the scheduler owns
|
|
270
267
|
# the single background thread and isolates per-job failures.
|
|
271
|
-
@idle_scheduler =
|
|
272
|
-
engine: self,
|
|
273
|
-
jobs: [@reminders, @recap].compact
|
|
274
|
-
)
|
|
268
|
+
@idle_scheduler = build_idle_scheduler
|
|
275
269
|
subscribe_session_observers
|
|
276
270
|
end
|
|
277
271
|
|
|
@@ -388,6 +382,21 @@ module Samagotchi
|
|
|
388
382
|
end
|
|
389
383
|
private :build_plugin_tasks
|
|
390
384
|
|
|
385
|
+
# The system prompt's live sources and this session's fixed inputs.
|
|
386
|
+
def build_system_prompt(memories, appended_prompt_files)
|
|
387
|
+
SystemPrompt.new(profile: -> { profile }, tools: -> { @tools }, session: -> { @session },
|
|
388
|
+
thinking: -> { turn_thinking }, model: -> { model_identity },
|
|
389
|
+
model_notes: -> { model_notes }, memories: memories,
|
|
390
|
+
muted_memory_names: @muted_memory_names, appended_files: appended_prompt_files)
|
|
391
|
+
end
|
|
392
|
+
private :build_system_prompt
|
|
393
|
+
|
|
394
|
+
# The idle layer's shared scheduler (see #initialize for when).
|
|
395
|
+
def build_idle_scheduler
|
|
396
|
+
IdleScheduler.new(engine: self, jobs: [@reminders, @recap].compact)
|
|
397
|
+
end
|
|
398
|
+
private :build_idle_scheduler
|
|
399
|
+
|
|
391
400
|
# A monotonically-increasing clock (wall clock can jump backwards; the idle
|
|
392
401
|
# detector must never treat a jump as "activity"). Injectable for specs.
|
|
393
402
|
def monotonic_now
|
|
@@ -1005,6 +1014,9 @@ module Samagotchi
|
|
|
1005
1014
|
sync_kernel_client!
|
|
1006
1015
|
@client.invalidate_context_window!
|
|
1007
1016
|
@metrics.forget_model_reports!
|
|
1017
|
+
# The last turn's ctx % was the old model's window's
|
|
1018
|
+
# (#announce_switched_context! sends the new one's).
|
|
1019
|
+
@last_context_status = nil
|
|
1008
1020
|
ref = effective_model_ref
|
|
1009
1021
|
if persist_default
|
|
1010
1022
|
ConfigFile.write_default_model!(ref)
|
|
@@ -1013,6 +1025,42 @@ module Samagotchi
|
|
|
1013
1025
|
ref
|
|
1014
1026
|
end
|
|
1015
1027
|
|
|
1028
|
+
# After /model: how full the context is on the new model's window,
|
|
1029
|
+
# before its first turn: the last turn's count (the old model's
|
|
1030
|
+
# tokenizer's, replaced at the next generation) against the new model's
|
|
1031
|
+
# window (EffectiveModel#context_window, ContextWindow.resolve: the
|
|
1032
|
+
# running server first, one /props GET that a remote chat host is
|
|
1033
|
+
# spared; else the host's cached model list; else the configured
|
|
1034
|
+
# window) and the
|
|
1035
|
+
# session's budget. Sent as a :context_status event (the web's ctx
|
|
1036
|
+
# meter, the attached TUI's status row) with context_window_tokens, and
|
|
1037
|
+
# kept for a client that joins later (#session_state_snapshot; the
|
|
1038
|
+
# window in the metrics' context too, which a page counts from). A window
|
|
1039
|
+
# that can't be resolved sends usage nil: the UIs clear the % until the
|
|
1040
|
+
# next generation. Nothing before the session's first counted turn.
|
|
1041
|
+
# +configured_only+: the configured window only, never asking a server
|
|
1042
|
+
# (EffectiveModel#configured_context_window): a loaded session's (#session=).
|
|
1043
|
+
# @return [Hash, nil] the event sent
|
|
1044
|
+
def announce_switched_context!(configured_only: false)
|
|
1045
|
+
used = @metrics.snapshot.dig(:context, :used_tokens)
|
|
1046
|
+
return nil unless used.is_a?(Integer)
|
|
1047
|
+
|
|
1048
|
+
model = effective_model
|
|
1049
|
+
window = configured_only ? model.configured_context_window : current_context_window(model)
|
|
1050
|
+
display = window && saved_context_status({ used_tokens: used, window_tokens: window.tokens })
|
|
1051
|
+
@last_context_status = display&.dup
|
|
1052
|
+
@metrics.note_context_window(window.tokens, window.source) if window
|
|
1053
|
+
usage = display && { estimated_pct: display[:est_pct], estimated_used_tokens: used, window_tokens: window.tokens,
|
|
1054
|
+
window_source: window.source.to_s, source: "switch" }
|
|
1055
|
+
event = { type: :context_status, usage: usage, bucket: display&.dig(:bucket),
|
|
1056
|
+
context_window_tokens: window&.tokens, context_window_source: window&.source&.to_s }
|
|
1057
|
+
@session_observer.notify(event)
|
|
1058
|
+
event
|
|
1059
|
+
rescue StandardError => e
|
|
1060
|
+
Log.warn(:model, "switched_context_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1061
|
+
nil
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1016
1064
|
# ── Hooks API ──────────────────────────────────────────────────────────────
|
|
1017
1065
|
|
|
1018
1066
|
# Register a hook callback for a named lifecycle event.
|
|
@@ -1179,7 +1227,7 @@ module Samagotchi
|
|
|
1179
1227
|
# :last_prompt [String, nil] the last user prompt (empty string if none)
|
|
1180
1228
|
# :event_seq [Integer] @session_observer.event_count
|
|
1181
1229
|
# :metrics [Hash] @metrics.snapshot (per-session analytics)
|
|
1182
|
-
# :pending_question [Hash, nil]
|
|
1230
|
+
# :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
|
|
1183
1231
|
# :used_memory_names [Array<String>] deduped memory names active this session
|
|
1184
1232
|
# :prompt_notes [Array<Hash>] the model notes the prompt carried
|
|
1185
1233
|
# ({name:, scope:, chars:, digest:}; #prompt_notes)
|
|
@@ -1205,7 +1253,7 @@ module Samagotchi
|
|
|
1205
1253
|
last_prompt: @session&.last_prompt,
|
|
1206
1254
|
event_seq: @session_observer&.event_count,
|
|
1207
1255
|
metrics: metrics,
|
|
1208
|
-
pending_question: @question_desk.pending,
|
|
1256
|
+
pending_question: @question_desk.pending&.to_h,
|
|
1209
1257
|
used_memory_names: @used_memories.names,
|
|
1210
1258
|
preloaded_memory_names: preloaded_memory_names,
|
|
1211
1259
|
muted_memory_names: @muted_memory_names.dup,
|
|
@@ -1515,7 +1563,7 @@ module Samagotchi
|
|
|
1515
1563
|
|
|
1516
1564
|
# ── Ask-user-question (structured qualification) ──────────────────────────
|
|
1517
1565
|
|
|
1518
|
-
# @return [
|
|
1566
|
+
# @return [PendingQuestion, nil] the question pending now
|
|
1519
1567
|
def pending_question
|
|
1520
1568
|
@question_desk.pending
|
|
1521
1569
|
end
|
|
@@ -1541,7 +1589,7 @@ module Samagotchi
|
|
|
1541
1589
|
|
|
1542
1590
|
# Publish a standing question that blocks nobody (QuestionDesk#post: a
|
|
1543
1591
|
# worker's step-limit question).
|
|
1544
|
-
# @return [
|
|
1592
|
+
# @return [PendingQuestion, nil] the pending question, nil while another is open
|
|
1545
1593
|
def post_question(fields, on_answer:, on_superseded_close: nil)
|
|
1546
1594
|
@question_desk.post(fields, on_answer: on_answer, on_superseded_close: on_superseded_close)
|
|
1547
1595
|
end
|
|
@@ -1640,7 +1688,29 @@ module Samagotchi
|
|
|
1640
1688
|
# A woken worker's /stats and status line count the turns before it.
|
|
1641
1689
|
bind_metrics(session)
|
|
1642
1690
|
@used_memories.absorb(session)
|
|
1691
|
+
# Loaded on another model than its last turn ran on (a --resume'd
|
|
1692
|
+
# REPL's --model, a worker woken after a /model with no turn since):
|
|
1693
|
+
# the saved ctx % was that model's window's. Drop it and count it as
|
|
1694
|
+
# /model does, on a configured window only: this runs before a
|
|
1695
|
+
# worker's Bridge serves, so no /props GET or model list here (a slow
|
|
1696
|
+
# or hung host would hold up the worker's start). No configured
|
|
1697
|
+
# window: no % (ctx ?) until the first generation reports one.
|
|
1698
|
+
return unless model_switched_since_last_turn?
|
|
1699
|
+
|
|
1700
|
+
@metrics.forget_model_reports!
|
|
1701
|
+
announce_switched_context!(configured_only: true)
|
|
1702
|
+
end
|
|
1703
|
+
|
|
1704
|
+
# Whether the last turn that named its model ran on another one than
|
|
1705
|
+
# the effective model; false before any (or when the ref can't be told).
|
|
1706
|
+
def model_switched_since_last_turn?
|
|
1707
|
+
last = @metrics.last_model_ref
|
|
1708
|
+
!last.nil? && last != effective_model_ref
|
|
1709
|
+
rescue StandardError => e
|
|
1710
|
+
Log.warn(:model, "switch_check_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
1711
|
+
false
|
|
1643
1712
|
end
|
|
1713
|
+
private :model_switched_since_last_turn?
|
|
1644
1714
|
|
|
1645
1715
|
# @return [IdleRecap, nil] the idle recap detector, or nil when disabled
|
|
1646
1716
|
attr_reader :recap
|
|
@@ -2047,7 +2117,8 @@ module Samagotchi
|
|
|
2047
2117
|
result = backend(model).complete(
|
|
2048
2118
|
messages: turn.messages,
|
|
2049
2119
|
max_iterations: turn.limit,
|
|
2050
|
-
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller
|
|
2120
|
+
on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller,
|
|
2121
|
+
model_ref: generation_model_ref(model)),
|
|
2051
2122
|
cancel_controller: turn.controller,
|
|
2052
2123
|
model_name: bare_for_backend,
|
|
2053
2124
|
max_tool_output_chars: max_tool_output_chars,
|
|
@@ -2059,6 +2130,18 @@ module Samagotchi
|
|
|
2059
2130
|
end
|
|
2060
2131
|
private :generate
|
|
2061
2132
|
|
|
2133
|
+
# The ref (host:model, as asked) a turn's generations ran on: the model
|
|
2134
|
+
# #generate resolved, after the before_turn hooks (which may switch it).
|
|
2135
|
+
# SessionMetrics keys its per-model segments on it. nil when it can't be
|
|
2136
|
+
# named (the turn's record then has none).
|
|
2137
|
+
def generation_model_ref(model)
|
|
2138
|
+
model.ref
|
|
2139
|
+
rescue StandardError => e
|
|
2140
|
+
Log.warn(:turn, "model_ref_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2141
|
+
nil
|
|
2142
|
+
end
|
|
2143
|
+
private :generation_model_ref
|
|
2144
|
+
|
|
2062
2145
|
# A step-limit Continue's resume_from, when it is inside the messages the
|
|
2063
2146
|
# run gets (a wrong one only drops reasoning: it is ignored, logged).
|
|
2064
2147
|
# Entries before it never move between the turns (#turn_messages adds
|
|
@@ -2594,13 +2677,17 @@ module Samagotchi
|
|
|
2594
2677
|
#
|
|
2595
2678
|
# With a :generation_progress hook registered, a Hooks::StreamWatch gets
|
|
2596
2679
|
# each chunk's thinking and text after the UIs had it.
|
|
2597
|
-
|
|
2680
|
+
#
|
|
2681
|
+
# +model_ref+: the turn's model (#generation_model_ref), added to each
|
|
2682
|
+
# :generation_started as `model_ref:` (both loops send through here).
|
|
2683
|
+
def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
|
|
2598
2684
|
watch = stream_watch(cancel_controller)
|
|
2599
2685
|
proc do |event|
|
|
2600
2686
|
progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
|
|
2601
2687
|
@steer_cut.observe(event, progress)
|
|
2602
2688
|
case event[:type]
|
|
2603
2689
|
when :generation_started
|
|
2690
|
+
event = event.merge(model_ref: model_ref) if model_ref
|
|
2604
2691
|
@thinking_notices.generation_started
|
|
2605
2692
|
watch&.started(event[:iteration])
|
|
2606
2693
|
when :generation_retrying
|
|
@@ -2626,13 +2713,15 @@ module Samagotchi
|
|
|
2626
2713
|
|
|
2627
2714
|
# A :generation_completed with the generation's decode speed (`speed:`
|
|
2628
2715
|
# {decode_tps:, source:}, nil without one), the session's running
|
|
2629
|
-
# token totals (`tokens:`, the snapshot's block) and
|
|
2716
|
+
# token totals (`tokens:`, the snapshot's block) and its per-model rows
|
|
2717
|
+
# (`model_segments:`, the snapshot's) and whether its served
|
|
2630
2718
|
# model is an expected one (`served_expected_by:`, its host): the web's info bar
|
|
2631
2719
|
# reads them from the stream. SessionMetrics closes the generation here,
|
|
2632
2720
|
# so the event it gets next finds it closed.
|
|
2633
2721
|
def with_generation_report(event)
|
|
2634
2722
|
report = @metrics.finish_generation
|
|
2635
|
-
event.merge(speed: report.speed&.to_h, tokens: report.tokens,
|
|
2723
|
+
event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
|
|
2724
|
+
served_expected_by: served_expected_by(event[:served_model]))
|
|
2636
2725
|
rescue StandardError => e
|
|
2637
2726
|
Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
|
|
2638
2727
|
event
|