samagotchi 0.15.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- 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 +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- 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/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
|
@@ -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,
|
|
@@ -83,6 +92,23 @@ module Samagotchi
|
|
|
83
92
|
nil
|
|
84
93
|
end
|
|
85
94
|
|
|
95
|
+
# One turn's tool records from the session's saved analytics.json, in
|
|
96
|
+
# call order (iteration, then call_index), string-keyed as saved: what a
|
|
97
|
+
# join replays the turn's tool rows' durations from. Empty without a turn
|
|
98
|
+
# id (an older session's prompt), the file, or records.
|
|
99
|
+
# @param session_dir [String]
|
|
100
|
+
# @param turn_id [String, nil]
|
|
101
|
+
# @return [Array<Hash>]
|
|
102
|
+
def self.saved_tool_records(session_dir, turn_id)
|
|
103
|
+
return [] if turn_id.to_s.empty?
|
|
104
|
+
|
|
105
|
+
records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
|
|
106
|
+
Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
|
|
107
|
+
.sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
|
|
108
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
109
|
+
[]
|
|
110
|
+
end
|
|
111
|
+
|
|
86
112
|
# "ctx 12%" for the session lists, "" when unknown.
|
|
87
113
|
# @param pct [Float, nil]
|
|
88
114
|
# @return [String]
|
|
@@ -101,6 +127,11 @@ module Samagotchi
|
|
|
101
127
|
@turn = nil
|
|
102
128
|
@turn_records = []
|
|
103
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
|
|
104
135
|
end
|
|
105
136
|
|
|
106
137
|
# /model: the window and prompt profile a turn reported were the old
|
|
@@ -196,51 +227,31 @@ module Samagotchi
|
|
|
196
227
|
# The session totals are sums over the turn records (the ones loaded from
|
|
197
228
|
# disk and this process's), with the running turn's calls, iterations and
|
|
198
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.
|
|
199
234
|
# @return [Hash] the current summary snapshot
|
|
200
235
|
def snapshot
|
|
201
|
-
@mutex.synchronize
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
cancellations: records.count { |record| record[:status] == "canceled" },
|
|
210
|
-
tokens: {
|
|
211
|
-
prompt_sum: sum(records, :prompt_tokens_sum) + (turn&.prompt_sum || 0),
|
|
212
|
-
completion_sum: sum(records, :completion_tokens) + (turn&.completion_sum || 0),
|
|
213
|
-
source: combined_source(records.map { |record| record[:token_source] } + (turn&.token_sources || []))
|
|
214
|
-
},
|
|
215
|
-
context: context_block(records),
|
|
216
|
-
profile: @profile,
|
|
217
|
-
profile_source: @profile_source,
|
|
218
|
-
served_model: @served_model,
|
|
219
|
-
served_model_for: @served_model_for,
|
|
220
|
-
tool_calls_total: tools.size,
|
|
221
|
-
tool_calls_by_tool: tools.map { |tool| tool[:tool].to_s }.reject(&:empty?).tally,
|
|
222
|
-
tool_errors: @tool_records.count { |tool| tool[:status] == "error" },
|
|
223
|
-
iterations_total: sum(records, :iterations) + (turn&.iteration_count || 0),
|
|
224
|
-
gen_latency_ms: (sum(records, :gen_ms) + (turn&.gen_latency_accum || 0)).round,
|
|
225
|
-
retries: sum(records, :retries) + (turn&.retries || 0),
|
|
226
|
-
started_at: @started_at,
|
|
227
|
-
last_activity_at: @last_activity_at,
|
|
228
|
-
session_duration_ms: elapsed_ms(@session_started_monotonic),
|
|
229
|
-
turn_records: @turn_records.map(&:dup),
|
|
230
|
-
tool_records: @tool_records.map(&:dup),
|
|
231
|
-
active_turn: active_turn_snapshot,
|
|
232
|
-
active_tools: active_tool_snapshots
|
|
233
|
-
}
|
|
234
|
-
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 } }
|
|
235
244
|
end
|
|
236
245
|
|
|
237
|
-
# Persist the summary snapshot to a sibling
|
|
238
|
-
# directory. Atomic write; failures are
|
|
239
|
-
# 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).
|
|
240
249
|
# @param state_dir [String, nil]
|
|
241
250
|
# @return [Boolean] true on success
|
|
242
251
|
def persist(state_dir: nil)
|
|
243
|
-
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
|
|
244
255
|
return false if sid.nil? || sid.to_s.empty?
|
|
245
256
|
|
|
246
257
|
# Omitting state_dir lets Session.session_dir fall back to the default
|
|
@@ -248,7 +259,8 @@ module Samagotchi
|
|
|
248
259
|
# File.join.
|
|
249
260
|
FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
|
|
250
261
|
path = File.join(dir, "analytics.json")
|
|
251
|
-
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 }
|
|
252
264
|
true
|
|
253
265
|
rescue StandardError
|
|
254
266
|
false
|
|
@@ -256,6 +268,58 @@ module Samagotchi
|
|
|
256
268
|
|
|
257
269
|
private
|
|
258
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
|
+
|
|
259
323
|
def begin_turn(event)
|
|
260
324
|
@mutex.synchronize do
|
|
261
325
|
unless @session_id || event[:session_id].to_s.empty?
|
|
@@ -311,6 +375,7 @@ module Samagotchi
|
|
|
311
375
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
312
376
|
record.merge!(turn_token_fields(@turn))
|
|
313
377
|
@turn_records << record
|
|
378
|
+
count_turn(record)
|
|
314
379
|
end
|
|
315
380
|
@turn = nil
|
|
316
381
|
end
|
|
@@ -422,7 +487,7 @@ module Samagotchi
|
|
|
422
487
|
return unless active
|
|
423
488
|
|
|
424
489
|
finished_at = now
|
|
425
|
-
|
|
490
|
+
record = active.slice(
|
|
426
491
|
:id, :turn_id, :iteration, :call_index, :tool, :started_at
|
|
427
492
|
).merge(
|
|
428
493
|
status: status.empty? ? "ok" : status,
|
|
@@ -430,6 +495,8 @@ module Samagotchi
|
|
|
430
495
|
# Less a guardrail approval wait (ToolRunner's waited_ms).
|
|
431
496
|
duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
|
|
432
497
|
)
|
|
498
|
+
@tool_records << record
|
|
499
|
+
count_tool(record)
|
|
433
500
|
end
|
|
434
501
|
end
|
|
435
502
|
|
|
@@ -531,8 +598,15 @@ module Samagotchi
|
|
|
531
598
|
prior = JSON.parse(File.read(path))
|
|
532
599
|
return unless prior.is_a?(Hash)
|
|
533
600
|
|
|
534
|
-
|
|
601
|
+
loaded_turns = loaded_records(prior["turn_records"])
|
|
602
|
+
@turn_records = loaded_turns + @turn_records
|
|
535
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) }
|
|
536
610
|
@started_at = earliest_timestamp(prior["started_at"], @started_at)
|
|
537
611
|
@last_activity_at ||= prior["last_activity_at"]
|
|
538
612
|
# The window last seen, until this process's first generation reports.
|
|
@@ -562,8 +636,40 @@ module Samagotchi
|
|
|
562
636
|
}
|
|
563
637
|
end
|
|
564
638
|
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
567
673
|
end
|
|
568
674
|
|
|
569
675
|
# server, estimate, or mixed when both kinds of counts (or a turn that
|
|
@@ -148,15 +148,17 @@ module Samagotchi
|
|
|
148
148
|
# ctx_pct: how full the context was after the last turn, or nil
|
|
149
149
|
# waiting: the kind of question it waits on (question, approval,
|
|
150
150
|
# hook: chi answer or the web answers it), or nil; waiting_id: its
|
|
151
|
-
# id, for chi answer --question
|
|
151
|
+
# id, for chi answer --question; relayed_to: the parent session (short
|
|
152
|
+
# id) whose card it also waits in (the approval relay), or nil
|
|
152
153
|
keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
|
|
153
|
-
waiting_id]
|
|
154
|
+
waiting_id relayed_to]
|
|
154
155
|
@stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
|
|
155
156
|
when "tsv"
|
|
156
157
|
summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
|
|
157
158
|
else
|
|
158
159
|
summaries.each do |summary|
|
|
159
|
-
state = if summary[:
|
|
160
|
+
state = if summary[:relayed_to] then "waiting (in parent #{summary[:relayed_to]})"
|
|
161
|
+
elsif summary[:waiting] then "waiting"
|
|
160
162
|
elsif summary[:busy] then "running"
|
|
161
163
|
else summary[:live] ? "live" : summary[:status].to_s
|
|
162
164
|
end
|
|
@@ -206,7 +208,11 @@ module Samagotchi
|
|
|
206
208
|
)
|
|
207
209
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
208
210
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
209
|
-
|
|
211
|
+
waiting = s.waiting_question(live: !!live)
|
|
212
|
+
status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
|
|
213
|
+
elsif waiting then "waiting"
|
|
214
|
+
else s.status
|
|
215
|
+
end
|
|
210
216
|
"#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
211
217
|
end
|
|
212
218
|
if project
|
|
@@ -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.
|
|
@@ -38,7 +38,7 @@ module Samagotchi
|
|
|
38
38
|
# @param muted_memories [Array<String>] --mute: hidden from a new session
|
|
39
39
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
40
40
|
# :failed when the --model switch didn't go through, or (input from a
|
|
41
|
-
# pipe) :turn_failed / :unanswered (AttachedLoop#run)
|
|
41
|
+
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
42
42
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
43
43
|
memories: [], muted_memories: [])
|
|
44
44
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
@@ -56,6 +56,7 @@ module Samagotchi
|
|
|
56
56
|
close_surface(surface)
|
|
57
57
|
end
|
|
58
58
|
report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
|
|
59
|
+
report_ended(ended)
|
|
59
60
|
ended
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -68,9 +69,17 @@ module Samagotchi
|
|
|
68
69
|
err.flush
|
|
69
70
|
end
|
|
70
71
|
|
|
72
|
+
# A turn of ours that ended with no answer (input from a pipe): one
|
|
73
|
+
# line on stderr, as the in-process `chi -p --non-interactive` says
|
|
74
|
+
# it, so a script doesn't take the silence for an answer (exit 1).
|
|
75
|
+
def report_ended(ended, err: $stderr)
|
|
76
|
+
err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
|
|
77
|
+
end
|
|
78
|
+
|
|
71
79
|
# chi's exit status after #run: 0 detached, 3 a question left waiting
|
|
72
80
|
# for an answer (input from a pipe ran out; chi --attach or chi answer
|
|
73
|
-
# answers it), 1 anything else (a failed turn,
|
|
81
|
+
# answers it), 1 anything else (a failed turn, an empty answer, the
|
|
82
|
+
# worker gone).
|
|
74
83
|
# @param ended [Symbol] what #run returned
|
|
75
84
|
# @return [Integer]
|
|
76
85
|
def exit_status(ended)
|
|
@@ -20,6 +20,7 @@ require_relative "../model_profile"
|
|
|
20
20
|
require_relative "../output_formatter"
|
|
21
21
|
require_relative "../session_commands"
|
|
22
22
|
require_relative "../session_manager"
|
|
23
|
+
require_relative "../session_metrics"
|
|
23
24
|
require_relative "../guardrails/parent_approvals"
|
|
24
25
|
require_relative "../tool_activity"
|
|
25
26
|
require_relative "../web/message_parts"
|
|
@@ -143,8 +144,9 @@ module Samagotchi
|
|
|
143
144
|
# asks it to exit too, and it does unless something still needs it.
|
|
144
145
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
145
146
|
# :failed when the first command (--model) didn't go through, or
|
|
146
|
-
# (wait_at_eof) :turn_failed when one of this run's prompts failed
|
|
147
|
-
# :
|
|
147
|
+
# (wait_at_eof) :turn_failed when one of this run's prompts failed,
|
|
148
|
+
# :empty_answer when one ended with no answer, and :unanswered when
|
|
149
|
+
# a question came after the input ended
|
|
148
150
|
# @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
|
|
149
151
|
# Interrupt = Ctrl-C); defaults to Reline
|
|
150
152
|
def run(input: nil)
|
|
@@ -259,7 +261,9 @@ module Samagotchi
|
|
|
259
261
|
ask(event[:pending_question])
|
|
260
262
|
when :question_answered then question_answered(event)
|
|
261
263
|
when :question_cancelled
|
|
262
|
-
close_question(
|
|
264
|
+
close_question(@question.closed_text(event[:reason])) if @question
|
|
265
|
+
# Relayed to the parent's card, or no longer: the widget says so.
|
|
266
|
+
when :question_relay then question_relayed(event)
|
|
263
267
|
# Written while the session sat idle: shown at the open prompt. One
|
|
264
268
|
# collected just as a turn started describes the chat before it.
|
|
265
269
|
when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
|
|
@@ -306,7 +310,13 @@ module Samagotchi
|
|
|
306
310
|
return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
|
|
307
311
|
|
|
308
312
|
text = line.strip
|
|
309
|
-
|
|
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
|
|
310
320
|
|
|
311
321
|
# The terminal's own commands (SessionCommands registers them, the
|
|
312
322
|
# REPL reads the same words). Before the continue offer: the
|
|
@@ -445,7 +455,12 @@ module Samagotchi
|
|
|
445
455
|
# too, so nothing waits here.
|
|
446
456
|
def send_command(line)
|
|
447
457
|
reply = @client.post_command(line: line, client_id: @client_id)
|
|
448
|
-
|
|
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
|
|
449
464
|
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
450
465
|
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
451
466
|
|
|
@@ -469,6 +484,7 @@ module Samagotchi
|
|
|
469
484
|
end
|
|
470
485
|
|
|
471
486
|
def command_ran(event)
|
|
487
|
+
@open_ids.delete(event[:command_id])
|
|
472
488
|
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
|
|
473
489
|
output = event[:output].to_s
|
|
474
490
|
if output.empty?
|
|
@@ -569,6 +585,7 @@ module Samagotchi
|
|
|
569
585
|
id = (event[:origin] || {})[:enqueued_id]
|
|
570
586
|
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
571
587
|
@own_failed = true if ours && event[:type] == :turn_failed
|
|
588
|
+
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
572
589
|
@open_ids.delete(id)
|
|
573
590
|
@open_ids.subtract(@merged_ids)
|
|
574
591
|
@merged_ids.clear
|
|
@@ -591,7 +608,9 @@ module Samagotchi
|
|
|
591
608
|
# The input ended and nothing of ours is left to wait for.
|
|
592
609
|
def end_of_input
|
|
593
610
|
detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
|
|
594
|
-
|
|
611
|
+
return :turn_failed if @own_failed
|
|
612
|
+
|
|
613
|
+
@own_empty ? :empty_answer : :detached
|
|
595
614
|
end
|
|
596
615
|
|
|
597
616
|
def unanswered_question(pending)
|
|
@@ -757,6 +776,13 @@ module Samagotchi
|
|
|
757
776
|
end
|
|
758
777
|
end
|
|
759
778
|
|
|
779
|
+
def question_relayed(event)
|
|
780
|
+
return unless @question && @question.id == event[:id].to_s
|
|
781
|
+
|
|
782
|
+
@question.relayed_to = event[:relayed_to]
|
|
783
|
+
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
784
|
+
end
|
|
785
|
+
|
|
760
786
|
def question_answered(event)
|
|
761
787
|
return unless @question
|
|
762
788
|
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
@@ -925,7 +951,8 @@ module Samagotchi
|
|
|
925
951
|
return if text.empty?
|
|
926
952
|
|
|
927
953
|
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
928
|
-
|
|
954
|
+
# As if typed: a session command runs as the command.
|
|
955
|
+
command_registry.command?(text) ? send_command(text) : send_prompt(text)
|
|
929
956
|
end
|
|
930
957
|
|
|
931
958
|
def render_join_header(messages)
|
|
@@ -939,39 +966,76 @@ module Samagotchi
|
|
|
939
966
|
|
|
940
967
|
@screen.commit(prompt_line(nil, exchange[last_user][:content]))
|
|
941
968
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
#
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
969
|
+
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
970
|
+
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
971
|
+
# A turn that ended with no answer: its notice, as live.
|
|
972
|
+
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
973
|
+
if empty
|
|
974
|
+
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
975
|
+
else
|
|
976
|
+
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
977
|
+
# be only a tool call.
|
|
978
|
+
answer = exchange[(last_user + 1)..].reverse_each
|
|
979
|
+
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
980
|
+
.find { |text| !text.empty? }
|
|
981
|
+
@screen.commit(last_lines(answer)) if answer
|
|
982
|
+
end
|
|
949
983
|
render_join_notes(messages, after_exchange: true)
|
|
950
984
|
end
|
|
951
985
|
|
|
952
986
|
# What the last exchange did, as the live turn drew it: each saved
|
|
953
|
-
# call's tool row (action, params, status from its result
|
|
954
|
-
# duration
|
|
955
|
-
# where it came. The step texts stay out, as live (the
|
|
956
|
-
|
|
987
|
+
# call's tool row (action, params, status from its result, and its
|
|
988
|
+
# duration from +durations+, the turn's saved tool records) and a
|
|
989
|
+
# plugin's steer where it came. The step texts stay out, as live (the
|
|
990
|
+
# ticker's).
|
|
991
|
+
def render_join_steps(messages, from, durations: [])
|
|
992
|
+
records = durations.dup
|
|
957
993
|
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
958
994
|
if Steer.steer?(m)
|
|
959
995
|
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
960
996
|
elsif %w[model assistant].include?(m[:role].to_s)
|
|
961
997
|
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
962
|
-
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each
|
|
998
|
+
Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
999
|
+
@screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
|
|
1000
|
+
end
|
|
963
1001
|
end
|
|
964
1002
|
end
|
|
965
1003
|
end
|
|
966
1004
|
|
|
1005
|
+
# The prompt's turn's saved tool records (SessionMetrics, in this
|
|
1006
|
+
# machine's state dir, as #attach_images): none for a prompt with no
|
|
1007
|
+
# turn id (an older session) or a session with no analytics.
|
|
1008
|
+
def join_tool_records(prompt)
|
|
1009
|
+
SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
|
|
1010
|
+
rescue Session::InvalidId
|
|
1011
|
+
[]
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# The duration of +part+'s call: the next record of its tool, in call
|
|
1015
|
+
# order, consumed with the ones it skipped. By tool and order, not by
|
|
1016
|
+
# iteration: an empty retry spends an iteration that left no message.
|
|
1017
|
+
# nil (no duration shown) for a call that left none: it never
|
|
1018
|
+
# finished, or a worker before turn ids saved it.
|
|
1019
|
+
def join_duration(records, part)
|
|
1020
|
+
return nil if part[:output].nil?
|
|
1021
|
+
|
|
1022
|
+
at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
|
|
1023
|
+
return nil unless at
|
|
1024
|
+
|
|
1025
|
+
duration = records[at]["duration_ms"]
|
|
1026
|
+
records.shift(at + 1)
|
|
1027
|
+
duration.is_a?(Numeric) ? duration : nil
|
|
1028
|
+
end
|
|
1029
|
+
|
|
967
1030
|
# A saved call's row; "no result" for a call the turn never answered
|
|
968
1031
|
# (it ended first).
|
|
969
|
-
def join_tool_line(part)
|
|
1032
|
+
def join_tool_line(part, duration_ms: nil)
|
|
970
1033
|
output = part[:output]
|
|
971
1034
|
status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
|
|
972
1035
|
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
973
1036
|
params: part[:params], status: status }
|
|
974
|
-
"#{format_tool_activity_line(activity
|
|
1037
|
+
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1038
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
|
|
975
1039
|
end
|
|
976
1040
|
|
|
977
1041
|
# The context notes that came since the last prompt (all of them in a
|