samagotchi 0.16.0 → 0.18.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 +149 -1
- data/README.md +2 -0
- data/docs/architecture.md +38 -2
- data/docs/cli.md +37 -9
- data/docs/configuration.md +8 -6
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +41 -0
- data/docs/plugins.md +5 -2
- data/docs/sessions.md +8 -6
- data/docs/sub-agent.md +43 -4
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
- data/lib/samagotchi/bridge.rb +86 -14
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +121 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +1 -0
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +73 -19
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- 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 +136 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +46 -6
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +137 -48
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +46 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +35 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +4 -0
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +189 -43
- data/lib/samagotchi/web/public/app.js +157 -51
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +12 -5
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- 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 +67 -12
- data/lib/samagotchi/web/public/turn_events.js +74 -3
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +58 -60
- data/lib/samagotchi/worker_sidecar.rb +7 -1
- metadata +10 -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,
|
|
@@ -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
|
|
@@ -191,11 +191,12 @@ module Samagotchi
|
|
|
191
191
|
order ||= "desc"
|
|
192
192
|
sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
|
|
193
193
|
include_archived: include_archived)
|
|
194
|
-
# The saved recap's first sentence, else the last prompt
|
|
194
|
+
# The saved recap's first sentence, else the last prompt (or the first
|
|
195
|
+
# message, before any turn), cut as before.
|
|
195
196
|
state_dir = Samagotchi::Session.default_state_dir
|
|
196
197
|
list_text = lambda do |s|
|
|
197
198
|
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
198
|
-
(recap || Samagotchi::SessionManager.
|
|
199
|
+
(recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
199
200
|
end
|
|
200
201
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
201
202
|
row = lambda do |s|
|
|
@@ -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)
|
|
@@ -359,7 +381,7 @@ module Samagotchi
|
|
|
359
381
|
root = MemoryPaths.project_root(cwd)
|
|
360
382
|
lines = ["Current working directory:", cwd]
|
|
361
383
|
unless root == cwd
|
|
362
|
-
lines << "Project root (project memories
|
|
384
|
+
lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
|
|
363
385
|
lines << root
|
|
364
386
|
end
|
|
365
387
|
home = Dir.home
|
|
@@ -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
|
|
@@ -336,6 +342,9 @@ module Samagotchi
|
|
|
336
342
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
337
343
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
338
344
|
send_command(text)
|
|
345
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
346
|
+
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
347
|
+
@screen.commit(hint)
|
|
339
348
|
else
|
|
340
349
|
send_prompt(text)
|
|
341
350
|
end
|
|
@@ -449,7 +458,12 @@ module Samagotchi
|
|
|
449
458
|
# too, so nothing waits here.
|
|
450
459
|
def send_command(line)
|
|
451
460
|
reply = @client.post_command(line: line, client_id: @client_id)
|
|
452
|
-
|
|
461
|
+
if reply.status == 202
|
|
462
|
+
# Input from a pipe ends once its output is in (#command_ran).
|
|
463
|
+
command_id = reply.json&.fetch("command_id", nil)
|
|
464
|
+
@open_ids << command_id if command_id && @wait_at_eof
|
|
465
|
+
return
|
|
466
|
+
end
|
|
453
467
|
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
454
468
|
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
455
469
|
|
|
@@ -473,6 +487,7 @@ module Samagotchi
|
|
|
473
487
|
end
|
|
474
488
|
|
|
475
489
|
def command_ran(event)
|
|
490
|
+
@open_ids.delete(event[:command_id])
|
|
476
491
|
@screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
|
|
477
492
|
output = event[:output].to_s
|
|
478
493
|
if output.empty?
|
|
@@ -510,6 +525,9 @@ module Samagotchi
|
|
|
510
525
|
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
511
526
|
def continue_resolved(event)
|
|
512
527
|
@continue_offer = nil
|
|
528
|
+
# Asked as the step-limit question: its line says what became of it.
|
|
529
|
+
asked = @offer_asked
|
|
530
|
+
@offer_asked = false
|
|
513
531
|
sync_continue_slot
|
|
514
532
|
who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
|
|
515
533
|
outcome = if event[:decision] == "dropped"
|
|
@@ -518,14 +536,14 @@ module Samagotchi
|
|
|
518
536
|
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
519
537
|
end
|
|
520
538
|
@continue_answer = nil
|
|
521
|
-
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
|
|
539
|
+
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
522
540
|
sync_prompt
|
|
523
541
|
end
|
|
524
542
|
|
|
525
543
|
# The notes slot shows the offer's choices while it waits (a question
|
|
526
544
|
# has the slot while it is open).
|
|
527
545
|
def sync_continue_slot
|
|
528
|
-
return if @question
|
|
546
|
+
return if @question || @offer_asked
|
|
529
547
|
|
|
530
548
|
if @continue_offer
|
|
531
549
|
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
@@ -680,6 +698,9 @@ module Samagotchi
|
|
|
680
698
|
@set_aside = @reader&.typed_text unless @question
|
|
681
699
|
@question = QuestionPrompt.new(pending)
|
|
682
700
|
@question_pending = pending
|
|
701
|
+
# The step-limit question stands for the continue offer's own slot
|
|
702
|
+
# until the offer is resolved.
|
|
703
|
+
@offer_asked = true if @question.continue?
|
|
683
704
|
# An edit's diff goes above, into the scrollback, once per question
|
|
684
705
|
# (a join gets it from the snapshot and may get the event too).
|
|
685
706
|
unless @previewed_id == @question.id
|
|
@@ -693,7 +714,9 @@ module Samagotchi
|
|
|
693
714
|
end
|
|
694
715
|
|
|
695
716
|
def answer_question(text)
|
|
696
|
-
|
|
717
|
+
# Enter alone at the step-limit question continues (it can't be
|
|
718
|
+
# dismissed), as at the continue prompt.
|
|
719
|
+
return dismiss_question if text.empty? && !@question.continue?
|
|
697
720
|
|
|
698
721
|
answer = @question.parse(text)
|
|
699
722
|
@screen.commit(answer.note) if answer.note
|
|
@@ -881,6 +904,8 @@ module Samagotchi
|
|
|
881
904
|
sync_continue_slot
|
|
882
905
|
end
|
|
883
906
|
render_current_turn(snapshot[:current_turn])
|
|
907
|
+
# A question between turns (the step-limit one) belongs to no turn.
|
|
908
|
+
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
884
909
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
885
910
|
Array(snapshot[:queued]).each do |entry|
|
|
886
911
|
next if own?(entry[:client_id])
|
|
@@ -939,7 +964,15 @@ module Samagotchi
|
|
|
939
964
|
return if text.empty?
|
|
940
965
|
|
|
941
966
|
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
942
|
-
|
|
967
|
+
# As if typed: a session command runs as the command; a typo of one
|
|
968
|
+
# gets the hint, not a turn.
|
|
969
|
+
if command_registry.command?(text)
|
|
970
|
+
send_command(text)
|
|
971
|
+
elsif (hint = command_registry.unknown_command_hint(text))
|
|
972
|
+
@screen.commit(hint)
|
|
973
|
+
else
|
|
974
|
+
send_prompt(text)
|
|
975
|
+
end
|
|
943
976
|
end
|
|
944
977
|
|
|
945
978
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "question_slot"
|
|
4
|
+
require_relative "../turn_flow"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
class TerminalUI
|
|
@@ -12,7 +13,10 @@ module Samagotchi
|
|
|
12
13
|
# Bridge). An approval (kind "approval") takes only a number, an exact
|
|
13
14
|
# label, y (the first option, Allow once) or n (the last, Deny), plus
|
|
14
15
|
# an optional "; reason"; a substring would pick the wrong one ("y" is
|
|
15
|
-
# in "Deny").
|
|
16
|
+
# in "Deny"). The step-limit question (kind "continue") reads the
|
|
17
|
+
# continue words (TurnFlow.continue_decision): Enter, yes, y or
|
|
18
|
+
# /continue continue; no stops; "no, why" stops with the reason; 1 and
|
|
19
|
+
# 2 pick the options.
|
|
16
20
|
class QuestionPrompt
|
|
17
21
|
DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
|
|
18
22
|
|
|
@@ -51,11 +55,13 @@ module Samagotchi
|
|
|
51
55
|
@multi = !!field.(:multi_select)
|
|
52
56
|
@free = !!field.(:allow_freeform)
|
|
53
57
|
@approval = field.(:kind).to_s == "approval"
|
|
58
|
+
@continue = field.(:kind).to_s == "continue"
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
def multi? = @multi
|
|
57
62
|
def free? = @free
|
|
58
63
|
def approval? = @approval
|
|
64
|
+
def continue? = @continue
|
|
59
65
|
|
|
60
66
|
# The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
|
|
61
67
|
# short id), or nil when cleared.
|
|
@@ -77,6 +83,7 @@ module Samagotchi
|
|
|
77
83
|
# What a question closed with no answer here says: a relayed approval
|
|
78
84
|
# names where it went; else "(question cancelled)".
|
|
79
85
|
def closed_text(reason)
|
|
86
|
+
return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
|
|
80
87
|
return "(question cancelled)" unless @delegate
|
|
81
88
|
|
|
82
89
|
case reason.to_s
|
|
@@ -98,6 +105,10 @@ module Samagotchi
|
|
|
98
105
|
hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
|
|
99
106
|
end
|
|
100
107
|
|
|
108
|
+
# Why a step-limit question closed unanswered here.
|
|
109
|
+
CONTINUE_CLOSED = { "dropped" => "(dropped: a new prompt came)", "answered" => "(answered with /continue)",
|
|
110
|
+
"superseded" => "(set aside for another question)", "replaced" => "(asked again)" }.freeze
|
|
111
|
+
|
|
101
112
|
# The most diff lines #preview_lines prints; the web shows them all.
|
|
102
113
|
PREVIEW_LINES = 40
|
|
103
114
|
|
|
@@ -138,7 +149,7 @@ module Samagotchi
|
|
|
138
149
|
# @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
|
|
139
150
|
def answer_text(answer)
|
|
140
151
|
picked = answer.selected.to_a
|
|
141
|
-
if approval?
|
|
152
|
+
if approval? || continue?
|
|
142
153
|
choice = picked.first || options.last
|
|
143
154
|
return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
|
|
144
155
|
end
|
|
@@ -150,6 +161,7 @@ module Samagotchi
|
|
|
150
161
|
# @return [Answer]
|
|
151
162
|
def parse(raw)
|
|
152
163
|
return parse_approval(raw) if approval?
|
|
164
|
+
return parse_continue(raw) if continue?
|
|
153
165
|
|
|
154
166
|
raw = raw.to_s.strip
|
|
155
167
|
# "1,3; my text": the first ';' separates the selection from freeform text.
|
|
@@ -178,6 +190,9 @@ module Samagotchi
|
|
|
178
190
|
def mark = approval? ? "! " : "? "
|
|
179
191
|
|
|
180
192
|
def slot_hint
|
|
193
|
+
if continue?
|
|
194
|
+
return "Enter or yes = #{options.first}; no = #{options.last}; no, <reason> = #{options.last} and tell the model why"
|
|
195
|
+
end
|
|
181
196
|
if approval?
|
|
182
197
|
return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
|
|
183
198
|
"Enter alone denies"
|
|
@@ -189,6 +204,23 @@ module Samagotchi
|
|
|
189
204
|
hint.join("; ")
|
|
190
205
|
end
|
|
191
206
|
|
|
207
|
+
# The continue words, or an option's number. Empty is Continue (Enter
|
|
208
|
+
# alone, as at the REPL's continue prompt).
|
|
209
|
+
def parse_continue(raw)
|
|
210
|
+
text = raw.to_s.strip
|
|
211
|
+
if text.match?(/\A\d+\z/)
|
|
212
|
+
idx = text.to_i - 1
|
|
213
|
+
return Answer.new(selected: [options[idx]]) if idx.between?(0, options.size - 1)
|
|
214
|
+
end
|
|
215
|
+
decision, reason = TurnFlow.continue_decision(text)
|
|
216
|
+
case decision
|
|
217
|
+
when :resume then Answer.new(selected: [options.first])
|
|
218
|
+
when :abort then Answer.new(selected: [options.last])
|
|
219
|
+
when :abort_with_reason then Answer.new(selected: [options.last], freeform: reason)
|
|
220
|
+
else Answer.new(error: "Answer yes (Enter alone too), no, or no, <reason>.")
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
192
224
|
def parse_approval(raw)
|
|
193
225
|
sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
|
|
194
226
|
free_part = nil if free_part.to_s.empty?
|