samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -1
@@ -26,8 +26,8 @@ require_relative "../host_registry"
26
26
  require_relative "../llm_context_override"
27
27
  require_relative "../model_catalog"
28
28
  require_relative "../model_profile"
29
+ require_relative "../session_setting"
29
30
  require_relative "../thinking"
30
- require_relative "../reasoning_history"
31
31
  require_relative "../sampling_settings"
32
32
  require_relative "../served_model"
33
33
  require_relative "../project_scope"
@@ -557,52 +557,6 @@ module Samagotchi
557
557
  nil
558
558
  end
559
559
 
560
- # A session's LLM context strategy, apply rule and budget, each with
561
- # where it came from (LLMContextStrategy::Explained#summary), as its
562
- # worker would resolve them; nil when they can't be.
563
- # (LLMContextStrategy.for_session, shared with the ctx % the session
564
- # lists count.)
565
- def llm_context_for(session)
566
- LLMContextStrategy.for_session(session, registry: host_registry)
567
- end
568
-
569
- # A session's toolkit as its file names it ({name:, label:}), nil on
570
- # full: without a worker nothing resolves it (a bundle's kits load in
571
- # the worker), so the tools it leaves out aren't known here.
572
- def toolkit_for(session)
573
- toolkit = session.toolkit
574
- toolkit && !toolkit.full? ? { name: toolkit.name, label: toolkit.label } : nil
575
- end
576
-
577
- # A session's thinking level with where it came from and its own
578
- # (SettingExplained#summary), as its worker would resolve it; nil
579
- # when it can't be. Config and the registry only: no network.
580
- def thinking_for(session)
581
- registry = host_registry
582
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
583
- target = registry.resolve(name)
584
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
585
- Thinking.explain(target, names: names, session: session.thinking).summary
586
- rescue StandardError
587
- nil
588
- end
589
-
590
- # A session's reasoning_history level with where it came from and its
591
- # own (SettingExplained#summary), as its worker would resolve it; nil
592
- # on a native host (it ignores the level) or when it can't be. Config
593
- # and the registry only: no network.
594
- def reasoning_history_for(session)
595
- registry = host_registry
596
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
597
- target = registry.resolve(name)
598
- return nil unless target.entry&.chat?
599
-
600
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
601
- ReasoningHistory.explain(target, names: names, session: session.reasoning_history).summary
602
- rescue StandardError
603
- nil
604
- end
605
-
606
560
  # Built again when config.yml's hosts: changed (compared as read: the
607
561
  # YAML read is mtime-cached), so the model picker lists a host added
608
562
  # while chi web runs. An injected registry (specs) stays.
@@ -828,46 +782,15 @@ module Samagotchi
828
782
  live = bridge_get_json(id, "snapshot")
829
783
  turn_snapshot = live && live["snapshot"]
830
784
  snapshot = live && live["session_state_snapshot"]
831
- last_event_seq = snapshot ? snapshot["event_seq"] : bridge_event_seq(id)
785
+ last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
832
786
  current_turn = turn_snapshot && turn_snapshot["current_turn"]
833
787
  owner = session_owner(id)
834
- # The file's question only while a worker owns the session: one a
835
- # dead worker saved can't be answered (its turn is gone), nor a chi
836
- # REPL's (it shares nothing with the web).
837
- # A question between turns (the step-limit one) is the snapshot's own.
838
- pending = if turn_snapshot
839
- current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
840
- elsif owner&.worker?
841
- session.pending_question
842
- end
843
- # A /model in the worker changes it before the file catches up.
844
- session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
845
- session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
788
+ pending = shown_pending_question(session, turn_snapshot, owner)
846
789
  # What the worker's server said it served for that model (after a turn);
847
790
  # without a worker, what the session's analytics.json last saved.
848
791
  persisted = snapshot ? nil : read_analytics(id)
849
- if snapshot
850
- session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
851
- served_expected_by: snapshot["served_expected_by"])
852
- # The model notes its prompt carries now (a /model there changes them).
853
- session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
854
- else
855
- session_json = session_json.merge(saved_served_model(session, persisted))
856
- end
857
- # The LLM context strategy the next turn runs under (the info bar's
858
- # chip): the worker's, else worked out here from the session file.
859
- session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
860
- # The thinking level the next turn runs at (the think chip), the same way.
861
- session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
862
- # Its reasoning history (the reasoning chip): the worker's (nil on a
863
- # native host), else the server's reading (an older worker, none).
864
- history = snapshot&.key?("reasoning_history") ? snapshot["reasoning_history"] : reasoning_history_for(session)
865
- session_json = session_json.merge(reasoning_history: history)
866
- # Its toolkit when it isn't full (the tools chip): the worker's, which
867
- # resolved it, else the name saved in the session file.
868
- toolkit = snapshot&.key?("toolkit") ? snapshot["toolkit"] : toolkit_for(session)
869
- session_json = session_json.merge(toolkit: toolkit)
870
- raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
792
+ session_json = shown_session_json(session, snapshot, owner, persisted)
793
+ raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
871
794
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
872
795
  json_response(200, {
873
796
  session: session_json,
@@ -876,17 +799,17 @@ module Samagotchi
876
799
  cwd: session.working_directory),
877
800
  failed_turn: failed_turn_for(session, raw_messages),
878
801
  current_turn: current_turn,
879
- queued: turn_snapshot ? Array(turn_snapshot["queued"]) : [],
802
+ queued: snapshot_list(turn_snapshot, "queued"),
880
803
  # Commands waiting for the running turn's end (their bubbles).
881
- queued_commands: turn_snapshot ? Array(turn_snapshot["queued_commands"]) : [],
804
+ queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
882
805
  recap: turn_snapshot && turn_snapshot["recap"],
883
806
  saved_recap: saved_recap_for(id, session, turn_snapshot),
884
807
  continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
885
808
  guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
886
809
  plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
887
810
  # Plugins' slow setup still running (chi.init): the page's init row.
888
- init_tasks: turn_snapshot ? Array(turn_snapshot["init_tasks"]) : [],
889
- cards: cards_for_display(turn_snapshot ? turn_snapshot["cards"] : saved_cards(id)),
811
+ init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
812
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }),
890
813
  # The composer's / autocomplete; the built-ins until a worker
891
814
  # names its plugins' too.
892
815
  commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
@@ -903,6 +826,58 @@ module Samagotchi
903
826
  error_response(404, "not_found", e.message)
904
827
  end
905
828
 
829
+ # A list in the worker's turn +snapshot+ (queued prompts, commands,
830
+ # init tasks); [] without a worker.
831
+ def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
832
+
833
+ # A live worker's +key+ as its reading (+live+: a snapshot, a tail)
834
+ # says it, nil included; without a worker (+live+ nil) the block's,
835
+ # the server's own reading of the saved session. A nil value from a
836
+ # worker is not a missing one (contrast SessionSetting#snapshot_value's
837
+ # :or).
838
+ def live_or_saved(live, key)
839
+ live ? live[key] : yield
840
+ end
841
+
842
+ # The file's question only while a worker owns the session: one a
843
+ # dead worker saved can't be answered (its turn is gone), nor a chi
844
+ # REPL's (it shares nothing with the web).
845
+ # A question between turns (the step-limit one) is the snapshot's own.
846
+ def shown_pending_question(session, turn_snapshot, owner)
847
+ if turn_snapshot
848
+ current_turn = turn_snapshot["current_turn"]
849
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
850
+ elsif owner&.worker?
851
+ session.pending_question
852
+ end
853
+ end
854
+
855
+ # The full show's session part: the file's, with what a live worker's
856
+ # state +snapshot+ knows first (its model, status, served model,
857
+ # prompt notes and chips); without one, the served model from
858
+ # +persisted+ (analytics.json). Sets a worker's model on +session+:
859
+ # a /model in the worker changes it before the file catches up.
860
+ def shown_session_json(session, snapshot, owner, persisted)
861
+ session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
862
+ session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
863
+ if snapshot
864
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
865
+ served_expected_by: snapshot["served_expected_by"])
866
+ # The model notes its prompt carries now (a /model there changes them).
867
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
868
+ else
869
+ session_json = session_json.merge(saved_served_model(session, persisted))
870
+ end
871
+ # The per-session settings' chips (the info bar's llm ctx, think,
872
+ # reasoning and tools): the worker's, else worked out here from the
873
+ # session file (SessionSetting#snapshot_value, #saved_summary).
874
+ SessionSetting::ALL.each do |setting|
875
+ chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
876
+ session_json = session_json.merge(setting.key => chip)
877
+ end
878
+ session_json
879
+ end
880
+
906
881
  # ?cards=1: the page's re-read when a card arrives, for its rendered
907
882
  # body (the stream carries the card's plain text only). The worker's
908
883
  # cards from its light GET tail, else the saved ones; the session file
@@ -911,7 +886,7 @@ module Samagotchi
911
886
  return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
912
887
 
913
888
  tail = live_tail(id)
914
- json_response(200, { cards: cards_for_display(tail ? tail["cards"] : saved_cards(id)) })
889
+ json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
915
890
  end
916
891
 
917
892
  # ?tail=1: the page's re-read at the end of a turn wants the final
@@ -931,17 +906,18 @@ module Samagotchi
931
906
  turn_id = nil if turn_id.empty?
932
907
  tail = live_tail(id, turn_id: turn_id)
933
908
  state = tail && tail["session_state_snapshot"]
934
- if state.is_a?(Hash)
935
- session_json = { id: id, status: state["status"], used_memory_names: Array(state["used_memory_names"]),
936
- prompt_notes: Array(state["prompt_notes"]) }
909
+ state = nil unless state.is_a?(Hash)
910
+ if state
911
+ status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
937
912
  messages = last_assistant_for_display([tail["answer"]].compact)
938
913
  else
939
914
  session = @session_class.load(id, state_dir: default_state_dir)
940
- state = nil
941
- session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
942
- prompt_notes: session.prompt_notes.map(&:to_h) }
915
+ status = displayed_status(session)
916
+ used = session.used_memory_names
917
+ notes = session.prompt_notes.map(&:to_h)
943
918
  messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
944
919
  end
920
+ session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
945
921
  live_metrics = state && state["metrics"]
946
922
  # Without recent=1: a tab opened before this release (it replaces its
947
923
  # timing with the reply's). Remove this branch after the next release.
@@ -234,12 +234,15 @@ module Samagotchi
234
234
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
235
235
  def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
236
236
  tool_id: nil, marks: {})
237
+ # A built-in reached through call_tool reloads as that built-in's
238
+ # row, with via (its saved params and label are the built-in's).
239
+ call = Tools::BuiltinCalls.unwrap_saved(call, output)
237
240
  name = call[:name].to_s
238
241
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
239
242
  part = { tool: name, params: params.to_s }
240
- # Its row fields (ToolRowFields: title, called_as, view, ref), built from the saved
243
+ # Its row fields (ToolRowFields: title, called_as, via, view, ref), built from the saved
241
244
  # call (never saved themselves), so older sessions get them too. The
242
- # saved call is rebuilt by BuiltinCalls, which sets called_as again.
245
+ # saved call is rebuilt by BuiltinCalls, which sets called_as and via again.
243
246
  part.merge!(ToolRowFields.for(name, call, cwd: cwd))
244
247
  part[:label] = label if label.is_a?(String) && !label.empty?
245
248
  unless output.nil?
@@ -40,6 +40,8 @@ export function newActivity() {
40
40
  // it in place of the params when there is one.
41
41
  // called_as: the name the model called the tool by when it was an
42
42
  // alias's ("bash" run as execute); the row says so.
43
+ // via: the tool it was reached through ("call_tool": a built-in the
44
+ // session's toolkit doesn't declare); the row says so.
43
45
  // view: the call's full arguments for the expanded row and the hovers
44
46
  // (the server's ToolView: an execute/task_create's command, its cwd).
45
47
  // ref: what the row traces to (the server's FileRef: {kind: "file",
@@ -48,7 +50,7 @@ export function newActivity() {
48
50
  // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
49
51
  // label?, actions}]): the first on the row's title, the others in its
50
52
  // menu (refs.js bindRowRefs).
51
- export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
53
+ export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as", "via"]);
52
54
  export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
53
55
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
54
56