samagotchi 0.16.0 → 0.17.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 +86 -1
- data/README.md +2 -0
- data/docs/architecture.md +13 -0
- data/docs/cli.md +4 -2
- data/docs/configuration.md +4 -4
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +3 -2
- data/docs/sub-agent.md +1 -0
- data/docs/testing.md +47 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +38 -7
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -1
- data/lib/samagotchi/web/public/app.js +37 -28
- data/lib/samagotchi/web/public/notify.js +7 -3
- data/lib/samagotchi/web/public/stage_view.js +13 -1
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +26 -12
- data/lib/samagotchi/web/public/turn_events.js +13 -3
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +3 -1
|
@@ -7,6 +7,7 @@ require_relative "context_window"
|
|
|
7
7
|
require_relative "session"
|
|
8
8
|
require_relative "log_path"
|
|
9
9
|
require_relative "model_profile"
|
|
10
|
+
require_relative "model_overlay"
|
|
10
11
|
require_relative "thinking"
|
|
11
12
|
require_relative "served_model"
|
|
12
13
|
require_relative "host_registry"
|
|
@@ -39,7 +40,8 @@ module Samagotchi
|
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def fields(env: ENV)
|
|
42
|
-
|
|
43
|
+
default = model_name
|
|
44
|
+
model = session_model(env) || default
|
|
43
45
|
[
|
|
44
46
|
["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
|
|
45
47
|
["source", "#{SOURCE_DIR} (#{install_kind})"],
|
|
@@ -49,7 +51,8 @@ module Samagotchi
|
|
|
49
51
|
["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
|
|
50
52
|
["sessions", Session.default_state_dir(env: env)],
|
|
51
53
|
["log", log_path(env)],
|
|
52
|
-
["model", model
|
|
54
|
+
["model", model ? model_label(model, default, env) : "(not configured)"],
|
|
55
|
+
["model key", model ? model_key_for(model, env) : "-"],
|
|
53
56
|
["host", model ? host_for(model, env) : "-"],
|
|
54
57
|
["api key", model ? api_key_for(model, env) : "-"],
|
|
55
58
|
["loop", model ? loop_for(model, env) : "-"],
|
|
@@ -125,6 +128,46 @@ module Samagotchi
|
|
|
125
128
|
nil
|
|
126
129
|
end
|
|
127
130
|
|
|
131
|
+
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
132
|
+
PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
|
|
133
|
+
|
|
134
|
+
# The model of the session this chi self runs in (its execute exports
|
|
135
|
+
# SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
|
|
136
|
+
# outside a session's commands.
|
|
137
|
+
def session_model(env)
|
|
138
|
+
name = env[SESSION_MODEL_ENV].to_s.strip
|
|
139
|
+
name.empty? ? nil : name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# "splash:x (this session abcd1234; default main:y)", "main:y (this
|
|
143
|
+
# session abcd1234, the default)", or outside a session "main:y (default)".
|
|
144
|
+
def model_label(model, default, env)
|
|
145
|
+
return "#{model} (default)" unless session_model(env)
|
|
146
|
+
|
|
147
|
+
id = env[PARENT_SESSION_ENV].to_s
|
|
148
|
+
session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
|
|
149
|
+
return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
|
|
150
|
+
|
|
151
|
+
"#{model} (#{session}; default #{default || "not configured"})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def same_model?(one, other, env)
|
|
155
|
+
return true if one == other
|
|
156
|
+
|
|
157
|
+
ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
|
|
158
|
+
rescue StandardError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The memory overlay key (ModelOverlay.key_for the bare id, as the
|
|
163
|
+
# engine keys memory_write current_model_only): which `<name>.<key>.md`
|
|
164
|
+
# overlays are this model's.
|
|
165
|
+
def model_key_for(model, env)
|
|
166
|
+
ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
|
|
167
|
+
rescue StandardError
|
|
168
|
+
ModelOverlay.key_for(model) || "-"
|
|
169
|
+
end
|
|
170
|
+
|
|
128
171
|
def model_name
|
|
129
172
|
ModelProfile.required_model_name
|
|
130
173
|
rescue ArgumentError
|
|
@@ -147,6 +190,8 @@ module Samagotchi
|
|
|
147
190
|
entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
|
|
148
191
|
end
|
|
149
192
|
|
|
193
|
+
NO_PROPS = "reported per turn (the server has no /props)"
|
|
194
|
+
|
|
150
195
|
# chi self's one server call: a single-model llama.cpp answers any name
|
|
151
196
|
# with the model it loaded, and its /props names it (model_alias). One
|
|
152
197
|
# GET with the probe's short timeouts; a remote host isn't asked.
|
|
@@ -157,10 +202,11 @@ module Samagotchi
|
|
|
157
202
|
bare = target.bare_model
|
|
158
203
|
return "-" unless entry
|
|
159
204
|
return "reported per turn (remote host)" if entry.remote?
|
|
205
|
+
return NO_PROPS if entry.chat?
|
|
160
206
|
|
|
161
207
|
client = registry.client_for(entry)
|
|
162
208
|
props = client.server_props(model: bare)
|
|
163
|
-
return
|
|
209
|
+
return NO_PROPS if props.nil? || props.status == :http_error
|
|
164
210
|
return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
|
|
165
211
|
|
|
166
212
|
served = ServedModel.from_props(props)
|
|
@@ -37,6 +37,8 @@ module Samagotchi
|
|
|
37
37
|
chi send --wait [--timeout S] [--format json] ID
|
|
38
38
|
Sends a message to each session, as if typed in it: a turn starts,
|
|
39
39
|
or a running one picks it up. A stopped session's worker starts.
|
|
40
|
+
A session command (/model x, !cmd) runs as the command; --wait
|
|
41
|
+
then has no reply to wait for (exit 0, status command).
|
|
40
42
|
-m TEXT the message; stdin, when piped too, goes above it as a
|
|
41
43
|
quote (context); without -m, stdin is the message
|
|
42
44
|
--image PATH
|
|
@@ -64,7 +66,7 @@ module Samagotchi
|
|
|
64
66
|
(--wait) one JSON object on stdout, whatever the end:
|
|
65
67
|
status answered (text), question (question,
|
|
66
68
|
answer_with), running, or failed, canceled,
|
|
67
|
-
no_answer, error, worker_gone, stopped (detail)
|
|
69
|
+
no_answer, error, worker_gone, stopped, command (detail)
|
|
68
70
|
Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
|
|
69
71
|
question waits, 4 still running (--timeout), 130 Ctrl-C.
|
|
70
72
|
Only sessions on this machine. Answers show in the attached TUI
|
|
@@ -246,23 +248,27 @@ module Samagotchi
|
|
|
246
248
|
error_line("chi send: refused: #{refusal}")
|
|
247
249
|
return 1
|
|
248
250
|
end
|
|
251
|
+
# With images the web's way: idle (the message names it in the
|
|
252
|
+
# lists), the images copied in, then the message as a turn. So too a
|
|
253
|
+
# line that may be a session command (/model x): its worker's answer
|
|
254
|
+
# says whether it ran as one.
|
|
255
|
+
through_worker = images || command_like?(prompt)
|
|
249
256
|
begin
|
|
250
|
-
|
|
251
|
-
# lists), the images copied in, then the message as a turn.
|
|
252
|
-
start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
257
|
+
start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
|
|
253
258
|
session = SessionManager.spawn_session(**start, working_directory: options[:dir],
|
|
254
259
|
model_name: options[:model], state_dir: @state_dir)
|
|
255
260
|
rescue StandardError => e
|
|
256
261
|
error_line("chi send: could not start a session: #{e.message}")
|
|
257
262
|
return 1
|
|
258
263
|
end
|
|
259
|
-
if
|
|
264
|
+
if through_worker
|
|
260
265
|
return 1 unless deliver_new(session, prompt)
|
|
261
266
|
else
|
|
262
267
|
@info.puts("#{session.id} started")
|
|
263
268
|
end
|
|
264
269
|
@session_id = session.id
|
|
265
270
|
return 0 unless options[:wait]
|
|
271
|
+
return report_command(session.id) if @sent_command
|
|
266
272
|
|
|
267
273
|
wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
|
|
268
274
|
timeout: options[:timeout])
|
|
@@ -297,6 +303,7 @@ module Samagotchi
|
|
|
297
303
|
cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
|
|
298
304
|
baseline = baseline_of(session)
|
|
299
305
|
return 1 unless deliver(id, prompt)
|
|
306
|
+
return report_command(id) if @sent_command
|
|
300
307
|
|
|
301
308
|
wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
|
|
302
309
|
end
|
|
@@ -352,10 +359,36 @@ module Samagotchi
|
|
|
352
359
|
return false
|
|
353
360
|
end
|
|
354
361
|
|
|
355
|
-
@
|
|
362
|
+
@sent_command = command_ack?(result[:ack])
|
|
363
|
+
@info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
|
|
356
364
|
true
|
|
357
365
|
end
|
|
358
366
|
|
|
367
|
+
SENT_COMMAND = "sent as a session command"
|
|
368
|
+
|
|
369
|
+
# Whether the worker took the message as a session command (a POST
|
|
370
|
+
# /turn whose line is one answers with its command_id).
|
|
371
|
+
def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
|
|
372
|
+
|
|
373
|
+
# Whether +text+ may be a session command, which only its worker knows
|
|
374
|
+
# (a bundle's too).
|
|
375
|
+
def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
|
|
376
|
+
|
|
377
|
+
# --wait after a message that ran as a session command: no reply comes
|
|
378
|
+
# for it. Its output shows where the session is open.
|
|
379
|
+
# @return [Integer] 0
|
|
380
|
+
def report_command(id)
|
|
381
|
+
detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
|
|
382
|
+
if @json
|
|
383
|
+
@stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
|
|
384
|
+
@stdout.flush
|
|
385
|
+
@reported = true
|
|
386
|
+
else
|
|
387
|
+
@stderr.puts("#{id[0, 8]} #{detail}")
|
|
388
|
+
end
|
|
389
|
+
CLI::Exit::OK
|
|
390
|
+
end
|
|
391
|
+
|
|
359
392
|
# @return [Boolean] whether the message was queued
|
|
360
393
|
def deliver(id, prompt)
|
|
361
394
|
short = id[0, 8]
|
|
@@ -373,6 +406,12 @@ module Samagotchi
|
|
|
373
406
|
return false
|
|
374
407
|
end
|
|
375
408
|
|
|
409
|
+
@sent_command = command_ack?(result[:ack])
|
|
410
|
+
if @sent_command
|
|
411
|
+
@info.puts("#{short} #{SENT_COMMAND}")
|
|
412
|
+
return true
|
|
413
|
+
end
|
|
414
|
+
|
|
376
415
|
# A busy worker runs a message with images as its own next turn
|
|
377
416
|
# rather than merging it into the running one.
|
|
378
417
|
note = if owner.nil? then " (started its worker)"
|
|
@@ -17,15 +17,24 @@ module Samagotchi
|
|
|
17
17
|
#
|
|
18
18
|
# Each finished turn leaves a record (status, timings, model, prompt and
|
|
19
19
|
# completion tokens, tool calls, iterations, retries); session totals are
|
|
20
|
-
# sums over the records,
|
|
21
|
-
#
|
|
20
|
+
# sums over the records, kept as running counters as records are added, so
|
|
21
|
+
# a worker that stops and wakes again (a new collector) keeps counting:
|
|
22
|
+
# #session_id= loads the records already saved.
|
|
22
23
|
# Only prompt and completion counts, which every backend reports the same
|
|
23
24
|
# way, are kept.
|
|
24
25
|
#
|
|
25
|
-
# A snapshot is surfaced via Engine#session_state_snapshot
|
|
26
|
-
#
|
|
27
|
-
#
|
|
26
|
+
# A snapshot is surfaced via Engine#session_state_snapshot (the Bridge's
|
|
27
|
+
# /state, /snapshot, /stats and SSE frames) and persisted to a sibling
|
|
28
|
+
# analytics.json next to the session file. The live snapshot carries the
|
|
29
|
+
# totals and only the recent records (the newest finished turn's, plus any
|
|
30
|
+
# not yet saved, at most LIVE_RECORDS_CAP turns), so it stays the same size
|
|
31
|
+
# however long the session runs; analytics.json holds every record. The
|
|
32
|
+
# event trail itself goes to the debug log (LogSubscriber).
|
|
28
33
|
class SessionMetrics
|
|
34
|
+
# The most turns (with their tool calls) a live snapshot carries: the
|
|
35
|
+
# unsaved ones of a collector whose saves keep failing stay bounded.
|
|
36
|
+
LIVE_RECORDS_CAP = 20
|
|
37
|
+
|
|
29
38
|
# Per-turn transient state, reset on each turn_started.
|
|
30
39
|
TurnState = Struct.new(
|
|
31
40
|
:session_id,
|
|
@@ -118,6 +127,11 @@ module Samagotchi
|
|
|
118
127
|
@turn = nil
|
|
119
128
|
@turn_records = []
|
|
120
129
|
@tool_records = []
|
|
130
|
+
# How many turn records the last successful save (or the load) holds,
|
|
131
|
+
# and how many were loaded (a woken collector's live records start empty).
|
|
132
|
+
@persisted_turns = 0
|
|
133
|
+
@loaded_turns = 0
|
|
134
|
+
reset_totals
|
|
121
135
|
end
|
|
122
136
|
|
|
123
137
|
# /model: the window and prompt profile a turn reported were the old
|
|
@@ -213,51 +227,31 @@ module Samagotchi
|
|
|
213
227
|
# The session totals are sums over the turn records (the ones loaded from
|
|
214
228
|
# disk and this process's), with the running turn's calls, iterations and
|
|
215
229
|
# finished generations counted at once, so /stats agrees mid-turn.
|
|
230
|
+
# :turn_records / :tool_records are the recent ones only: the newest
|
|
231
|
+
# finished turn's and any not yet saved (at most LIVE_RECORDS_CAP turns),
|
|
232
|
+
# with the running turn's finished calls; the full history is the
|
|
233
|
+
# session's analytics.json.
|
|
216
234
|
# @return [Hash] the current summary snapshot
|
|
217
235
|
def snapshot
|
|
218
|
-
@mutex.synchronize
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
cancellations: records.count { |record| record[:status] == "canceled" },
|
|
227
|
-
tokens: {
|
|
228
|
-
prompt_sum: sum(records, :prompt_tokens_sum) + (turn&.prompt_sum || 0),
|
|
229
|
-
completion_sum: sum(records, :completion_tokens) + (turn&.completion_sum || 0),
|
|
230
|
-
source: combined_source(records.map { |record| record[:token_source] } + (turn&.token_sources || []))
|
|
231
|
-
},
|
|
232
|
-
context: context_block(records),
|
|
233
|
-
profile: @profile,
|
|
234
|
-
profile_source: @profile_source,
|
|
235
|
-
served_model: @served_model,
|
|
236
|
-
served_model_for: @served_model_for,
|
|
237
|
-
tool_calls_total: tools.size,
|
|
238
|
-
tool_calls_by_tool: tools.map { |tool| tool[:tool].to_s }.reject(&:empty?).tally,
|
|
239
|
-
tool_errors: @tool_records.count { |tool| tool[:status] == "error" },
|
|
240
|
-
iterations_total: sum(records, :iterations) + (turn&.iteration_count || 0),
|
|
241
|
-
gen_latency_ms: (sum(records, :gen_ms) + (turn&.gen_latency_accum || 0)).round,
|
|
242
|
-
retries: sum(records, :retries) + (turn&.retries || 0),
|
|
243
|
-
started_at: @started_at,
|
|
244
|
-
last_activity_at: @last_activity_at,
|
|
245
|
-
session_duration_ms: elapsed_ms(@session_started_monotonic),
|
|
246
|
-
turn_records: @turn_records.map(&:dup),
|
|
247
|
-
tool_records: @tool_records.map(&:dup),
|
|
248
|
-
active_turn: active_turn_snapshot,
|
|
249
|
-
active_tools: active_tool_snapshots
|
|
250
|
-
}
|
|
251
|
-
end
|
|
236
|
+
@mutex.synchronize { build_snapshot(:recent) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The served model a generation last reported and the name asked for
|
|
240
|
+
# then, without building a snapshot (Engine#served_model).
|
|
241
|
+
# @return [Hash] :served_model, :served_model_for
|
|
242
|
+
def served_report
|
|
243
|
+
@mutex.synchronize { { served_model: @served_model, served_model_for: @served_model_for } }
|
|
252
244
|
end
|
|
253
245
|
|
|
254
|
-
# Persist the summary snapshot to a sibling
|
|
255
|
-
# directory. Atomic write; failures are
|
|
256
|
-
# break the running session).
|
|
246
|
+
# Persist the summary snapshot, with every record, to a sibling
|
|
247
|
+
# analytics.json in the session directory. Atomic write; failures are
|
|
248
|
+
# swallowed (analytics must never break the running session).
|
|
257
249
|
# @param state_dir [String, nil]
|
|
258
250
|
# @return [Boolean] true on success
|
|
259
251
|
def persist(state_dir: nil)
|
|
260
|
-
sid = @mutex.synchronize
|
|
252
|
+
sid, data, turn_count = @mutex.synchronize do
|
|
253
|
+
[@session_id, build_snapshot(:all).merge(active_turn: nil, active_tools: []), @turn_records.size]
|
|
254
|
+
end
|
|
261
255
|
return false if sid.nil? || sid.to_s.empty?
|
|
262
256
|
|
|
263
257
|
# Omitting state_dir lets Session.session_dir fall back to the default
|
|
@@ -265,7 +259,8 @@ module Samagotchi
|
|
|
265
259
|
# File.join.
|
|
266
260
|
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
267
261
|
path = File.join(dir, "analytics.json")
|
|
268
|
-
AtomicFile.write(path, JSON.pretty_generate(
|
|
262
|
+
AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
|
|
263
|
+
@mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
|
|
269
264
|
true
|
|
270
265
|
rescue StandardError
|
|
271
266
|
false
|
|
@@ -273,6 +268,58 @@ module Samagotchi
|
|
|
273
268
|
|
|
274
269
|
private
|
|
275
270
|
|
|
271
|
+
# Caller holds the mutex. +records+: :recent (the live snapshot) or :all
|
|
272
|
+
# (analytics.json).
|
|
273
|
+
def build_snapshot(records_mode)
|
|
274
|
+
totals = @totals
|
|
275
|
+
turn = @turn
|
|
276
|
+
in_flight = turn ? turn.tool_calls_by_id.values : []
|
|
277
|
+
{
|
|
278
|
+
session_id: @session_id,
|
|
279
|
+
turns: @turn_records.size + (turn ? 1 : 0),
|
|
280
|
+
cancellations: totals[:cancellations],
|
|
281
|
+
tokens: {
|
|
282
|
+
prompt_sum: totals[:prompt_sum] + (turn&.prompt_sum || 0),
|
|
283
|
+
completion_sum: totals[:completion_sum] + (turn&.completion_sum || 0),
|
|
284
|
+
source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
|
|
285
|
+
},
|
|
286
|
+
context: context_block(@turn_records),
|
|
287
|
+
profile: @profile,
|
|
288
|
+
profile_source: @profile_source,
|
|
289
|
+
served_model: @served_model,
|
|
290
|
+
served_model_for: @served_model_for,
|
|
291
|
+
tool_calls_total: @tool_records.size + in_flight.size,
|
|
292
|
+
tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
|
|
293
|
+
tool_errors: totals[:tool_errors],
|
|
294
|
+
iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
|
|
295
|
+
gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
|
|
296
|
+
retries: totals[:retries] + (turn&.retries || 0),
|
|
297
|
+
started_at: @started_at,
|
|
298
|
+
last_activity_at: @last_activity_at,
|
|
299
|
+
session_duration_ms: elapsed_ms(@session_started_monotonic),
|
|
300
|
+
**records_view(records_mode),
|
|
301
|
+
active_turn: active_turn_snapshot,
|
|
302
|
+
active_tools: active_tool_snapshots
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The records a snapshot carries, copied. :all is every record; :recent
|
|
307
|
+
# the newest turn this collector finished (live before its save lands)
|
|
308
|
+
# and the unsaved ones (capped), and the tool records of those turns and
|
|
309
|
+
# of the running one. Records are appended in turn order, so both come
|
|
310
|
+
# from the tails. Caller holds the mutex.
|
|
311
|
+
def records_view(mode)
|
|
312
|
+
return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
|
|
313
|
+
|
|
314
|
+
unsaved = @turn_records.size - @persisted_turns
|
|
315
|
+
count = unsaved.clamp(@turn_records.size > @loaded_turns ? 1 : 0, LIVE_RECORDS_CAP)
|
|
316
|
+
turns = @turn_records.last(count)
|
|
317
|
+
ids = turns.to_set { |record| record[:id] }
|
|
318
|
+
ids << @turn.id if @turn
|
|
319
|
+
tools = @tool_records.reverse_each.take_while { |tool| ids.include?(tool[:turn_id]) }.reverse
|
|
320
|
+
{ turn_records: turns.map(&:dup), tool_records: tools.map(&:dup) }
|
|
321
|
+
end
|
|
322
|
+
|
|
276
323
|
def begin_turn(event)
|
|
277
324
|
@mutex.synchronize do
|
|
278
325
|
unless @session_id || event[:session_id].to_s.empty?
|
|
@@ -328,6 +375,7 @@ module Samagotchi
|
|
|
328
375
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
329
376
|
record.merge!(turn_token_fields(@turn))
|
|
330
377
|
@turn_records << record
|
|
378
|
+
count_turn(record)
|
|
331
379
|
end
|
|
332
380
|
@turn = nil
|
|
333
381
|
end
|
|
@@ -439,7 +487,7 @@ module Samagotchi
|
|
|
439
487
|
return unless active
|
|
440
488
|
|
|
441
489
|
finished_at = now
|
|
442
|
-
|
|
490
|
+
record = active.slice(
|
|
443
491
|
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
444
492
|
).merge(
|
|
445
493
|
status: status.empty? ? "ok" : status,
|
|
@@ -447,6 +495,8 @@ module Samagotchi
|
|
|
447
495
|
# Less a guardrail approval wait (ToolRunner's waited_ms).
|
|
448
496
|
duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
|
|
449
497
|
)
|
|
498
|
+
@tool_records << record
|
|
499
|
+
count_tool(record)
|
|
450
500
|
end
|
|
451
501
|
end
|
|
452
502
|
|
|
@@ -548,8 +598,15 @@ module Samagotchi
|
|
|
548
598
|
prior = JSON.parse(File.read(path))
|
|
549
599
|
return unless prior.is_a?(Hash)
|
|
550
600
|
|
|
551
|
-
|
|
601
|
+
loaded_turns = loaded_records(prior["turn_records"])
|
|
602
|
+
@turn_records = loaded_turns + @turn_records
|
|
552
603
|
@tool_records = loaded_records(prior["tool_records"]) + @tool_records
|
|
604
|
+
@persisted_turns = @loaded_turns = loaded_turns.size
|
|
605
|
+
# Once per collector: recount in record order (the tally's key order
|
|
606
|
+
# stays first-seen).
|
|
607
|
+
reset_totals
|
|
608
|
+
@turn_records.each { |record| count_turn(record) }
|
|
609
|
+
@tool_records.each { |record| count_tool(record) }
|
|
553
610
|
@started_at = earliest_timestamp(prior["started_at"], @started_at)
|
|
554
611
|
@last_activity_at ||= prior["last_activity_at"]
|
|
555
612
|
# The window last seen, until this process's first generation reports.
|
|
@@ -579,8 +636,40 @@ module Samagotchi
|
|
|
579
636
|
}
|
|
580
637
|
end
|
|
581
638
|
|
|
582
|
-
|
|
583
|
-
|
|
639
|
+
# The session totals over the finished records, as running counters
|
|
640
|
+
# (#snapshot adds the running turn). Caller holds the mutex.
|
|
641
|
+
def reset_totals
|
|
642
|
+
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
643
|
+
gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0 }
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def count_turn(record)
|
|
647
|
+
totals = @totals
|
|
648
|
+
totals[:cancellations] += 1 if record[:status] == "canceled"
|
|
649
|
+
totals[:prompt_sum] += number(record[:prompt_tokens_sum])
|
|
650
|
+
totals[:completion_sum] += number(record[:completion_tokens])
|
|
651
|
+
totals[:iterations] += number(record[:iterations])
|
|
652
|
+
totals[:gen_ms] += number(record[:gen_ms])
|
|
653
|
+
totals[:retries] += number(record[:retries])
|
|
654
|
+
source = record[:token_source]
|
|
655
|
+
totals[:token_sources] |= [source.to_s] unless source.nil?
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def count_tool(record)
|
|
659
|
+
@totals[:tool_errors] += 1 if record[:status] == "error"
|
|
660
|
+
tally_into(@totals[:by_tool], [record])
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def tally_into(counts, tools)
|
|
664
|
+
tools.each do |tool|
|
|
665
|
+
name = tool[:tool].to_s
|
|
666
|
+
counts[name] = counts.fetch(name, 0) + 1 unless name.empty?
|
|
667
|
+
end
|
|
668
|
+
counts
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def number(value)
|
|
672
|
+
value.is_a?(Numeric) ? value : 0
|
|
584
673
|
end
|
|
585
674
|
|
|
586
675
|
# server, estimate, or mixed when both kinds of counts (or a turn that
|
|
@@ -23,6 +23,15 @@ module Samagotchi
|
|
|
23
23
|
class SystemPrompt
|
|
24
24
|
AGENT_DESCRIPTION_FILE = "AGENT.md"
|
|
25
25
|
|
|
26
|
+
# The model a session runs on, as the prompt names it: the resolved
|
|
27
|
+
# ref, its host ("name, host:port" or url), the memory overlay key, and
|
|
28
|
+
# what a llama.cpp server says it serves when that differs (else nil).
|
|
29
|
+
# fallback_key: the key an alias typed for the model used to key overlays
|
|
30
|
+
# by (read when +key+ has none, as memory_read does); not shown.
|
|
31
|
+
ModelIdentity = Data.define(:ref, :host, :key, :served, :fallback_key) do
|
|
32
|
+
def initialize(ref:, host:, key:, served: nil, fallback_key: nil) = super
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# Always auto-loaded from the system scope unless muted (B-light).
|
|
27
36
|
DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
|
|
28
37
|
|
|
@@ -34,10 +43,12 @@ module Samagotchi
|
|
|
34
43
|
# @param tools [#call] → Tools::Registry
|
|
35
44
|
# @param session [#call] → Session, nil
|
|
36
45
|
# @param thinking [#call] → Symbol, the effective model's level (Thinking)
|
|
46
|
+
# @param model [#call] → ModelIdentity, nil (the section is left out)
|
|
37
47
|
# @param memories [Array<String>] the --memory list
|
|
38
48
|
# @param muted_memory_names [Array<String>] normalized (MutedMemories)
|
|
39
|
-
def initialize(profile:, tools:, session:, thinking:, memories: [], muted_memory_names: [])
|
|
49
|
+
def initialize(profile:, tools:, session:, thinking:, model: -> {}, memories: [], muted_memory_names: [])
|
|
40
50
|
@profile_lookup = profile
|
|
51
|
+
@model_lookup = model
|
|
41
52
|
@tools_lookup = tools
|
|
42
53
|
@session_lookup = session
|
|
43
54
|
@thinking_lookup = thinking
|
|
@@ -223,7 +234,7 @@ module Samagotchi
|
|
|
223
234
|
"Project memories:\n#{project_index}",
|
|
224
235
|
"System memories:\n#{system_index}"
|
|
225
236
|
].join("\n\n")
|
|
226
|
-
[thinking_token + base, rg_guidance, project_description, project_location, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
|
|
237
|
+
[thinking_token + base, rg_guidance, project_description, project_location, current_model, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
|
|
227
238
|
end
|
|
228
239
|
|
|
229
240
|
# B-light: auto-preload the built-in identity memory.
|
|
@@ -235,7 +246,7 @@ module Samagotchi
|
|
|
235
246
|
DEFAULT_SYSTEM_MEMORIES.each do |name|
|
|
236
247
|
next if memory_muted?(name)
|
|
237
248
|
|
|
238
|
-
body = Tools::MemoryRead.call(name, scope: "system")
|
|
249
|
+
body = Tools::MemoryRead.call(name, scope: "system", **overlay_keys)
|
|
239
250
|
next if body.start_with?("Error:")
|
|
240
251
|
next if body.strip.empty?
|
|
241
252
|
|
|
@@ -248,6 +259,17 @@ module Samagotchi
|
|
|
248
259
|
|
|
249
260
|
# ── Memory helpers ─────────────────────────────────────────────────────────
|
|
250
261
|
|
|
262
|
+
# The session model's overlay keys, so the prompt's own memory bodies
|
|
263
|
+
# (identity, preloads) get `<name>.<key>.md` as memory_read gives it.
|
|
264
|
+
def overlay_keys
|
|
265
|
+
model = @model_lookup.call
|
|
266
|
+
return {} unless model&.key
|
|
267
|
+
|
|
268
|
+
{ model_key: model.key, fallback_model_key: model.fallback_key }
|
|
269
|
+
rescue StandardError
|
|
270
|
+
{}
|
|
271
|
+
end
|
|
272
|
+
|
|
251
273
|
# The scope's index text without the muted memories' lines.
|
|
252
274
|
def read_memory_index(scope)
|
|
253
275
|
BundleNeeds.annotate_index(MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names), scope)
|
|
@@ -297,7 +319,7 @@ module Samagotchi
|
|
|
297
319
|
names = raw.split(",").map(&:strip).reject(&:empty?)
|
|
298
320
|
names.each do |name|
|
|
299
321
|
scope, actual_name = split_memory_scope(name)
|
|
300
|
-
body = Tools::MemoryRead.call(actual_name, scope: scope)
|
|
322
|
+
body = Tools::MemoryRead.call(actual_name, scope: scope, **overlay_keys)
|
|
301
323
|
if body.start_with?("Error:")
|
|
302
324
|
source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
|
|
303
325
|
Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
|
|
@@ -378,6 +400,25 @@ module Samagotchi
|
|
|
378
400
|
path
|
|
379
401
|
end
|
|
380
402
|
|
|
403
|
+
# The model this session runs on. A model guesses its name from
|
|
404
|
+
# training (a fine-tune often knows only its base model's), so the line
|
|
405
|
+
# says to answer from here. It depends only on the effective model, and
|
|
406
|
+
# the prompt is rebuilt only on a switch (#reset!): no per-turn churn.
|
|
407
|
+
def current_model
|
|
408
|
+
model = @model_lookup.call
|
|
409
|
+
return nil unless model && !model.ref.to_s.empty?
|
|
410
|
+
|
|
411
|
+
details = [model.host, model.key && "model key #{model.key}", model.served && "the server says it serves #{model.served}"]
|
|
412
|
+
details = details.compact.reject(&:empty?)
|
|
413
|
+
where = details.empty? ? "" : " (#{details.join("; ")})"
|
|
414
|
+
"Model: this session runs on #{model.ref}#{where}.\n" \
|
|
415
|
+
"Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
|
|
416
|
+
"its base model's name, but this session runs what is named here; it changes only with /model. " \
|
|
417
|
+
"Guidance for this model only goes in memory overlays: memory_write current_model_only: true."
|
|
418
|
+
rescue StandardError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
381
422
|
# Fixed for the session's lifetime, so it doesn't churn the prompt cache.
|
|
382
423
|
# Omitted until a session is attached (run_turn / TerminalUI set it). A
|
|
383
424
|
# delegated session (parent_id set) is told who reads its reply.
|
|
@@ -310,7 +310,13 @@ module Samagotchi
|
|
|
310
310
|
return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
|
|
311
311
|
|
|
312
312
|
text = line.strip
|
|
313
|
-
|
|
313
|
+
if @question
|
|
314
|
+
# /detach leaves the question open, as Ctrl-D does; any other
|
|
315
|
+
# line (/exit too) is read as the answer.
|
|
316
|
+
return submit(nil) if command_registry.lookup_local(text)&.id == :detach
|
|
317
|
+
|
|
318
|
+
return answer_question(text)
|
|
319
|
+
end
|
|
314
320
|
|
|
315
321
|
# The terminal's own commands (SessionCommands registers them, the
|
|
316
322
|
# REPL reads the same words). Before the continue offer: the
|
|
@@ -449,7 +455,12 @@ module Samagotchi
|
|
|
449
455
|
# too, so nothing waits here.
|
|
450
456
|
def send_command(line)
|
|
451
457
|
reply = @client.post_command(line: line, client_id: @client_id)
|
|
452
|
-
|
|
458
|
+
if reply.status == 202
|
|
459
|
+
# Input from a pipe ends once its output is in (#command_ran).
|
|
460
|
+
command_id = reply.json&.fetch("command_id", nil)
|
|
461
|
+
@open_ids << command_id if command_id && @wait_at_eof
|
|
462
|
+
return
|
|
463
|
+
end
|
|
453
464
|
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
454
465
|
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
455
466
|
|
|
@@ -473,6 +484,7 @@ module Samagotchi
|
|
|
473
484
|
end
|
|
474
485
|
|
|
475
486
|
def command_ran(event)
|
|
487
|
+
@open_ids.delete(event[:command_id])
|
|
476
488
|
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
|
|
477
489
|
output = event[:output].to_s
|
|
478
490
|
if output.empty?
|
|
@@ -939,7 +951,8 @@ module Samagotchi
|
|
|
939
951
|
return if text.empty?
|
|
940
952
|
|
|
941
953
|
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
942
|
-
|
|
954
|
+
# As if typed: a session command runs as the command.
|
|
955
|
+
command_registry.command?(text) ? send_command(text) : send_prompt(text)
|
|
943
956
|
end
|
|
944
957
|
|
|
945
958
|
def render_join_header(messages)
|
|
@@ -63,7 +63,8 @@ module Samagotchi
|
|
|
63
63
|
when :generation_chunk
|
|
64
64
|
@view.generation_feedback_chunk(event)
|
|
65
65
|
when :tool_call_started
|
|
66
|
-
|
|
66
|
+
# A joined running call (a snapshot's replay) started elapsed_ms ago.
|
|
67
|
+
@tool_started_at[tool_call_key(event)] = @clock.call - (event[:elapsed_ms].to_f / 1000)
|
|
67
68
|
@view.tool_call_feedback_started(event)
|
|
68
69
|
when :tool_call_completed
|
|
69
70
|
@view.clear_generation_retry
|
|
@@ -232,6 +232,10 @@ module Samagotchi
|
|
|
232
232
|
@engine.session = session
|
|
233
233
|
messages = messages_for(session)
|
|
234
234
|
|
|
235
|
+
# As typed at the prompt: a session command (/models, !ls) runs as
|
|
236
|
+
# the command, not as a turn.
|
|
237
|
+
return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
|
|
238
|
+
|
|
235
239
|
if @prompt && @non_interactive
|
|
236
240
|
# Headless / CI mode: run directly without TTY rendering.
|
|
237
241
|
ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
|
|
@@ -281,6 +285,22 @@ module Samagotchi
|
|
|
281
285
|
|
|
282
286
|
EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
|
|
283
287
|
|
|
288
|
+
# `chi -p "/models" --non-interactive`: the command's output; a refused
|
|
289
|
+
# one fails the run (bin/chi exits 1). The session is saved only when
|
|
290
|
+
# the command changed it.
|
|
291
|
+
# @return [Symbol, nil] :turn_failed when the command was refused
|
|
292
|
+
def run_one_shot_command(session)
|
|
293
|
+
line = @prompt.strip
|
|
294
|
+
result = begin
|
|
295
|
+
@commands.run(line)
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
|
|
298
|
+
end
|
|
299
|
+
@surface.commit(result.output) unless result.output.to_s.empty?
|
|
300
|
+
session.save unless Array(result.changed).empty?
|
|
301
|
+
:turn_failed if result.status == :error
|
|
302
|
+
end
|
|
303
|
+
|
|
284
304
|
# Delete the scratch session, the last thing the REPL does. Quiet after
|
|
285
305
|
# a one-shot or on the way out of an error or a signal.
|
|
286
306
|
def delete_scratch(session, quiet:)
|
|
@@ -1184,6 +1204,10 @@ module Samagotchi
|
|
|
1184
1204
|
|
|
1185
1205
|
# A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
|
|
1186
1206
|
def read_choice_line(question_prompt)
|
|
1207
|
+
# Off a terminal the ? prompt can't be taken back: at the input's end
|
|
1208
|
+
# it would stay bare above the summary. eof? waits as gets would.
|
|
1209
|
+
return nil if !$stdin.tty? && $stdin.eof?
|
|
1210
|
+
|
|
1187
1211
|
@surface.set_slot(:editor, [question_prompt])
|
|
1188
1212
|
line = $stdin.gets
|
|
1189
1213
|
# A pipe doesn't echo: the line completes the ? prompt here, once,
|