samagotchi 0.154.0 → 0.154.1
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 +15 -1
- data/docs/cli.md +9 -7
- data/docs/internals/bridge.md +18 -5
- data/docs/internals/engine.md +1 -1
- data/docs/internals/prompt-caching.md +3 -1
- data/docs/internals/web-frontend.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +15 -0
- data/lib/samagotchi/answer_text.rb +1 -2
- data/lib/samagotchi/bridge/card_places.rb +56 -7
- data/lib/samagotchi/bridge/card_store.rb +24 -9
- data/lib/samagotchi/bridge.rb +9 -0
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
- data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
- data/lib/samagotchi/engine_snapshots.rb +9 -8
- data/lib/samagotchi/idle_recap.rb +3 -1
- data/lib/samagotchi/kernel_loop.rb +4 -3
- data/lib/samagotchi/llm/chat_loop.rb +3 -1
- data/lib/samagotchi/session_manager.rb +8 -13
- data/lib/samagotchi/session_retention.rb +24 -4
- data/lib/samagotchi/steer.rb +12 -1
- data/lib/samagotchi/stream_event.rb +22 -0
- data/lib/samagotchi/system_prompt.rb +52 -36
- data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
- data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
- data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
- data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
- data/lib/samagotchi/tool_runner.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +7 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -1
- data/lib/samagotchi/web/message_display.rb +7 -0
- data/lib/samagotchi/web/public/card_stamps.js +27 -0
- data/lib/samagotchi/web/public/cards_place.js +5 -3
- data/lib/samagotchi/web/public/composer_mode.js +1 -1
- data/lib/samagotchi/web/public/format.js +3 -2
- data/lib/samagotchi/web/public/turn_html.js +8 -4
- data/lib/samagotchi/web/session_access_routes.rb +8 -0
- data/lib/samagotchi/web/session_show.rb +4 -0
- data/lib/samagotchi/worker.rb +8 -5
- data/lib/samagotchi.rb +0 -1
- metadata +3 -2
- data/lib/samagotchi/pending_input_queue.rb +0 -55
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "degrade"
|
|
3
4
|
require_relative "session_setting"
|
|
4
5
|
require_relative "token_usage"
|
|
5
6
|
|
|
@@ -171,16 +172,16 @@ module Samagotchi
|
|
|
171
172
|
# doesn't count. Without a model (EffectiveModel), no probe. A remote
|
|
172
173
|
# chat host has no /props: nil until a turn. The name asked is never
|
|
173
174
|
# resolved (HostRegistry#bare_name), so it holds on a broken resolve.
|
|
174
|
-
def served_model_for(snapshot, model: nil)
|
|
175
|
-
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
176
|
-
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
177
|
-
return [nil, nil] unless model
|
|
178
|
-
|
|
179
|
-
model.served_from_props(asked)
|
|
180
175
|
# Only bare_model_name and the snapshot lookup are left to fail here:
|
|
181
176
|
# the probe's failure is EffectiveModel#served_from_props's.
|
|
182
|
-
|
|
183
|
-
[nil, nil]
|
|
177
|
+
def served_model_for(snapshot, model: nil)
|
|
178
|
+
Degrade.call(:model, "served_model_unreadable", fallback: [nil, nil]) do
|
|
179
|
+
asked = bare_model_name(model ? model.name : effective_model_name)
|
|
180
|
+
return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
|
|
181
|
+
return [nil, nil] unless model
|
|
182
|
+
|
|
183
|
+
model.served_from_props(asked)
|
|
184
|
+
end
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
# The next turn's kit prompt text for /stats: its kit and its estimated
|
|
@@ -473,7 +473,9 @@ module Samagotchi
|
|
|
473
473
|
parsed = safe_parse(@engine.messages_json_for_recap)
|
|
474
474
|
return :too_short if parsed.nil?
|
|
475
475
|
|
|
476
|
-
|
|
476
|
+
# The turns as the page counts them (Steer.turn_opening?): not Qwen's
|
|
477
|
+
# recovery line, nor input merged into a running turn.
|
|
478
|
+
user_turns = parsed.count { |message| Steer.turn_opening?(message) }
|
|
477
479
|
if parsed.empty? || user_turns < @min_user_turns
|
|
478
480
|
drop_stale(parsed)
|
|
479
481
|
return :too_short
|
|
@@ -18,8 +18,8 @@ require_relative "cut"
|
|
|
18
18
|
require_relative "cut_policy"
|
|
19
19
|
require_relative "thinking"
|
|
20
20
|
require_relative "hooks"
|
|
21
|
-
require_relative "pending_input_queue"
|
|
22
21
|
require_relative "steer"
|
|
22
|
+
require_relative "stream_event"
|
|
23
23
|
require_relative "reasoning_history"
|
|
24
24
|
require_relative "thought_stream_splitter"
|
|
25
25
|
require_relative "tools/builtins"
|
|
@@ -726,7 +726,8 @@ module Samagotchi
|
|
|
726
726
|
turn.pending_tool_calls = false
|
|
727
727
|
if turn.qwen_partial && turn.qwen_attempts < QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_LIMIT
|
|
728
728
|
turn.qwen_attempts += 1
|
|
729
|
-
turn.conversation << { role: "user",
|
|
729
|
+
turn.conversation << { role: "user", kind: Steer::RECOVERY_KIND, content: QWEN_INCOMPLETE_TOOL_CALL_RECOVERY_PROMPT,
|
|
730
|
+
preserve_literals: true }
|
|
730
731
|
return :next
|
|
731
732
|
end
|
|
732
733
|
|
|
@@ -834,7 +835,7 @@ module Samagotchi
|
|
|
834
835
|
|
|
835
836
|
# A failing stream callback (a UI gone) must not break the turn.
|
|
836
837
|
def emit_stream_event(callback, event)
|
|
837
|
-
|
|
838
|
+
StreamEvent.emit(callback, event, failure: "stream_event_callback_failed")
|
|
838
839
|
end
|
|
839
840
|
|
|
840
841
|
# ── Hook dispatch helper ───────────────────────────────────────────────────
|
|
@@ -432,7 +432,8 @@ module Samagotchi
|
|
|
432
432
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
433
433
|
# reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
|
|
434
434
|
# refs of a user message or a tool result, a prompt's turn_id and its
|
|
435
|
-
# Verify/Revise anchors (Engine, never sent)
|
|
435
|
+
# Verify/Revise anchors (Engine, never sent), an opening's opening_id
|
|
436
|
+
# (TurnFlow#abort_continue!'s reason line, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
436
437
|
# (the live row's params line and label, never sent back), an
|
|
437
438
|
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
438
439
|
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
@@ -448,6 +449,7 @@ module Samagotchi
|
|
|
448
449
|
message[:tool_call_id] = entry[:tool_call_id] if entry[:tool_call_id]
|
|
449
450
|
message[:images] = entry[:images] if entry[:images].is_a?(Array) && !entry[:images].empty?
|
|
450
451
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
452
|
+
message[:opening_id] = entry[:opening_id] if entry[:opening_id].is_a?(String)
|
|
451
453
|
message[:turn_start] = true if entry[:turn_start] == true
|
|
452
454
|
message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
|
|
453
455
|
message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
|
|
@@ -33,6 +33,7 @@ require_relative "worker_idle_exit"
|
|
|
33
33
|
require_relative "worker_signals"
|
|
34
34
|
require_relative "chi_invocation"
|
|
35
35
|
require_relative "log"
|
|
36
|
+
require_relative "degrade"
|
|
36
37
|
require_relative "log_path"
|
|
37
38
|
require_relative "paths"
|
|
38
39
|
require_relative "project_env"
|
|
@@ -282,7 +283,7 @@ module Samagotchi
|
|
|
282
283
|
child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
|
|
283
284
|
# And the spawner's debug log, absolute: a relative log.file would
|
|
284
285
|
# otherwise land in the worker's (the session's) directory.
|
|
285
|
-
log_path = begin LogPath.resolve rescue nil end
|
|
286
|
+
log_path = begin LogPath.resolve rescue nil end # degrade-ok: no log to say it in; the worker's is off (below)
|
|
286
287
|
if log_path
|
|
287
288
|
child_env["SAMAGOTCHI_LOG_FILE"] = log_path
|
|
288
289
|
else
|
|
@@ -936,9 +937,7 @@ module Samagotchi
|
|
|
936
937
|
# session.keep_empty off: sessions left with nothing in them are deleted
|
|
937
938
|
# (the worker as it exits, the retention sweep).
|
|
938
939
|
def self.discard_empty?
|
|
939
|
-
Samagotchi::Config.get("session.keep_empty") != true
|
|
940
|
-
rescue StandardError
|
|
941
|
-
false
|
|
940
|
+
Degrade.call(:config, "keep_empty_unreadable", fallback: false) { Samagotchi::Config.get("session.keep_empty") != true }
|
|
942
941
|
end
|
|
943
942
|
|
|
944
943
|
# Whether a session being left (or found left) is deleted: session.keep_empty
|
|
@@ -977,9 +976,9 @@ module Samagotchi
|
|
|
977
976
|
return true if one.to_s == other.to_s
|
|
978
977
|
return false if one.nil? || other.nil?
|
|
979
978
|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
979
|
+
Degrade.call(:config, "model_ref_unresolved", fallback: false) do
|
|
980
|
+
ConfigFile.model_ref(one).ref == ConfigFile.model_ref(other).ref
|
|
981
|
+
end
|
|
983
982
|
end
|
|
984
983
|
|
|
985
984
|
# Only the system prompt (an Engine seeds one, a saved session keeps
|
|
@@ -1281,9 +1280,7 @@ module Samagotchi
|
|
|
1281
1280
|
end
|
|
1282
1281
|
|
|
1283
1282
|
def self.config_idle_exit_minutes
|
|
1284
|
-
Samagotchi::Config.get("session.idle_exit_minutes")
|
|
1285
|
-
rescue StandardError
|
|
1286
|
-
nil
|
|
1283
|
+
Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
|
|
1287
1284
|
end
|
|
1288
1285
|
|
|
1289
1286
|
private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
|
|
@@ -1640,9 +1637,7 @@ module Samagotchi
|
|
|
1640
1637
|
end
|
|
1641
1638
|
|
|
1642
1639
|
private_class_method def self.delivery_owner(manager, session_id, state_dir)
|
|
1643
|
-
manager.session_owner(session_id, state_dir: state_dir)
|
|
1644
|
-
rescue StandardError
|
|
1645
|
-
nil
|
|
1640
|
+
Degrade.call(:worker, "owner_unreadable", sid: session_id) { manager.session_owner(session_id, state_dir: state_dir) }
|
|
1646
1641
|
end
|
|
1647
1642
|
|
|
1648
1643
|
def self.stopped_on_disk?(session_id, state_dir:)
|
|
@@ -18,7 +18,7 @@ module Samagotchi
|
|
|
18
18
|
# (session.retention_days), count (session.max_count, counting only the
|
|
19
19
|
# sessions that stay), statuses kept (session.keep_status), sessions left
|
|
20
20
|
# empty (SessionManager.discardable?, an hour on), leftover scratch
|
|
21
|
-
# sessions and skeleton-only directories; a session with a live owner
|
|
21
|
+
# sessions (once past SCRATCH_START_GRACE) and skeleton-only directories; a session with a live owner
|
|
22
22
|
# always stays.
|
|
23
23
|
module SessionRetention
|
|
24
24
|
# The session.retention_days / max_count / keep_status defaults.
|
|
@@ -93,6 +93,16 @@ module Samagotchi
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# How long after its save an unowned scratch session may still be
|
|
97
|
+
# waiting for the worker just spawned for it to take its owner lock.
|
|
98
|
+
SCRATCH_START_GRACE = SessionManager::WORKER_START_GRACE
|
|
99
|
+
|
|
100
|
+
private_class_method def self.scratch_starting?(path, now)
|
|
101
|
+
now - File.mtime(path) < SCRATCH_START_GRACE
|
|
102
|
+
rescue SystemCallError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
|
|
96
106
|
# Lazy sweep guard: runs prune at most once per SWEEP_INTERVAL_HOURS
|
|
97
107
|
# (session.sweep_interval_hours).
|
|
98
108
|
MARKER = ".last_retention"
|
|
@@ -162,7 +172,9 @@ module Samagotchi
|
|
|
162
172
|
# count (`chi sessions clean` with no --days: test runs are throwaway).
|
|
163
173
|
#
|
|
164
174
|
# A scratch session nobody owns (its terminal and worker were killed) goes whatever its
|
|
165
|
-
# age, its status and the count; +test_only+ takes it too.
|
|
175
|
+
# age, its status and the count; +test_only+ takes it too. One saved
|
|
176
|
+
# within SCRATCH_START_GRACE (its file's mtime, against +now+) stays: its
|
|
177
|
+
# worker may not have taken the owner lock yet.
|
|
166
178
|
#
|
|
167
179
|
# +max_count+ counts the sessions that stay: one deleted whatever its
|
|
168
180
|
# place (scratch, left empty, expired, +any_age+) takes no slot.
|
|
@@ -173,7 +185,7 @@ module Samagotchi
|
|
|
173
185
|
# @return [Hash] { deleted: [ids], kept: [ids], skipped: [ids] }
|
|
174
186
|
def self.apply(state_dir: Session.default_state_dir, days: DEFAULT_RETENTION_DAYS, max_count: DEFAULT_MAX_COUNT,
|
|
175
187
|
keep_status: DEFAULT_KEEP_STATUS, dry_run: false, test_only: false, alive_check: nil,
|
|
176
|
-
empty_check: nil, any_age: false)
|
|
188
|
+
empty_check: nil, any_age: false, now: Time.now)
|
|
177
189
|
keep_status = Array(keep_status).map(&:to_s)
|
|
178
190
|
# Fetch all sessions sorted newest-first for count logic
|
|
179
191
|
all = Session.list(state_dir: state_dir, sort: "updated_at", order: "desc")
|
|
@@ -182,7 +194,6 @@ module Samagotchi
|
|
|
182
194
|
all = all.select { |session| session.test_run || session.scratch }
|
|
183
195
|
end
|
|
184
196
|
|
|
185
|
-
now = Time.now
|
|
186
197
|
cutoff = days.to_i.positive? ? now - (days.to_i * 86_400) : nil
|
|
187
198
|
max = max_count.to_i
|
|
188
199
|
|
|
@@ -217,6 +228,15 @@ module Samagotchi
|
|
|
217
228
|
end
|
|
218
229
|
end
|
|
219
230
|
|
|
231
|
+
# A scratch session saved within SCRATCH_START_GRACE: `chi scratch`
|
|
232
|
+
# saves it, then spawns the worker that takes its owner lock, so no
|
|
233
|
+
# owner yet doesn't mean a leftover. Kept, it takes no count slot.
|
|
234
|
+
if session.scratch && scratch_starting?(path, now)
|
|
235
|
+
kept << session.id
|
|
236
|
+
passed_over += 1
|
|
237
|
+
next
|
|
238
|
+
end
|
|
239
|
+
|
|
220
240
|
left_empty = begin
|
|
221
241
|
empty_check&.call(session.id)
|
|
222
242
|
rescue StandardError
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -28,6 +28,12 @@ module Samagotchi
|
|
|
28
28
|
module Steer
|
|
29
29
|
KIND = "steer"
|
|
30
30
|
INPUT_KIND = "input"
|
|
31
|
+
# KernelLoop's line asking a Qwen model to finish the tool call it left
|
|
32
|
+
# open: a user line inside the running turn, the model's alone. No turn
|
|
33
|
+
# opens with it (#turn_prompt?), and the page draws nothing for it, as it
|
|
34
|
+
# showed nothing live. Its kind adds no header (#header): its bytes go
|
|
35
|
+
# out as before it had one.
|
|
36
|
+
RECOVERY_KIND = "recovery"
|
|
31
37
|
|
|
32
38
|
# A drain item for an input line with its sender (source nil = the user).
|
|
33
39
|
# A plain String is still a valid item: the user's line. +mark+: fields
|
|
@@ -317,6 +323,11 @@ module Samagotchi
|
|
|
317
323
|
message.is_a?(Hash) && (message[:role] || message["role"]).to_s == "user" && !steer?(message)
|
|
318
324
|
end
|
|
319
325
|
|
|
326
|
+
# KernelLoop's recovery line (RECOVERY_KIND).
|
|
327
|
+
def recovery?(message)
|
|
328
|
+
message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == RECOVERY_KIND
|
|
329
|
+
end
|
|
330
|
+
|
|
320
331
|
# The user's lines merged into a running turn (Merge#messages).
|
|
321
332
|
def input?(message)
|
|
322
333
|
message.is_a?(Hash) && (message[:kind] || message["kind"]).to_s == INPUT_KIND
|
|
@@ -333,7 +344,7 @@ module Samagotchi
|
|
|
333
344
|
start = (message[:turn_start] || message["turn_start"]) == true
|
|
334
345
|
return start if (message[:role] || message["role"]).to_s == "system"
|
|
335
346
|
|
|
336
|
-
prompt?(message) && (!input?(message) || start)
|
|
347
|
+
prompt?(message) && !recovery?(message) && (!input?(message) || start)
|
|
337
348
|
end
|
|
338
349
|
|
|
339
350
|
# A stored `!shell` line: "!(cmd)\n<output>", no turn id. A user message,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "degrade"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The one rule for handing a loop event to its on_stream_event callback
|
|
7
|
+
# (KernelLoop#emit_stream_event, ToolRunner#emit): no callback is fine, and
|
|
8
|
+
# a failing one (a UI gone) must not break the turn or the tool call. Its
|
|
9
|
+
# failure is a Degrade record under :turn, named by the caller (+failure+,
|
|
10
|
+
# so a log still says which side lost the event), with the event's type.
|
|
11
|
+
module StreamEvent
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param callback [#call, nil] the loop's on_stream_event
|
|
15
|
+
# @param event [Hash] the event
|
|
16
|
+
# @param failure [String] the Degrade event slug when +callback+ raises
|
|
17
|
+
# @return the callback's value, nil when it raised or there is none
|
|
18
|
+
def emit(callback, event, failure:)
|
|
19
|
+
Degrade.call(:turn, failure, type: (event[:type] if event.is_a?(Hash))) { callback&.call(event) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -77,6 +77,10 @@ module Samagotchi
|
|
|
77
77
|
# a new process. The files' text is read at each build.
|
|
78
78
|
@config_appended = Degrade.call(:prompt, "appended_list_unreadable", fallback: []) { ConfigFile.appended_prompt_files }
|
|
79
79
|
@cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
|
|
80
|
+
# { [chat, thinking, layers] => Built }, swapped for a new one by #reset!
|
|
81
|
+
@cache = {}
|
|
82
|
+
# The Built #build returned last; nil before a build and after #reset!.
|
|
83
|
+
@last = nil
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# @return [Array<String>] the names the session preloads, known before
|
|
@@ -85,37 +89,35 @@ module Samagotchi
|
|
|
85
89
|
@requested_memories.map { |raw| split_memory_scope(raw).last }.uniq
|
|
86
90
|
end
|
|
87
91
|
|
|
92
|
+
# A prompt #build made, with what it read while it did: the memory
|
|
93
|
+
# index sizes (#memory_index), the model notes, the files and the
|
|
94
|
+
# plugins' sections it holds, and its stable part's length (chat).
|
|
95
|
+
Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length)
|
|
96
|
+
|
|
88
97
|
# The full prompt: #base wrapped with the index and the rest. Built once
|
|
89
98
|
# per loop and level (until #reset!) so the prompt prefix, and the
|
|
90
99
|
# server's KV cache for it, stay stable.
|
|
100
|
+
#
|
|
101
|
+
# #reset! runs on other threads (a plugin's tools_changed! /
|
|
102
|
+
# prompt_changed!, a /stats re-resolving the profile) and, through the
|
|
103
|
+
# profile lookup, inside a build on this one. So a build reads @cache
|
|
104
|
+
# once and computes into locals: a reset meanwhile swaps in a new cache
|
|
105
|
+
# and this build's result goes into the dropped one, so it is returned
|
|
106
|
+
# (the same bytes as without the reset) but not kept, and the next
|
|
107
|
+
# build reads the sources again. No lock: the build reads memory files
|
|
108
|
+
# and calls the lookups, one of which takes ModelBinding's mutex, whose
|
|
109
|
+
# holder (a /stats thread) may itself call #reset!.
|
|
91
110
|
# @param chat [Boolean] for the chat loop
|
|
92
111
|
# @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
|
|
93
112
|
# @param layers [Array<Symbol>] the turn's LLM context layers: the
|
|
94
113
|
# native prompt declares the tools they add (Tools::Registry#entries)
|
|
95
114
|
def build(chat: false, thinking: nil, layers: [])
|
|
96
115
|
key = [chat, thinking, layers]
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@
|
|
100
|
-
@
|
|
101
|
-
|
|
102
|
-
unless @built.key?(key)
|
|
103
|
-
@section_notes = nil
|
|
104
|
-
@section_files = []
|
|
105
|
-
@section_plugins = []
|
|
106
|
-
@built[key] = system_prompt_with_index(
|
|
107
|
-
assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
108
|
-
)
|
|
109
|
-
@index_sizes[key] = @measured_index
|
|
110
|
-
@notes_loaded[key] = @section_notes || []
|
|
111
|
-
@files_loaded[key] = @section_files.freeze
|
|
112
|
-
@sections_loaded[key] = @section_plugins.freeze
|
|
113
|
-
end
|
|
114
|
-
@memory_index = @index_sizes[key]
|
|
115
|
-
@prompt_notes = @notes_loaded[key]
|
|
116
|
-
@prompt_files = @files_loaded[key]
|
|
117
|
-
@prompt_sections = @sections_loaded[key]
|
|
118
|
-
@built[key]
|
|
116
|
+
cache = @cache
|
|
117
|
+
built = cache[key] || (cache[key] = build_prompt(chat: chat, thinking: thinking, layers: layers))
|
|
118
|
+
@memory_index = built.memory_index
|
|
119
|
+
@last = built
|
|
120
|
+
built.prompt
|
|
119
121
|
end
|
|
120
122
|
|
|
121
123
|
# The memory indexes the prompt #build returned last holds, per scope
|
|
@@ -130,7 +132,7 @@ module Samagotchi
|
|
|
130
132
|
# (Array<ModelNotes::Note>, [] with none), nil before a build and after
|
|
131
133
|
# #reset!. Read when the prompt is built, as the section is.
|
|
132
134
|
# @return [Array<ModelNotes::Note>, nil]
|
|
133
|
-
|
|
135
|
+
def prompt_notes = @last&.notes
|
|
134
136
|
|
|
135
137
|
# The files whose text the prompt #build returned last holds, as
|
|
136
138
|
# absolute paths in prompt order: the AGENTS.md chain, then the
|
|
@@ -138,26 +140,21 @@ module Samagotchi
|
|
|
138
140
|
# build and after #reset!. Read when the prompt is built, as the
|
|
139
141
|
# sections are.
|
|
140
142
|
# @return [Array<String>]
|
|
141
|
-
def prompt_files = @
|
|
143
|
+
def prompt_files = @last&.files || []
|
|
142
144
|
|
|
143
145
|
# The plugins' prompt sections the prompt #build returned last holds
|
|
144
146
|
# (Plugin::PromptSections::Built, prompt order), [] before a build and
|
|
145
147
|
# after #reset!.
|
|
146
148
|
# @return [Array<Plugin::PromptSections::Built>]
|
|
147
|
-
def prompt_sections = @
|
|
149
|
+
def prompt_sections = @last&.sections || []
|
|
148
150
|
|
|
149
151
|
# Drops the built prompts: the next #build reads the profile, tools,
|
|
150
152
|
# indexes and memories again (a model or profile switch, changed tools).
|
|
151
153
|
def reset!
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@
|
|
155
|
-
@
|
|
156
|
-
@files_loaded = nil
|
|
157
|
-
@prompt_files = nil
|
|
158
|
-
@sections_loaded = nil
|
|
159
|
-
@prompt_sections = nil
|
|
160
|
-
@stable_lengths = nil
|
|
154
|
+
# Single assignments: a build in flight keeps the cache it read
|
|
155
|
+
# (#build), so it neither fails nor is kept.
|
|
156
|
+
@cache = {}
|
|
157
|
+
@last = nil
|
|
161
158
|
end
|
|
162
159
|
|
|
163
160
|
# The length of +prompt+'s stable part when it is a chat prompt #build
|
|
@@ -165,7 +162,12 @@ module Samagotchi
|
|
|
165
162
|
# a remote prompt cache can keep for every session (PromptCache). nil
|
|
166
163
|
# for any other text, or a prompt without those lines.
|
|
167
164
|
# @return [Integer, nil]
|
|
168
|
-
def stable_length(prompt)
|
|
165
|
+
def stable_length(prompt)
|
|
166
|
+
last = @last
|
|
167
|
+
return last.stable_length if last&.prompt == prompt
|
|
168
|
+
|
|
169
|
+
@cache.each_value.find { |built| built.prompt == prompt }&.stable_length
|
|
170
|
+
end
|
|
169
171
|
|
|
170
172
|
# The base prompt (specs, plugins' declarations), with Gemma's tool
|
|
171
173
|
# declarations at its end as #build has them.
|
|
@@ -182,6 +184,20 @@ module Samagotchi
|
|
|
182
184
|
|
|
183
185
|
private
|
|
184
186
|
|
|
187
|
+
# One #build's work: the prompt and what it read, in a Built. The
|
|
188
|
+
# @section_* and @measured_* scratch is this build's (the turn thread's)
|
|
189
|
+
# alone: #reset! never touches it.
|
|
190
|
+
def build_prompt(chat:, thinking:, layers:)
|
|
191
|
+
@section_notes = nil
|
|
192
|
+
@section_files = []
|
|
193
|
+
@section_plugins = []
|
|
194
|
+
@measured_stable_length = nil
|
|
195
|
+
prompt = system_prompt_with_index(assist_system_prompt(chat: chat, layers: layers),
|
|
196
|
+
chat: chat, thinking: thinking, layers: layers)
|
|
197
|
+
Built.new(prompt: prompt, memory_index: @measured_index, notes: @section_notes || [],
|
|
198
|
+
files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length)
|
|
199
|
+
end
|
|
200
|
+
|
|
185
201
|
def profile = @profile_lookup.call
|
|
186
202
|
|
|
187
203
|
def turn_thinking = @thinking_lookup.call
|
|
@@ -331,7 +347,7 @@ module Samagotchi
|
|
|
331
347
|
return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
|
|
332
348
|
|
|
333
349
|
prompt = "#{stable_text}\n#{volatile.join("\n")}"
|
|
334
|
-
|
|
350
|
+
@measured_stable_length = stable_text.length + 1 if chat
|
|
335
351
|
with_tail_declarations(prompt, chat: chat, layers: layers)
|
|
336
352
|
end
|
|
337
353
|
|
|
@@ -60,15 +60,6 @@ module Samagotchi
|
|
|
60
60
|
@screen.commit(text)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# Draw on +surface+ from now on (no caller since the REPL went): what the slot shows
|
|
64
|
-
# moves there.
|
|
65
|
-
def surface=(surface)
|
|
66
|
-
@lock.synchronize do
|
|
67
|
-
@screen = surface
|
|
68
|
-
redraw_status if status_text
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
63
|
# A new turn: its tally starts over too.
|
|
73
64
|
def reset_turn_feedback
|
|
74
65
|
@lock.synchronize do
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "reline"
|
|
6
6
|
|
|
7
7
|
require_relative "../config"
|
|
8
|
+
require_relative "../degrade"
|
|
8
9
|
require_relative "../memory_paths"
|
|
9
10
|
require_relative "../prompt_history"
|
|
10
11
|
require_relative "../tools/memory"
|
|
@@ -90,20 +91,18 @@ module Samagotchi
|
|
|
90
91
|
fragment = slash_fragment.to_s.strip
|
|
91
92
|
return [] unless fragment.start_with?("/")
|
|
92
93
|
|
|
93
|
-
|
|
94
|
+
# Only at the line's start: "/x" mid-line is a path, not a command.
|
|
95
|
+
Degrade.call(:attached, "completion_line_unreadable") do
|
|
94
96
|
buf = Reline.line_buffer.to_s
|
|
95
|
-
if !buf.empty? && !buf.lstrip.start_with?("/")
|
|
96
|
-
return []
|
|
97
|
-
end
|
|
98
|
-
rescue StandardError
|
|
99
|
-
nil
|
|
97
|
+
return [] if !buf.empty? && !buf.lstrip.start_with?("/")
|
|
100
98
|
end
|
|
101
99
|
|
|
102
100
|
lowered = fragment.downcase
|
|
103
101
|
return slash_commands.dup if lowered == "/"
|
|
104
102
|
|
|
105
103
|
slash_commands.select { |cmd| cmd.start_with?(lowered) }
|
|
106
|
-
rescue StandardError
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Degrade.note(:attached, "slash_completion_failed", e)
|
|
107
106
|
[]
|
|
108
107
|
end
|
|
109
108
|
|
|
@@ -134,7 +133,8 @@ module Samagotchi
|
|
|
134
133
|
relative_path = "#{relative_path}/" if File.directory?(absolute_path)
|
|
135
134
|
"#{AT_PATH_COMPLETION_PREFIX}#{relative_path}"
|
|
136
135
|
end
|
|
137
|
-
rescue StandardError
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Degrade.note(:attached, "path_completion_failed", e)
|
|
138
138
|
[]
|
|
139
139
|
end
|
|
140
140
|
|
|
@@ -190,7 +190,8 @@ module Samagotchi
|
|
|
190
190
|
yield scope, name
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
|
-
rescue StandardError
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
Degrade.note(:attached, "memory_completion_failed", e)
|
|
194
195
|
[]
|
|
195
196
|
end
|
|
196
197
|
|
|
@@ -207,8 +208,8 @@ module Samagotchi
|
|
|
207
208
|
@history_signature = PromptHistory.signature
|
|
208
209
|
@history_seen = PromptHistory.entries
|
|
209
210
|
@history_seen.last(PromptHistory::LIMIT).each { |entry| Reline::HISTORY << entry }
|
|
210
|
-
rescue StandardError
|
|
211
|
-
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
Degrade.note(:attached, "history_load_failed", e)
|
|
212
213
|
end
|
|
213
214
|
|
|
214
215
|
# A scratch session keeps nothing, its typed lines neither.
|
|
@@ -223,8 +224,8 @@ module Samagotchi
|
|
|
223
224
|
history_lock.synchronize { forget_own_line(own) } if own
|
|
224
225
|
raise
|
|
225
226
|
end
|
|
226
|
-
rescue StandardError
|
|
227
|
-
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
Degrade.note(:attached, "history_append_failed", e)
|
|
228
229
|
end
|
|
229
230
|
|
|
230
231
|
# Before a main-prompt read, and on the first ↑ of a walk at an open
|
|
@@ -255,8 +256,8 @@ module Samagotchi
|
|
|
255
256
|
@history_signature = signature
|
|
256
257
|
@history_seen = entries
|
|
257
258
|
history_lock.synchronize { add_others_lines(fresh) }
|
|
258
|
-
rescue StandardError
|
|
259
|
-
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
Degrade.note(:attached, "history_pickup_failed", e)
|
|
260
261
|
end
|
|
261
262
|
end
|
|
262
263
|
|
|
@@ -77,8 +77,11 @@ module Samagotchi
|
|
|
77
77
|
Log.session_id = @link.client.session_id
|
|
78
78
|
Log.info(:attached, "joined", session: @link.client.session_id, messages: exchange.size,
|
|
79
79
|
notes: messages.count { |m| ContextNote.note?(m) })
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
# The last turn's own prompt (Steer.turn_prompt?, as the page's
|
|
81
|
+
# turns): not Qwen's recovery line, nor input merged into the turn.
|
|
82
|
+
last_user = exchange.rindex { |m| Steer.turn_prompt?(m) }
|
|
83
|
+
from = last_user && messages.index { |m| m.equal?(exchange[last_user]) }
|
|
84
|
+
render_join_notes(messages, from)
|
|
82
85
|
return unless last_user
|
|
83
86
|
|
|
84
87
|
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
@@ -86,7 +89,6 @@ module Samagotchi
|
|
|
86
89
|
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
87
90
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
88
91
|
end
|
|
89
|
-
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
90
92
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
91
93
|
record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
|
|
92
94
|
failed = join_failed_record(record)
|
|
@@ -114,19 +116,21 @@ module Samagotchi
|
|
|
114
116
|
.find { |text| !text.empty? }
|
|
115
117
|
@screen.commit(last_lines(answer)) if answer
|
|
116
118
|
end
|
|
117
|
-
render_join_notes(messages, after_exchange: true)
|
|
119
|
+
render_join_notes(messages, from, after_exchange: true)
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
# What the last exchange did, as the live turn drew it: each saved
|
|
121
123
|
# call's tool row (action, params, status from its result, and its
|
|
122
124
|
# duration from +durations+, the turn's saved tool records) and a
|
|
123
|
-
# plugin's steer
|
|
124
|
-
# ticker's).
|
|
125
|
+
# plugin's steer and the user's input merged into the turn where they
|
|
126
|
+
# came. The step texts stay out, as live (the ticker's).
|
|
125
127
|
def render_join_steps(messages, from, durations: [])
|
|
126
128
|
records = durations.dup
|
|
127
129
|
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
128
130
|
if Steer.steer?(m)
|
|
129
131
|
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
132
|
+
elsif Steer.input?(m) && !Steer.turn_prompt?(m)
|
|
133
|
+
@screen.commit("input> #{m[:content]}")
|
|
130
134
|
elsif %w[model assistant].include?(m[:role].to_s)
|
|
131
135
|
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
132
136
|
Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
@@ -209,13 +213,13 @@ module Samagotchi
|
|
|
209
213
|
format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
|
|
210
214
|
end
|
|
211
215
|
|
|
212
|
-
# The context notes that came since the last prompt (
|
|
213
|
-
# session with none), after the
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return if after_exchange != !
|
|
216
|
+
# The context notes that came since the last prompt (+from+, its index
|
|
217
|
+
# in +messages+; all of them in a session with none), after the
|
|
218
|
+
# exchange the header shows.
|
|
219
|
+
def render_join_notes(messages, from, after_exchange: false)
|
|
220
|
+
return if after_exchange != !from.nil?
|
|
217
221
|
|
|
218
|
-
messages[(
|
|
222
|
+
messages[(from ? from + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
219
223
|
@screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
|
|
220
224
|
end
|
|
221
225
|
end
|
|
@@ -44,11 +44,6 @@ module Samagotchi
|
|
|
44
44
|
# @return [#call, nil]
|
|
45
45
|
attr_accessor :interrupt_handler
|
|
46
46
|
|
|
47
|
-
# Called with no arguments for each key a read takes (typing is
|
|
48
|
-
# activity for an idle clock; nobody sets it now).
|
|
49
|
-
# @return [#call, nil]
|
|
50
|
-
attr_accessor :key_handler
|
|
51
|
-
|
|
52
47
|
# @return [Boolean] a Reline read is open (it owns stdin)
|
|
53
48
|
def reading? = @reading == true
|
|
54
49
|
|
|
@@ -150,11 +145,6 @@ module Samagotchi
|
|
|
150
145
|
@rendered_screen.base_y = 0 if RelineSeam.screen
|
|
151
146
|
end
|
|
152
147
|
|
|
153
|
-
def update(key)
|
|
154
|
-
RelineSeam.key_handler&.call
|
|
155
|
-
super
|
|
156
|
-
end
|
|
157
|
-
|
|
158
148
|
def screen_height
|
|
159
149
|
screen = RelineSeam.screen
|
|
160
150
|
screen ? [super, screen.editor_budget].min : super
|
|
@@ -54,13 +54,6 @@ module Samagotchi
|
|
|
54
54
|
@shown = nil
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# The surface it draws on (no caller since the REPL went): the row
|
|
58
|
-
# is drawn there at the next change.
|
|
59
|
-
def surface=(surface)
|
|
60
|
-
@surface = surface
|
|
61
|
-
@shown = nil
|
|
62
|
-
end
|
|
63
|
-
|
|
64
57
|
def [](field) = @values[field]
|
|
65
58
|
|
|
66
59
|
# Change some fields; the row is redrawn when its text changed.
|
|
@@ -7,6 +7,7 @@ require_relative "file_ref"
|
|
|
7
7
|
require_relative "guardrails"
|
|
8
8
|
require_relative "vision_context"
|
|
9
9
|
require_relative "degrade"
|
|
10
|
+
require_relative "stream_event"
|
|
10
11
|
require_relative "log"
|
|
11
12
|
require_relative "edit_preview"
|
|
12
13
|
require_relative "tool_output_cut"
|
|
@@ -562,7 +563,7 @@ module Samagotchi
|
|
|
562
563
|
|
|
563
564
|
# A failing stream callback (a UI gone) must not break the call either.
|
|
564
565
|
def emit(callback, event)
|
|
565
|
-
|
|
566
|
+
StreamEvent.emit(callback, event, failure: "tool_event_callback_failed")
|
|
566
567
|
end
|
|
567
568
|
end
|
|
568
569
|
end
|