samagotchi 0.75.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../memory_bundle/index_size"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # A session's rows per model in /stats (the snapshot's model_segments).
9
+ # The four public model_segment_* labels are the words the web's ctx
10
+ # tooltip shares (ctx.js; spec/shared/labels_matrix.json model_segments).
11
+ # Rows come symbol- or string-keyed (the attached TUI's as JSON).
12
+ # Included by Formatting, whose cost_text and speed_text it uses.
13
+ module ModelSegmentsText
14
+ # A row's model: its ref; the rows folded past the cap ("3 earlier
15
+ # segments", told by folded_segments); a session's turns from before
16
+ # turns named their model ("earlier turns").
17
+ def model_segment_label(row)
18
+ folded = segment_value(row, :folded_segments).to_i
19
+ return "#{folded} earlier segments" if folded.positive?
20
+
21
+ ref = segment_value(row, :model_ref).to_s
22
+ ref.empty? ? "earlier turns" : ref
23
+ end
24
+
25
+ # "6.0k in / 40 out": the row's prompt and completion sums.
26
+ def model_segment_tokens_text(row)
27
+ count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
28
+ "#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
29
+ end
30
+
31
+ # "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
32
+ def model_segment_cost_text(row)
33
+ [cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
34
+ .reject(&:empty?).join(" + ")
35
+ end
36
+
37
+ # "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
38
+ # row's model prefilled (its cost an estimate from the configured
39
+ # price, left out without one); "" for none.
40
+ def model_segment_prefill_text(row)
41
+ tokens = segment_value(row, :prefill_tokens).to_i
42
+ return "" unless tokens.positive?
43
+
44
+ cost = cost_text(segment_value(row, :prefill_cost_estimate))
45
+ "switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
46
+ end
47
+
48
+ private
49
+
50
+ def segment_value(row, key)
51
+ row[key] || row[key.to_s]
52
+ end
53
+
54
+ # /stats' models: block, in place of the tokens line: a row per
55
+ # segment (model, when, turns, tokens, its average speed and cost), a
56
+ # switch's prefill under the row of the model switched to, and the
57
+ # total (the tokens line's sums and breakdown).
58
+ def models_block_lines(segments, turns, tokens, token_src_label)
59
+ dated = segment_dates_differ?(segments)
60
+ cells = segments.map do |row|
61
+ speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
62
+ [model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
63
+ [model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
64
+ end
65
+ total = ["total", "", turns_text(turns),
66
+ "#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
67
+ "#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
68
+ "(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
69
+ widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
70
+ row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
71
+ lines = []
72
+ segments.each_with_index do |row, i|
73
+ lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
74
+ prefill = model_segment_prefill_text(row)
75
+ lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
76
+ end
77
+ lines << "#{" " * 18}#{row_text.call(total)}"
78
+ end
79
+
80
+ def turns_text(count)
81
+ "#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
82
+ end
83
+
84
+ # "10:02–10:41", "10:43–now" (a row with a turn running); with the
85
+ # day ("Oct 16 10:02") when the rows span more than one day.
86
+ def segment_span_text(row, dated:)
87
+ started = segment_time(segment_value(row, :started_at))
88
+ ended = segment_time(segment_value(row, :ended_at))
89
+ format_time = lambda { |time|
90
+ if time
91
+ time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
92
+ else
93
+ "?"
94
+ end
95
+ }
96
+ "#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
97
+ end
98
+
99
+ def segment_dates_differ?(segments)
100
+ days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
101
+ .filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
102
+ days.uniq.size > 1
103
+ end
104
+
105
+ def segment_time(stamp)
106
+ stamp ? Time.iso8601(stamp.to_s).localtime : nil
107
+ rescue ArgumentError
108
+ nil
109
+ end
110
+
111
+ # The row the session's last speed came from: the newest with one.
112
+ def last_speed_label(segments)
113
+ row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
114
+ row && model_segment_label(row)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -89,7 +89,9 @@ module Samagotchi
89
89
  # hint). Other events change nothing.
90
90
  def take_event(event)
91
91
  case event[:type]
92
- when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
92
+ # usage nil (a /model whose window didn't resolve) clears it.
93
+ when :context_status
94
+ update(context: event[:usage] ? { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] } : nil)
93
95
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
94
96
  when :generation_completed
95
97
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
@@ -873,6 +873,7 @@ module Samagotchi
873
873
  end
874
874
  if command
875
875
  show_command_result(command)
876
+ refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
876
877
  persist_recent_history(input) if command.shell
877
878
  return
878
879
  end
@@ -1400,9 +1401,10 @@ module Samagotchi
1400
1401
 
1401
1402
  # The status row under the prompt, from the Engine's state (what a
1402
1403
  # worker hands an attached TUI when it joins): drawn when it changed.
1403
- def refresh_status_row
1404
+ def refresh_status_row(clear_unknown_context: false)
1404
1405
  state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
1405
1406
  @status_row.take_state(state, default_model: @default_model_name)
1407
+ @status_row.update(context: nil) if clear_unknown_context && state.key?(:context_status) && state[:context_status].nil?
1406
1408
  refresh_children_status
1407
1409
  end
1408
1410
 
@@ -1463,13 +1465,13 @@ module Samagotchi
1463
1465
  def drain_pending_question?
1464
1466
  pq = @pending_question_event
1465
1467
  # Also check Engine's persisted pending (covers resume)
1466
- pq ||= @engine.pending_question
1468
+ pq ||= @engine.pending_question&.to_h
1467
1469
  return false unless pq
1468
1470
 
1469
1471
  # If the pending no longer matches the engine's current pending (stale
1470
1472
  # event after sync handler already answered and cleared), discard.
1471
1473
  current = @engine.pending_question
1472
- if current && (pq[:id] || pq["id"]).to_s != (current[:id] || current["id"]).to_s
1474
+ if current && (pq[:id] || pq["id"]).to_s != current.id.to_s
1473
1475
  @pending_question_event = nil
1474
1476
  return false
1475
1477
  end
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
39
39
 
40
40
  # What a web row keeps of a tool's output, on a reload
41
- # (Web::MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
41
+ # (MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
42
42
  # row shows 300 chars and the rest on hover. A longer one keeps its head
43
43
  # and tail (preview).
44
44
  WEB_PREVIEW_CHARS = 2000
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  # Runs +calls+ in order (ToolRunner#run each), yielding each run and its
30
30
  # index as it finishes. +emit+ takes the dispatch events, the runner's
31
31
  # own go to +on_stream_event+.
32
- # @return [Array<Hash>] the runs
32
+ # @return [Array<ToolRun>] the runs
33
33
  def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
34
34
  emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
35
35
  runs = calls.each_with_index.map do |call, index|
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  # The runs' activity, for the turn's tool list (none for a call whose
46
46
  # dispatcher raised).
47
47
  def activities(runs)
48
- runs.filter_map { |run| run[:activity] }
48
+ runs.filter_map(&:activity)
49
49
  end
50
50
 
51
51
  # The native loop's entry for a batch: the capped outputs joined, every image
@@ -53,14 +53,14 @@ module Samagotchi
53
53
  # each on its own tool row), and the saved-only fields one per call:
54
54
  # +ids+ (ToolIds.next_ids) as tool_ids.
55
55
  def joined(runs, ids:)
56
- images = runs.flat_map { |run| Array(run[:images]) }
57
- entry = { role: "tool_response", content: runs.map { |run| run[:capped_output] }.join(SEPARATOR) }
56
+ images = runs.flat_map { |run| Array(run.images) }
57
+ entry = { role: "tool_response", content: runs.map(&:capped_output).join(SEPARATOR) }
58
58
  unless images.empty?
59
59
  entry[:images] = images
60
- entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
60
+ entry[:image_counts] = runs.map { |run| Array(run.images).size }
61
61
  end
62
- saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
63
- tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
62
+ saved = { tool_params: runs.map(&:shown_params), tool_labels: runs.map(&:shown_label),
63
+ tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs) }
64
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
65
  entry[:tool_ids] = ids
66
66
  entry
@@ -70,12 +70,12 @@ module Samagotchi
70
70
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
71
71
  # #joined's tool_ids).
72
72
  def single(run, tool_call_id:, ids:)
73
- entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
74
- entry[:images] = run[:images] if run[:images]&.any?
75
- entry[:tool_params] = run[:shown_params] if run[:shown_params]
76
- entry[:tool_labels] = run[:shown_label] if run[:shown_label]
77
- entry[:tool_diffs] = run[:diff] if run[:diff]
78
- entry[:tool_refs] = run[:refs] if run[:refs]
73
+ entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
74
+ entry[:images] = run.images if run.images&.any?
75
+ entry[:tool_params] = run.shown_params if run.shown_params
76
+ entry[:tool_labels] = run.shown_label if run.shown_label
77
+ entry[:tool_diffs] = run.diff if run.diff
78
+ entry[:tool_refs] = run.refs if run.refs
79
79
  entry[:tool_ids] = ids
80
80
  entry
81
81
  end
@@ -7,7 +7,7 @@ require_relative "file_ref"
7
7
  module Samagotchi
8
8
  # The fields a tool row shows beyond its params, built from the call in
9
9
  # one place for every path: the live tool_call_started (ToolRunner#run)
10
- # and a reload from the saved call (Web::MessageParts#tool_part). The
10
+ # and a reload from the saved call (MessageParts#tool_part). The
11
11
  # bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
12
12
  # these lists, so a new field is added here and travels everywhere.
13
13
  #
@@ -15,6 +15,17 @@ require_relative "memory_bundle/index_sync"
15
15
  require_relative "memory_bundle/index_size"
16
16
 
17
17
  module Samagotchi
18
+ # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
+ # tool_response entries) and the loops' activity list. Fields a call didn't
20
+ # produce (images, shown_params, shown_label, refs, diff, output_file) are nil.
21
+ ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
22
+ :output_file) do
23
+ def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
24
+ shown_label: nil, refs: nil, diff: nil, output_file: nil)
25
+ super
26
+ end
27
+ end
28
+
18
29
  # The single per-call path both loops use: the tool_call_started and
19
30
  # tool_call_completed events, the guardrail gate (before_tool_call hooks
20
31
  # and their veto), dispatch through KernelLoop, the after_tool_call hook
@@ -40,7 +51,7 @@ module Samagotchi
40
51
  end
41
52
 
42
53
  # @param call_index [Integer] 1-based position of the call in its batch
43
- # @return [Hash] output:, capped_output:, truncated:, activity:,
54
+ # @return [ToolRun] output:, capped_output:, truncated:, activity:,
44
55
  # output_file: (the saved whole output of a cut execute result),
45
56
  # images: (refs) when the tool returned images the model gets to see, and
46
57
  # shown_params: the params line the live row showed, and shown_label:
@@ -135,14 +146,10 @@ module Samagotchi
135
146
  fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
136
147
  status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
137
148
 
138
- run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
139
- run[:images] = images if images&.any?
140
- run[:shown_params] = params if params && plugin_tool?(call[:name])
141
- run[:shown_label] = label if label
142
- run[:refs] = refs unless refs.empty?
143
- run[:diff] = diff if diff
144
- run[:output_file] = output_file if output_file
145
- run
149
+ ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
150
+ images: images&.any? ? images : nil,
151
+ shown_params: params && plugin_tool?(call[:name]) ? params : nil,
152
+ shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file)
146
153
  end
147
154
 
148
155
  private
@@ -77,16 +77,17 @@ module Samagotchi
77
77
  super
78
78
  end
79
79
 
80
- # What the prompt carries of it: the declared names, the reachable
81
- # and removed ones (the not-loaded line, find_tools' chi line), when
82
- # either list has any the kit name the line gives, and the kit's
83
- # prompt text. Two equal keys build the same prompt (/toolkit's "no
84
- # change", its cost).
85
- def prompt_key
86
- [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name,
87
- resolved.prompt]
80
+ # What the prompt carries before the kit's prompt text: the declared
81
+ # names, reachable and removed ones (the not-loaded line, find_tools'
82
+ # chi line), and, when either list has any, the kit name the line gives.
83
+ def tools_key
84
+ [declared.map(&:name), reachable, removed, reachable.empty? && removed.empty? ? nil : resolved.name]
88
85
  end
89
86
 
87
+ # What the prompt carries of it, including the kit's prompt text. Two
88
+ # equal keys build the same prompt (/toolkit's "no change", its cost).
89
+ def prompt_key = [*tools_key, resolved.prompt]
90
+
90
91
  # What the UIs show (the snapshot, command_ran): nil when it declares
91
92
  # every tool on full (full, full +delegate), which has no chip.
92
93
  # +left_out+ is +reachable+ and +removed+ together; +prompt+ (the
@@ -129,9 +129,9 @@ module Samagotchi
129
129
 
130
130
  # What the confirm says is read again: from the tools on (the front of
131
131
  # every request), or from the kit's prompt text on when only that
132
- # changes (Explained#prompt_key's last part: a kit on full's tools).
132
+ # changes (Explained#tools_key is unchanged: only a kit's prompt text).
133
133
  def reread_line(before, after)
134
- if before.prompt_key[0...-1] == after.prompt_key[0...-1]
134
+ if before.tools_key == after.tools_key
135
135
  "The kit's prompt text is in the system prompt: the prompt is read again from it on."
136
136
  else
137
137
  "The tools are the front of every request: the whole prompt is read again from them on."
@@ -144,11 +144,7 @@ module Samagotchi
144
144
 
145
145
  # The XDG dirs set in +env+, made absolute (nothing for an unset one).
146
146
  def xdg_absolute(env = ENV)
147
- set = ->(key) { !env.fetch(key, "").to_s.strip.empty? }
148
- dirs = {}
149
- dirs["XDG_STATE_HOME"] = Paths.state_home(env: env) if set.call("XDG_STATE_HOME")
150
- dirs["XDG_CONFIG_HOME"] = Paths.config_home(env: env) if set.call("XDG_CONFIG_HOME")
151
- dirs
147
+ Paths.absolute_xdg(env: env)
152
148
  end
153
149
 
154
150
  # The unsets that keep a `chi --model X` worker's default from passing
@@ -37,7 +37,7 @@ module Samagotchi
37
37
  # @param relay [#open_question, #relay_desk] Peers#relay
38
38
  # @return [Boolean] a relay can take this question
39
39
  def relayable?(relay, question)
40
- !relay.nil? && question.is_a?(Hash) && question[:kind].to_s == Guardrails::Approval::KIND
40
+ !relay.nil? && question.is_a?(PendingQuestion) && question.kind.to_s == Guardrails::Approval::KIND
41
41
  end
42
42
 
43
43
  # Relay the child's pending approval and wait until it settles.
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  # @return [Outcome]
46
46
  def call(child_id, question, relay:, state_dir:, more: 0)
47
47
  child = Session.load(child_id, state_dir: state_dir)
48
- qid = question[:id].to_s
48
+ qid = question.id.to_s
49
49
  desk = relay.relay_desk
50
50
  relay_id = desk.open(child_id: child_id, child_question_id: qid)
51
51
  client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
@@ -70,7 +70,7 @@ module Samagotchi
70
70
  end
71
71
 
72
72
  pending = Session.load(child_id, state_dir: state_dir).pending_question
73
- pending && pending[:id].to_s == qid ? nil : "answered_on_child"
73
+ pending && pending.id.to_s == qid ? nil : "answered_on_child"
74
74
  rescue ArgumentError
75
75
  "child_gone"
76
76
  end
@@ -175,7 +175,7 @@ module Samagotchi
175
175
  return if waiting.empty?
176
176
 
177
177
  child_id, question = waiting.first
178
- @done[question[:id].to_s] = true
178
+ @done[question.id.to_s] = true
179
179
  outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
180
180
  @on_outcome.call(child_id, outcome)
181
181
  # The next one, if any, right after.
@@ -186,7 +186,7 @@ module Samagotchi
186
186
  # @return [Integer]
187
187
  def count = waiting.size
188
188
 
189
- # @return [Array<Array(String, Hash)>] [child id, pending question],
189
+ # @return [Array<Array(String, PendingQuestion)>] [child id, pending question],
190
190
  # oldest question first
191
191
  def waiting
192
192
  rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
@@ -194,10 +194,10 @@ module Samagotchi
194
194
  end
195
195
  rows.filter_map do |row|
196
196
  pending = Session.load(row[:id], state_dir: @state_dir).pending_question
197
- [row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
197
+ [row[:id], pending] if pending && pending.id.to_s == row[:waiting_id].to_s
198
198
  rescue ArgumentError
199
199
  nil
200
- end.sort_by { |_, pending| pending[:created_at].to_s }
200
+ end.sort_by { |_, pending| pending.created_at.to_s }
201
201
  end
202
202
 
203
203
  private
@@ -91,7 +91,7 @@ module Samagotchi
91
91
  # asked), the relayed question no longer reported: a turn that
92
92
  # ends with no reply during the relay's POST or after it still
93
93
  # ends the wait.
94
- baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
94
+ baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question.id)
95
95
  end
96
96
  rescue ArgumentError => e
97
97
  "Error: #{e.message}"
@@ -3,7 +3,6 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "ipaddr"
6
- require "nokogiri"
7
6
  require_relative "../version"
8
7
 
9
8
  module Samagotchi
@@ -128,6 +127,9 @@ module Samagotchi
128
127
  def self.extract_text_from_html(html)
129
128
  return "Error: empty HTML response" if html.nil? || html.empty?
130
129
 
130
+ # Load lazily so workers don't pay for Nokogiri on every start.
131
+ require "nokogiri"
132
+
131
133
  doc = Nokogiri::HTML(html)
132
134
 
133
135
  # Extract noscript text before removing it
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.75.0"
4
+ VERSION = "0.77.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -43,7 +43,7 @@ require_relative "../recap_store"
43
43
  require_relative "markdown_renderer"
44
44
  require_relative "capabilities"
45
45
  require_relative "editor"
46
- require_relative "message_parts"
46
+ require_relative "../message_parts"
47
47
  require_relative "session_summary"
48
48
  require_relative "../log"
49
49
  require_relative "../paths"
@@ -855,7 +855,7 @@ module Samagotchi
855
855
  current_turn = turn_snapshot["current_turn"]
856
856
  current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
857
857
  elsif owner&.worker?
858
- session.pending_question
858
+ session.pending_question&.to_h
859
859
  end
860
860
  end
861
861
 
@@ -1794,6 +1794,9 @@ module Samagotchi
1794
1794
  # before the next turn streams (live over saved).
1795
1795
  context: source["context"],
1796
1796
  tokens: source["tokens"],
1797
+ # The tokens, cost and speeds per model (SessionMetrics'
1798
+ # model_segments; none with one model, or from an older file).
1799
+ model_segments: Array(source["model_segments"]),
1797
1800
  # The memory indexes the session's prompt holds (the ctx tooltip).
1798
1801
  memory_index: source["memory_index"]
1799
1802
  }
@@ -45,6 +45,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
45
45
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
46
46
  import { createCardsPlace } from "./cards_place.js";
47
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
48
+ import { createMarkdownPlace } from "./markdown_place.js";
48
49
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
49
50
  import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
50
51
  import { createTaskStopper } from "./task_stop.js";
@@ -86,6 +87,7 @@ import { liveSlots } from "./stage_model.js";
86
87
  import { currentGen } from "./turn_model.js";
87
88
  import { toolName } from "./activity.js";
88
89
  import { toastBottom } from "./toast_place.js";
90
+ import { createToasts } from "./toasts.js";
89
91
 
90
92
  const $ = (s) => document.querySelector(s);
91
93
  const topStripEl = $("#topStrip");
@@ -231,26 +233,25 @@ const viewArgs = {
231
233
  // the route (its sidecar features).
232
234
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
233
235
  };
236
+ const markdownPlace = createMarkdownPlace({
237
+ capabilities: () => pageCapabilities,
238
+ renderText,
239
+ roots: () => turnRoots(),
240
+ touchedFiles,
241
+ currentDir: () => current.dir,
242
+ linkRefs,
243
+ applyFinalAnswer,
244
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
245
+ normalize,
246
+ pairAnswerRefs,
247
+ });
248
+ const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
234
249
  const turnDom = view === "stage"
235
250
  ? createStageView({
236
251
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
237
252
  renderMarkdown, linkRefs: linkRefsIn,
238
253
  })
239
254
  : createTurnView(viewArgs);
240
- // A text the page already shows as its markdown HTML (POST /api/render,
241
- // sanitised there), or null: markdown off here (no request), or a failed
242
- // read (the raw text stays).
243
- function renderMarkdown(text) {
244
- if (pageCapabilities.display !== true) return Promise.resolve(null);
245
- return renderText(text)
246
- .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
247
- .catch(() => null);
248
- }
249
- // The `path:line` refs in one rendered text (the stage's asking slot, a
250
- // kept reply), as linkTextRefs links the answers'.
251
- function linkRefsIn(el) {
252
- linkRefs(el, touchedFiles(turnRoots()), current.dir);
253
- }
254
255
  // Where a running turn's rows can be: the history, and the stage while it
255
256
  // holds a turn (lookups by id, copy, annotate, card actions).
256
257
  function turnRoots() {
@@ -978,32 +979,6 @@ async function readTurnEnd() {
978
979
  } catch(_){}
979
980
  }
980
981
 
981
- // The turn's answer as its rendered markdown and with its plugin refs,
982
- // once it is final (the turn-end read, an answer_display re-read:
983
- // answer_refs.js applyFinalAnswer); then the refs.
984
- function applyFinalMarkdown(messages) {
985
- applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
986
- linkTextRefs();
987
- }
988
-
989
- // The refs on the answers' and closed steps' texts, on this page: first
990
- // each answer's own (a plugin's, answer_refs.js), then the `path:line`
991
- // refs that name a file the session's rows (and cards) touched
992
- // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
993
- // turn's end and after a reload's render. A ref already made stays as it
994
- // is.
995
- function linkTextRefs() {
996
- const roots = turnRoots();
997
- const touched = touchedFiles(roots);
998
- for (const root of roots) {
999
- for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
1000
- }
1001
- if (!touched.size) return;
1002
- for (const root of roots) {
1003
- for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
1004
- }
1005
- }
1006
-
1007
982
  function seedSeenFromDom() {
1008
983
  shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
1009
984
  }
@@ -1475,7 +1450,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1475
1450
  // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1476
1451
  // image refused (bad_image, too_large, bad_images) says why below.
1477
1452
  if (e.code === "owned_by_tui") {
1478
- if (sentTo !== selected) return alert(e.message);
1453
+ if (sentTo !== selected) return showToast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
1479
1454
  current.owner = "tui";
1480
1455
  updateComposerMode();
1481
1456
  return;
@@ -1485,7 +1460,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1485
1460
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1486
1461
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1487
1462
  if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1488
- alert(e.message);
1463
+ showToast(cantText("send", e), null, { sticky: true });
1489
1464
  } finally {
1490
1465
  actionBtn.disabled = current.owner === "tui";
1491
1466
  promptEl.focus();
@@ -1502,13 +1477,14 @@ const composerCommands = createComposerCommands({
1502
1477
  refreshHistory: () => refreshHistory(),
1503
1478
  failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1504
1479
  // As handleSendTurn says a turn's failure after a switch.
1505
- failedElsewhere: (line, text) => alert(`${line}: ${text}`),
1480
+ failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1506
1481
  ownedByTui: () => {
1507
1482
  current.owner = "tui";
1508
1483
  updateComposerMode();
1509
1484
  },
1510
1485
  composer: {
1511
1486
  busy: () => { actionBtn.disabled = true; },
1487
+ text: () => promptEl.value,
1512
1488
  clear: () => clearComposer(),
1513
1489
  done: () => {
1514
1490
  actionBtn.disabled = current.owner === "tui";
@@ -2052,24 +2028,11 @@ function placeToast() {
2052
2028
  else root.removeProperty("--toast-bottom");
2053
2029
  }
2054
2030
  }
2031
+ const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
2055
2032
  // The model panel opening, closing or changing height (its list filters).
2056
2033
  new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
2057
2034
 
2058
- // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2059
- // words, or a template (chains.js openChildrenTemplate: a link in it, a
2060
- // delegate a continue waits for, opens that session and hides the toast).
2061
- let toastTimer = null;
2062
- function showToast(text, action = null, { sticky = false } = {}) {
2063
- const el = $("#toast");
2064
- const hide = () => { el.hidden = true; };
2065
- render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2066
- ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2067
- : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2068
- el.hidden = false;
2069
- placeToast();
2070
- clearTimeout(toastTimer);
2071
- if (!sticky) toastTimer = setTimeout(hide, 6000);
2072
- }
2035
+ function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
2073
2036
 
2074
2037
  // Annotate (annotate_bar.js): a selection in the conversation offers
2075
2038
  // "Annotate" and the presets, which append a quote to the composer, and the
@@ -230,7 +230,7 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
230
230
  const ago = relativeTime(s.updated_at || s.created_at || "", now);
231
231
  const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
232
232
  return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
233
- title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">#${n}</span>${
233
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-n">#${n}</span>${
234
234
  date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
235
235
  rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
236
236
  });