samagotchi 0.141.0 → 0.142.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/README.md +1 -1
  4. data/bin/chi +2 -0
  5. data/docs/architecture.md +12 -283
  6. data/docs/bundle-authoring.md +1 -0
  7. data/docs/cli.md +15 -7
  8. data/docs/configuration.md +57 -18
  9. data/docs/context.md +2 -2
  10. data/docs/guardrails.md +20 -11
  11. data/docs/hooks.md +9 -7
  12. data/docs/internals/attached-context.md +226 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bridge.md +380 -0
  15. data/docs/internals/bundles.md +1 -0
  16. data/docs/internals/context-telemetry.md +1 -1
  17. data/docs/internals/engine.md +24 -18
  18. data/docs/internals/participants.md +13 -12
  19. data/docs/internals/remote-sessions.md +1 -1
  20. data/docs/internals/{tool-guardrails.md → tool-output-limits.md} +21 -2
  21. data/docs/memory.md +31 -1
  22. data/docs/plugins.md +122 -1
  23. data/docs/sessions.md +50 -26
  24. data/docs/system-one.md +122 -0
  25. data/docs/testing.md +3 -1
  26. data/lib/samagotchi/bridge.rb +2 -0
  27. data/lib/samagotchi/bundles/check-in/manifest.yml +6 -3
  28. data/lib/samagotchi/bundles/check-in/plugin.rb +101 -4
  29. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/config.rb +74 -14
  35. data/lib/samagotchi/context_candidate_choice.rb +8 -0
  36. data/lib/samagotchi/context_candidates_log.rb +25 -1
  37. data/lib/samagotchi/context_judge.rb +6 -2
  38. data/lib/samagotchi/engine.rb +68 -7
  39. data/lib/samagotchi/extension_load.rb +3 -2
  40. data/lib/samagotchi/guardrail_wiring.rb +36 -31
  41. data/lib/samagotchi/guardrails/chi_dirs.rb +7 -4
  42. data/lib/samagotchi/guardrails/path_resolve.rb +1 -1
  43. data/lib/samagotchi/guardrails/protected_paths.rb +7 -4
  44. data/lib/samagotchi/guardrails_command.rb +3 -3
  45. data/lib/samagotchi/judgments_command.rb +155 -0
  46. data/lib/samagotchi/kernel_loop.rb +13 -1
  47. data/lib/samagotchi/llm/chat_loop.rb +74 -10
  48. data/lib/samagotchi/llm/errors.rb +7 -5
  49. data/lib/samagotchi/llm/stray_tool_call.rb +61 -0
  50. data/lib/samagotchi/log_line.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/entry_size.rb +100 -0
  52. data/lib/samagotchi/plugin/api.rb +33 -2
  53. data/lib/samagotchi/plugin/context.rb +49 -1
  54. data/lib/samagotchi/plugin/judgments.rb +72 -0
  55. data/lib/samagotchi/plugin/loader.rb +3 -2
  56. data/lib/samagotchi/run_options.rb +1 -0
  57. data/lib/samagotchi/self_report.rb +36 -0
  58. data/lib/samagotchi/session_access/contract.rb +1 -1
  59. data/lib/samagotchi/session_commands.rb +3 -0
  60. data/lib/samagotchi/session_manager.rb +13 -2
  61. data/lib/samagotchi/session_retention.rb +10 -0
  62. data/lib/samagotchi/side_judgments.rb +12 -1
  63. data/lib/samagotchi/system_one/answer.rb +63 -11
  64. data/lib/samagotchi/system_one/approval_label.rb +56 -0
  65. data/lib/samagotchi/system_one/approval_question.rb +2 -0
  66. data/lib/samagotchi/system_one/client.rb +43 -17
  67. data/lib/samagotchi/system_one/fake_backend.rb +1 -1
  68. data/lib/samagotchi/system_one/http_backend.rb +12 -4
  69. data/lib/samagotchi/system_one/judgment_settings.rb +61 -0
  70. data/lib/samagotchi/system_one/judgments.rb +260 -0
  71. data/lib/samagotchi/system_one/log.rb +4 -2
  72. data/lib/samagotchi/system_one/recording.rb +117 -0
  73. data/lib/samagotchi/system_one/runs.rb +179 -0
  74. data/lib/samagotchi/system_one/runs_store.rb +230 -0
  75. data/lib/samagotchi/system_one.rb +9 -0
  76. data/lib/samagotchi/testing/bundle.rb +9 -2
  77. data/lib/samagotchi/testing/host.rb +2 -1
  78. data/lib/samagotchi/testing/judgments.rb +185 -0
  79. data/lib/samagotchi/testing/recorder.rb +5 -0
  80. data/lib/samagotchi/testing/recording_api.rb +1 -1
  81. data/lib/samagotchi/tool_output_cut.rb +26 -6
  82. data/lib/samagotchi/tool_response.rb +1 -1
  83. data/lib/samagotchi/tool_runner.rb +63 -4
  84. data/lib/samagotchi/toolkit_suggest.rb +11 -2
  85. data/lib/samagotchi/tools/memory.rb +28 -0
  86. data/lib/samagotchi/turn_note.rb +9 -0
  87. data/lib/samagotchi/version.rb +1 -1
  88. metadata +16 -2
@@ -11,6 +11,7 @@ require_relative "anthropic_wire"
11
11
  require_relative "native_tool_normalizer"
12
12
  require_relative "refusal_memo"
13
13
  require_relative "stream_drops"
14
+ require_relative "stray_tool_call"
14
15
  require_relative "../kernel_loop"
15
16
  require_relative "../answer_display"
16
17
  require_relative "../answer_refs"
@@ -374,7 +375,7 @@ module Samagotchi
374
375
  def strip_model_thought(text) = @kernel.strip_model_thought(text)
375
376
 
376
377
  def tool_runner
377
- @tool_runner ||= ToolRunner.new(@kernel)
378
+ @tool_runner ||= ToolRunner.new(@kernel, window: -> { context_window(@kernel.turn_settings&.model_name) })
378
379
  end
379
380
 
380
381
  # The window for +model+: the kernel's client probes the server it
@@ -584,6 +585,10 @@ module Samagotchi
584
585
  @reasoning_start = ReasoningHistory.run_start(conversation, start || conversation.length)
585
586
  @tool_activity = []
586
587
  @empty_retry = EmptyAnswerRetry.new
588
+ # A reply that was only a tool call the host could not read is asked
589
+ # for once, whatever retry.empty_answer says (a bug fix, not a
590
+ # preference), at the same retry temperature.
591
+ @stray_retry = EmptyAnswerRetry.new(limit: 1)
587
592
  # The empty generations, never saved: the UIs draw the turn's
588
593
  # steps from them (ModelResult#empty_steps).
589
594
  @empty_steps = []
@@ -629,6 +634,10 @@ module Samagotchi
629
634
  # tool calls that came with it are not run.
630
635
  @refusal = refusal_of(response, last_text)
631
636
  stopped = @refusal || response.finish_reason.to_s == FILTERED
637
+ # The budget is for two in a row: a real tool call in between means
638
+ # the model recovered, and a slip later in a long turn gets its own retry.
639
+ @stray_retry = EmptyAnswerRetry.new(limit: 1) unless response.tool_calls.empty?
640
+
632
641
  if response.tool_calls.empty? || stopped
633
642
  # A refusal is no answer, never asked again; text it streamed
634
643
  # before is not kept as one. A filtered answer's text stays,
@@ -637,24 +646,56 @@ module Samagotchi
637
646
  last_text = "#{last_text}\n#{FILTERED_MARK}" if !@refusal && response.finish_reason.to_s == FILTERED
638
647
  # Only whitespace is no answer either (as in the native loop).
639
648
  empty = last_text.strip.empty?
649
+ # A reply that is nothing but a tool call the host could not
650
+ # read (LLM::StrayToolCall) is no answer either. Checked here,
651
+ # before the append, so the bad text never enters the history:
652
+ # the retry's prompt is the same conversation plus one note, and
653
+ # nothing is guessed from the block. (:generation_completed and
654
+ # :after_generation have already seen the text, as they do for
655
+ # every step; the UIs lose the streamed text with the
656
+ # :generation_retrying below.)
657
+ stray = empty || stopped ? nil : LLM::StrayToolCall.shape(last_text)
658
+ retry_stray = stray && @stray_retry.left? && !@cancel_controller&.cancelled?
659
+ if stray
660
+ Log.warn(:model, "generation_malformed", iteration: iteration, shape: stray,
661
+ finish_reason: response.finish_reason,
662
+ served_model: response.model,
663
+ retried: retry_stray,
664
+ payload: LLM::StrayToolCall.tail(last_text))
665
+ unless retry_stray
666
+ # The second one in a row is no answer and runs no tool: the
667
+ # turn fails, and its earlier steps stay (FailedTurn's
668
+ # partial conversation, as for any provider error here).
669
+ @stray_retry.drop_nudge!(@conversation)
670
+ host = host_label
671
+ raise LLM::MalformedGeneration.new("#{host}: the reply came back as a #{stray} block the host " \
672
+ "could not read, twice in a row; nothing was run", host: host)
673
+ end
674
+ end
640
675
  # Kept before a merge too: the model answers the merged line
641
676
  # knowing what it just said.
642
- @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty
677
+ @conversation << with_thinking({ role: "model", content: last_text }, response) unless empty || stray
643
678
  retry_empty = empty && !stopped &&
644
679
  @empty_retry.retry_empty?(iteration: iteration, cancelled: @cancel_controller&.cancelled?,
645
680
  finish_reason: response.finish_reason,
646
681
  used_tokens: response.usage&.total_tokens, window_tokens: @window&.tokens)
647
- # An empty answer that will be retried is no answer site: a
648
- # plugin's steer joins the retry instead of being dropped, and
649
- # queued input (a user's line, a steer) goes in place of the nudge.
650
- next if inject_pending_input(iteration, answer: retry_empty ? nil : last_text)
682
+ # An empty answer or a stray call that will be retried is no
683
+ # answer site: a plugin's steer joins the retry instead of being
684
+ # dropped, and queued input (a user's line, a steer) goes in
685
+ # place of the nudge.
686
+ next if inject_pending_input(iteration, answer: retry_empty || retry_stray ? nil : last_text)
651
687
 
652
688
  # An empty answer (content "" + stop, seen from a remote host)
653
- # ends the turn with no text: the UIs show their notice.
654
- @empty_steps << with_thinking({ role: "model", content: "" }, response) if empty
689
+ # ends the turn with no text: the UIs show their notice. A stray
690
+ # reply's text goes to the steps too, so a reload of the turn
691
+ # can show what the model wrote and did not get run.
692
+ @empty_steps << with_thinking({ role: "model", content: empty ? "" : last_text }, response) if empty || retry_stray
693
+ # Nothing of this reply is the turn's answer: a run that runs
694
+ # out of steps at the retry reports no answer, not the block.
695
+ last_text = "" if empty || retry_stray
655
696
  next if retry_empty && nudge_empty_answer(iteration, response)
697
+ next if retry_stray && nudge_stray_call(iteration, response, stray)
656
698
 
657
- last_text = "" if empty
658
699
  exhausted = false
659
700
  break
660
701
  end
@@ -692,6 +733,27 @@ module Samagotchi
692
733
  thinking_chars: response.reasoning.to_s.length)
693
734
  end
694
735
 
736
+ # The hidden nudge before the retry of a reply that was only a tool
737
+ # call (LLM::StrayToolCall). The step's streamed text is void, so the
738
+ # stream events say so first (:generation_retrying with restarted,
739
+ # what TurnAccumulator and the web already honour), then the note goes
740
+ # on the tail (:empty_answer_retry with malformed, the "malformed
741
+ # answer" line the UIs know) and the next request runs at the retry
742
+ # sampling. Returns true.
743
+ def nudge_stray_call(iteration, response, shape)
744
+ emit(type: :generation_retrying, iteration: iteration, restarted: true, shape: shape)
745
+ @stray_retry.nudge!(@conversation, TurnNote.malformed_retry,
746
+ emit: method(:emit), iteration: iteration, malformed: true, shape: shape,
747
+ finish_reason: response.finish_reason, thinking_chars: response.reasoning.to_s.length)
748
+ end
749
+
750
+ # The host a failure is blamed on: the adapter's own name, the model
751
+ # when the adapter has none.
752
+ def host_label
753
+ name = @loop.adapter.host_name if @loop.adapter.respond_to?(:host_name)
754
+ name.to_s.strip.empty? ? @model_name.to_s : name.to_s
755
+ end
756
+
695
757
  # A cut generation (CutPolicy): a Stop after it ends the turn as
696
758
  # any cancel, the turn going on is :retry, the hook ending is the
697
759
  # turn's result.
@@ -737,7 +799,9 @@ module Samagotchi
737
799
  @loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
738
800
  return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
739
801
 
740
- retry_generation = @empty_retry.take_sampling!
802
+ # The retry of an empty answer, or of a reply that was only a tool
803
+ # call, runs at the retry temperature.
804
+ retry_generation = @empty_retry.take_sampling! || @stray_retry.take_sampling!
741
805
  emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
742
806
  context_window_source: window&.source, price: @loop.price&.to_h)
743
807
  streamed = +""
@@ -244,11 +244,13 @@ module Samagotchi
244
244
  def summary = "unexpected response from host #{host}: #{detail}"
245
245
  end
246
246
 
247
- # A native generation that came out corrupt twice (KernelLoop: a tool
248
- # call never closed, or a fresh thought header after the answer), the
249
- # second time without the server's prompt cache. A poisoned llama.cpp
250
- # prompt cache does this (ggml-org/llama.cpp#27148); the text is not
251
- # an answer.
247
+ # A generation that came out corrupt twice, and is no answer. KernelLoop:
248
+ # a native generation with a tool call never closed, or a fresh thought
249
+ # header after the answer, the second time asked without the server's
250
+ # prompt cache (a poisoned llama.cpp prompt cache does this,
251
+ # ggml-org/llama.cpp#27148). ChatLoop: a reply that is nothing but a
252
+ # tool-call block the host could not read (LLM::StrayToolCall), asked for
253
+ # once more with a nudge.
252
254
  class MalformedGeneration < ProtocolError
253
255
  def summary = "malformed generation from host #{host}: #{detail}"
254
256
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module LLM
5
+ # A reply that is nothing but a tool call written as text. The host's
6
+ # parser could not read the block, so it came back as plain content with
7
+ # no tool calls: a Qwen flavour that writes `<parameter=execute>` where
8
+ # `<function=execute>` belongs, seen twice in 14 days of chat-loop
9
+ # sessions (docs/internals/engine.md, "A reply that is only a tool call").
10
+ # It is not an answer, and nothing is guessed from it: ChatLoop asks for
11
+ # the reply once more, and a second one fails the turn.
12
+ #
13
+ # The rule is the narrow one the data supports, and it needs no model
14
+ # profile (ChatLoop has no ToolCallParser): the whole reply is one closed
15
+ # block that opens with the call. A reply opening with prose, a fence or
16
+ # a quote is an answer, and so is one that mentions, quotes or fences the
17
+ # syntax. KernelLoop has its own path for a corrupt generation
18
+ # (ToolCallParser#malformed, per profile); this predicate is ChatLoop's.
19
+ module StrayToolCall
20
+ # A stray call can only open with the block itself.
21
+ OPENS = ["<tool_call>", "<function=", "<parameter="].freeze
22
+ # ... and end with the close of that block: nothing else in the reply.
23
+ CLOSES = ["</function>", "</parameter>", "</tool_call>"].freeze
24
+ # A block carries its own tags; an answer that merely starts with a
25
+ # "<" is not one.
26
+ INNER_TAG = /<[a-z_]+=/i
27
+ # How much of the reply the warning keeps (KernelLoop's own cap).
28
+ TAIL_CHARS = 400
29
+
30
+ module_function
31
+
32
+ # @return [String, nil] the shape of the reply ("tool_call", "function"
33
+ # or "parameter") when all of it is one closed tool-call block, nil
34
+ # when it reads as an answer.
35
+ # +text+ is the reply with its thought already stripped (ChatLoop's
36
+ # strip_model_thought).
37
+ def shape(text)
38
+ body = text.to_s.strip
39
+ # The cheap gate: a whole block has to open with the block. This is
40
+ # the whole cost of the check on a normal answer.
41
+ return nil unless body.start_with?("<")
42
+
43
+ opener = OPENS.find { |open| body.start_with?(open) }
44
+ return nil if opener.nil?
45
+ return nil unless CLOSES.any? { |close| body.end_with?(close) }
46
+
47
+ # The tags inside it: a lone opening tag in a sentence is not a call
48
+ # the host tried to read.
49
+ return nil unless body[1..-2].match?(INNER_TAG)
50
+
51
+ opener[/[a-z_]+/]
52
+ end
53
+
54
+ # The tail of a stray reply the log keeps, to see what the host sent.
55
+ def tail(text)
56
+ text = text.to_s
57
+ text.length > TAIL_CHARS ? text[-TAIL_CHARS..] : text
58
+ end
59
+ end
60
+ end
61
+ end
@@ -16,7 +16,7 @@ module Samagotchi
16
16
  # (debug dumps) are indented by four spaces and belong to the record above.
17
17
  module LogLine
18
18
  LEVELS = %w[DEBUG INFO WARN ERROR].freeze
19
- TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit].freeze
19
+ TAGS = %w[turn http worker bridge web attached repl idle recap hooks plugins guardrails config memory model context broadcast toolkit system_one].freeze
20
20
  INDENT = " "
21
21
 
22
22
  Record = Struct.new(:ts, :level, :tag, :pid, :sid, :event, :fields, :payload, keyword_init: true)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "index_size"
5
+ require_relative "index_sync"
6
+ require_relative "index_updater"
7
+ require_relative "../model_overlay"
8
+ require_relative "../tools/memory"
9
+
10
+ module Samagotchi
11
+ module MemoryBundle
12
+ # The size of one memory's text, which each memory_read of it pays
13
+ # (memory_read is not cut by max_tool_output_chars). memory_write and
14
+ # write/edit on a memory compare the size before and after a write
15
+ # against memory.entry_warn_tokens (#crossing_note); `chi self` lists
16
+ # the memories over it (SelfReport). tokens is the estimate IndexSize
17
+ # uses (characters / context.chars_per_token, rounded up).
18
+ #
19
+ # A memory that didn't exist before a write counts as 0 tokens. Not
20
+ # counted: the index, a model overlay, a model_notes_* memory (it has its
21
+ # own warning).
22
+ EntrySize = Data.define(:name, :scope, :tokens) do
23
+ # @param text [String, nil] the memory's text
24
+ # @return [EntrySize]
25
+ def self.of_text(name, scope, text, chars_per_token: IndexSize.chars_per_token)
26
+ new(name: name.to_s, scope: scope.to_s, tokens: (text.to_s.length / chars_per_token).ceil)
27
+ end
28
+
29
+ # Whether a memory of this name is sized at all (not the index, not a
30
+ # model note).
31
+ def self.counted?(name)
32
+ name = name.to_s
33
+ !name.casecmp?(IndexUpdater::MEMORY_INDEX) && !name.start_with?(Tools::MODEL_NOTES_PREFIX)
34
+ end
35
+
36
+ # The memory file at +path+ as it is now (0 tokens when it isn't
37
+ # there), nil when it isn't a counted memory of a scope or
38
+ # memory.entry_warn_tokens is off, or when it can't be measured (a
39
+ # write's measure must never fail the write).
40
+ # @return [EntrySize, nil]
41
+ def self.measure_file(path)
42
+ return nil unless warn_limit.positive?
43
+
44
+ path = File.expand_path(path.to_s)
45
+ scope = IndexSync.memory_scope(path)
46
+ name = File.basename(path, ".md")
47
+ return nil unless scope && counted?(name) && !ModelOverlay.overlay_file?(path)
48
+
49
+ of_text(name, scope, File.file?(path) ? File.read(path, encoding: "UTF-8") : "")
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ # The memories of +scope+ over +limit+ tokens, largest first: each
55
+ # top-level *.md of its dir but the index, the overlays and the
56
+ # model notes (hidden files, .bundles/ and archived/ are not in a
57
+ # top-level glob). Nothing for a limit that isn't positive.
58
+ # @param env [Hash] the XDG env the memories dir resolves from
59
+ # @return [Array<EntrySize>]
60
+ def self.over_limit(scope, limit: warn_limit, env: ENV)
61
+ return [] unless limit.to_i.positive?
62
+
63
+ Dir.glob(File.join(Tools::MemoryRead.memories_dir(scope, env: env), "*.md")).filter_map do |path|
64
+ name = File.basename(path, ".md")
65
+ next unless counted?(name) && File.file?(path) && !ModelOverlay.overlay_file?(path)
66
+
67
+ size = of_text(name, scope, File.read(path, encoding: "UTF-8"))
68
+ size if size.over?(limit)
69
+ end.sort_by { |size| [-size.tokens, size.name] }
70
+ end
71
+
72
+ # memory.entry_warn_tokens: the tokens one memory may hold before a
73
+ # write that crosses it gets a note; 0 turns the note off.
74
+ def self.warn_limit
75
+ Config.get("memory.entry_warn_tokens").to_i
76
+ rescue StandardError
77
+ 0
78
+ end
79
+
80
+ # The note a write gets when it took the memory from at or under
81
+ # +limit+ to over it, nil otherwise: once per crossing, as the
82
+ # index's. Independent of memory.index_warn_tokens.
83
+ # @param before [EntrySize, nil]
84
+ # @param after [EntrySize, nil]
85
+ # @return [String, nil]
86
+ def self.crossing_note(before, after, limit = warn_limit)
87
+ return nil unless before && after && after.over?(limit) && !before.over?(limit)
88
+
89
+ "Note: memory '#{after.name}' is now ~#{after.tokens} tokens (over memory.entry_warn_tokens #{limit}), paid " \
90
+ "on each read. When you next have a moment, tighten it; split out only parts that are read separately, " \
91
+ "since each new memory adds an index line to every prompt. Don't remove or merge memories unless the " \
92
+ "user asks."
93
+ end
94
+
95
+ # Whether the memory holds more than +limit+ tokens; a limit that
96
+ # isn't positive is off.
97
+ def over?(limit) = limit.to_i.positive? && tokens > limit.to_i
98
+ end
99
+ end
100
+ end
@@ -4,6 +4,7 @@ require_relative "../toolkit"
4
4
  require_relative "../log"
5
5
  require_relative "../hooks/registry"
6
6
  require_relative "../commands/registry"
7
+ require_relative "../system_one/judgments"
7
8
  require_relative "service"
8
9
  require_relative "tool_result"
9
10
  require_relative "tool_spec"
@@ -48,7 +49,8 @@ module Samagotchi
48
49
  "ctx.sessions.review" => "0.107.0", "ctx.sessions.send(delivery:)" => "0.64.0",
49
50
  "chi.toolkit(prompt:)" => "0.72.0", "ctx.ask_model(model:)" => "0.80.0", "chi.toolkit(groups:)" => "0.121.0",
50
51
  "chi.prompt_section" => "0.132.0", "chi.prompt_changed" => "0.132.0", "ctx.cache_confirm" => "0.132.0",
51
- "ctx.model_names" => "0.132.0"
52
+ "ctx.model_names" => "0.132.0", "chi.judgment" => "0.142.0", "ctx.judge" => "0.142.0",
53
+ "ctx.judge_later" => "0.142.0", "ctx.judgments" => "0.142.0"
52
54
  # Unreleased: "ctx.context.propose", "ctx.context.candidates", "ctx.context.withdraw", "ctx.review".
53
55
  }.freeze
54
56
 
@@ -71,6 +73,7 @@ module Samagotchi
71
73
  @toolkits = []
72
74
  @prompt_sections = []
73
75
  @peer_sources = []
76
+ @judgments = []
74
77
  @tool_source = nil
75
78
  @committed = false
76
79
  end
@@ -399,6 +402,33 @@ module Samagotchi
399
402
  nil
400
403
  end
401
404
 
405
+ # A cheap question this bundle asks System 1 (docs/plugins.md,
406
+ # Judgments): ctx.judge and ctx.judge_later ask it, and each ask saves a
407
+ # run. Off until the user sets its mode in config.yml's
408
+ # system_one.judgments, under its full name "<bundle>.<name>".
409
+ # @param name [String] dot-separated words of a-z, 0-9, _ and -
410
+ # @param questions [Hash, #call] id => {type:, instructions:, criteria:}
411
+ # ("noul", "choice" or "score"), checked now; or a callable given the
412
+ # ctx, returning that at each ask, where they vary per session
413
+ # @param positive [String, Array<String>, nil] the label values that
414
+ # say the answer was right, for a later comparison
415
+ # @param negative [String, Array<String>, nil] and wrong
416
+ # @param shared_host [Symbol] what to do while a turn runs on the same
417
+ # local server, where an ask evicts its cached prompt: :skip saves
418
+ # the run unasked (asked: false), :ask asks anyway
419
+ def judgment(name, questions:, positive: nil, negative: nil, shared_host: :skip)
420
+ raise ArgumentError, "this chi has no judgments (plugins: false)" unless @registries.judgments
421
+
422
+ declaration = SystemOne::Judgments::Declaration.build(bundle: @bundle, name: name, questions: questions, positive: positive,
423
+ negative: negative, shared_host: shared_host)
424
+ if @judgments.any? { |item| item.name == declaration.name } || @registries.judgments.declared?(declaration.name)
425
+ raise ArgumentError, "judgment #{declaration.name} is declared twice"
426
+ end
427
+
428
+ @judgments << declaration
429
+ nil
430
+ end
431
+
402
432
  # Register what was staged. Called by Loader after #register. What
403
433
  # can only be checked now (an annotation's command) is checked before
404
434
  # anything is registered: a raise here leaves the registries as they were.
@@ -439,6 +469,7 @@ module Samagotchi
439
469
  end
440
470
  @registries.tool_index.add(@tool_source) if @tool_source
441
471
  @peer_sources.each { |item| @registries.peer_sources.declare(item[:name], label: item[:label], bundle: @bundle) }
472
+ @judgments.each { |declaration| @registries.judgments.declare(declaration) }
442
473
  @committed = true
443
474
  end
444
475
 
@@ -446,7 +477,7 @@ module Samagotchi
446
477
  def counts = { commands: @commands.size, annotations: @annotations.size, tools: @tools.size, hooks: @hooks.size,
447
478
  services: @services.size, inits: @inits.size, toolkits: @toolkits.size,
448
479
  prompt_sections: @prompt_sections.size,
449
- tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size }
480
+ tool_sources: @tool_source ? 1 : 0, peer_sources: @peer_sources.size, judgments: @judgments.size }
450
481
 
451
482
  # Each annotation's command is one #command staged.
452
483
  def check_annotations!
@@ -28,6 +28,7 @@ module Samagotchi
28
28
  autoload :Http, File.expand_path("http", __dir__)
29
29
  autoload :HttpError, File.expand_path("http", __dir__)
30
30
  autoload :HttpCancelled, File.expand_path("http", __dir__)
31
+ autoload :Judgments, File.expand_path("judgments", __dir__)
31
32
 
32
33
  # The Engine's side of a Context: callables, so the Engine itself is
33
34
  # never handed out. +messages+ returns the conversation, +notify+ takes
@@ -65,11 +66,13 @@ module Samagotchi
65
66
  # session's model is looked up under (EffectiveModel#names).
66
67
  # +candidate_proposed+ takes (candidate): a late proposal no owner
67
68
  # prompt mentions, for the candidate judge (Engine#judge_late_candidates).
69
+ # +judgments+ returns the SystemOne::Judgments ctx.judge, ctx.judge_later
70
+ # and ctx.judgments ask and label through.
68
71
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
69
72
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :setup, :prompt_files,
70
73
  :steer, :stop_turn, :stop_generation, :frontend, :toolkit, :declared_tools, :use_toolkit, :load_tools,
71
74
  :cancel_controller, :cost, :usage, :turn_author, :cache_confirm, :model_names,
72
- :candidate_proposed, keyword_init: true)
75
+ :candidate_proposed, :judgments, keyword_init: true)
73
76
 
74
77
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
75
78
  # sent nothing usable. The message says why, for the user.
@@ -407,6 +410,45 @@ module Samagotchi
407
410
  # ctx.ask_model's answer limit when none is given.
408
411
  ASK_MAX_TOKENS = 1024
409
412
 
413
+ # Ask System 1 this bundle's judgment (chi.judgment, docs/plugins.md,
414
+ # Judgments) and wait, at most system_one.timeout_ms. A Stop of the
415
+ # turn cuts an ask made for it. Off, no host, a failed backend or a
416
+ # skip is a Result with no answers, never a raise.
417
+ # @param name [String] as declared, without the bundle
418
+ # @param state [String, Hash, Array] what the question is about; it
419
+ # goes to the System 1 host and is saved in the run as sent
420
+ # @return [SystemOne::Judgments::Result] #answers, #p(id), #run_id,
421
+ # #audit?, #no_judgment?, #reason
422
+ # @raise [ArgumentError] a judgment this bundle didn't declare, a bad
423
+ # state, or called in a :generation_progress handler, whose stream it
424
+ # would hold (use #judge_later)
425
+ def judge(name, state:)
426
+ event = Thread.current[CURRENT_EVENTS]&.[](self)
427
+ if event.is_a?(Hash) && event[:type] == :generation_progress
428
+ raise ArgumentError, "ctx.judge would hold the stream in a :generation_progress handler; use ctx.judge_later"
429
+ end
430
+
431
+ judgment_layer.judge(judgment_name(name), state: state, context: self, cancel_controller: http_cancel_controller)
432
+ end
433
+
434
+ # #judge off this thread: the run's id comes back at once and the block
435
+ # gets the Result later, on another thread, always once. So its ctx.*
436
+ # act on the session as it is then, not this handler's event. While
437
+ # this judgment's last judge_later still runs, nothing is asked: a
438
+ # skipped run, and the block gets a Result with no answers.
439
+ # @return [String, nil] the run's id; nil when no run will be saved (off,
440
+ # no host, runs off, a scratch session, a participant's turn)
441
+ # @raise [ArgumentError] a judgment this bundle didn't declare, a bad state
442
+ def judge_later(name, state:, &)
443
+ judgment_layer.later(judgment_name(name), state: state, context: self, &)
444
+ end
445
+
446
+ # What came of this bundle's judgments' runs: #label, #acted.
447
+ # @return [Judgments]
448
+ def judgments
449
+ @judgments ||= Judgments.new(-> { judgment_layer }, bundle: @bundle)
450
+ end
451
+
410
452
  # Other sessions: fork one from a conversation, send one a message,
411
453
  # read one (Sessions).
412
454
  # @return [Sessions]
@@ -493,6 +535,12 @@ module Samagotchi
493
535
 
494
536
  private
495
537
 
538
+ def judgment_layer
539
+ @host.judgments&.call or raise ArgumentError, "this chi has no judgments (plugins: false)"
540
+ end
541
+
542
+ def judgment_name(name) = "#{@bundle}.#{name}"
543
+
496
544
  # Whether this plugin's handler runs on this thread (#with_event).
497
545
  def in_handler? = !!Thread.current[CURRENT_EVENTS]&.key?(self)
498
546
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../system_one/runs"
4
+
5
+ module Samagotchi
6
+ module Plugin
7
+ # ctx.judgments: what came of a bundle's own runs (docs/plugins.md,
8
+ # Judgments), saved as later lines on them. A bundle's lines say it is
9
+ # theirs (+by+ is its name), and it reaches no one else's runs.
10
+ class Judgments
11
+ MAX_TEXT = 200
12
+ STRENGTHS = %w[strong weak].freeze
13
+
14
+ # @param layer [#call] → the SystemOne::Judgments to save through
15
+ # @param bundle [String]
16
+ def initialize(layer, bundle:)
17
+ @layer = layer
18
+ @bundle = bundle
19
+ end
20
+
21
+ # The bundle's own word for its run: a raw value now, mapped at analysis.
22
+ # @param run_id [String, nil] ctx.judge's Result#run_id, or what
23
+ # ctx.judge_later returned; nil (no run was saved) saves nothing
24
+ # @param value [String] up to 200 characters
25
+ # @param by [String, nil] what said it, after the bundle's name ("card":
26
+ # "check-in:card")
27
+ # @param strength [String, nil] "strong" or "weak"
28
+ # @param question [String, nil] the question it answers
29
+ # @return [Boolean] whether it was saved
30
+ # @raise [ArgumentError] a run of another bundle's, a bad value
31
+ def label(run_id, value, by: nil, strength: nil, question: nil)
32
+ value = text("label", value)
33
+ raise ArgumentError, "strength: is #{STRENGTHS.join(" or ")}, not #{strength.inspect}" unless strength.nil? || STRENGTHS.include?(strength.to_s)
34
+ return false unless own?(run_id)
35
+
36
+ @layer.call.label(run_id, value, by: by.to_s.strip.empty? ? @bundle : "#{@bundle}:#{text("by", by)}",
37
+ strength: strength&.to_s, question: question && text("question", question.to_s))
38
+ end
39
+
40
+ # What the bundle did with the answer: never known when the run is saved.
41
+ # @param what [String] up to 200 characters
42
+ # @return [Boolean] whether it was saved
43
+ # @raise [ArgumentError] a run of another bundle's, a bad value
44
+ def acted(run_id, what)
45
+ what = text("acted", what)
46
+ return false unless own?(run_id)
47
+
48
+ @layer.call.acted(run_id, what)
49
+ end
50
+
51
+ private
52
+
53
+ # False for nil; a run of anything but a judgment this bundle declared
54
+ # raises (a name it can't declare has no run of its).
55
+ def own?(run_id)
56
+ return false if run_id.nil?
57
+
58
+ judgment = SystemOne::Runs.judgment_of(run_id)
59
+ return true if judgment&.start_with?("#{@bundle}.") && @layer.call.declared?(judgment)
60
+
61
+ raise ArgumentError, "#{run_id.inspect} is not a run of bundle #{@bundle}'s"
62
+ end
63
+
64
+ def text(what, value)
65
+ word = value.to_s.strip
66
+ return word if value.is_a?(String) && word.length.between?(1, MAX_TEXT)
67
+
68
+ raise ArgumentError, "#{what} takes a String of 1 to #{MAX_TEXT} characters, not #{value.inspect[0, 40]}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -22,9 +22,10 @@ module Samagotchi
22
22
  # +tool_index+ the Engine's Tools::ToolIndex (chi.tool_source);
23
23
  # +peer_sources+ the Engine's PeerSources (chi.peer_source);
24
24
  # +prompt_sections+ the Engine's PromptSections (chi.prompt_section),
25
- # +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!).
25
+ # +prompt_changed+ rebuilds its system prompts (chi.prompt_changed!),
26
+ # +judgments+ the Engine's SystemOne::Judgments (chi.judgment).
26
27
  Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
27
- :toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed,
28
+ :toolkits, :tool_index, :peer_sources, :prompt_sections, :prompt_changed, :judgments,
28
29
  keyword_init: true)
29
30
 
30
31
  # Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
@@ -207,6 +207,7 @@ module Samagotchi
207
207
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
208
208
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
209
209
  chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
210
+ chi judgments ls | report NAME System 1's saved asks on this machine
210
211
  chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
211
212
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
212
213
  chi models [--format json] [TEXT] the models --model takes, from every host
@@ -21,6 +21,7 @@ require_relative "memory_bundle/provenance"
21
21
  require_relative "memory_bundle/manifest"
22
22
  require_relative "memory_bundle/system_bundle"
23
23
  require_relative "memory_bundle/index_size"
24
+ require_relative "memory_bundle/entry_size"
24
25
  require_relative "desktop"
25
26
  require_relative "live_versions"
26
27
  require_relative "web/lan"
@@ -60,6 +61,7 @@ module Samagotchi
60
61
  ["memories", Tools::MemoryRead.memories_dir("system", env: env)],
61
62
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
62
63
  ["memory index", memory_index_summary(env)],
64
+ ["big memories", big_memories_summary(env)],
63
65
  ["sessions", Session.default_state_dir(env: env)],
64
66
  ["log", log_path(env)],
65
67
  ["model", model ? model_label(model, default, env) : "(not configured)"],
@@ -122,6 +124,40 @@ module Samagotchi
122
124
  "(unreadable: #{e.message})"
123
125
  end
124
126
 
127
+ # How many big memories "big memories" names before "+N more".
128
+ BIG_MEMORIES_SHOWN = 5
129
+
130
+ # The memories over memory.entry_warn_tokens in both scopes, largest
131
+ # first, "name ~5.6k tokens (system, from samagotchi-system)": each
132
+ # memory_read of one pays its size, and a memory already over gets no
133
+ # crossing note. A bundle's memory says whose it is (upstream's to
134
+ # tighten). "none" when there are none, "off" with the limit 0. A scope
135
+ # that can't be read is named instead.
136
+ def big_memories_summary(env)
137
+ limit = MemoryBundle::EntrySize.warn_limit
138
+ return "off (memory.entry_warn_tokens 0)" unless limit.positive?
139
+
140
+ big = []
141
+ unreadable = []
142
+ %w[system project].each do |scope|
143
+ big.concat(MemoryBundle::EntrySize.over_limit(scope, limit: limit, env: env))
144
+ rescue StandardError => e
145
+ unreadable << "#{scope} memories unreadable: #{e.message}"
146
+ end
147
+ shown = big.sort_by { |size| [-size.tokens, size.scope, size.name] }.first(BIG_MEMORIES_SHOWN).map { |size| big_memory(size, env) }
148
+ shown << "+#{big.size - BIG_MEMORIES_SHOWN} more" if big.size > BIG_MEMORIES_SHOWN
149
+ parts = shown + unreadable.map { |text| "(#{text})" }
150
+ parts.empty? ? "none" : parts.join(", ")
151
+ end
152
+
153
+ def big_memory(size, env)
154
+ owner = MemoryBundle::Provenance.claimants("#{size.name}.md", scope: size.scope, dir: MemoryPaths.bundles_dir(env: env)).first
155
+ where = [size.scope, ("from #{owner}" if owner)].compact.join(", ")
156
+ "#{size.name} ~#{MemoryBundle::IndexSize.count_text(size.tokens)} tokens (#{where})"
157
+ rescue StandardError
158
+ "#{size.name} ~#{MemoryBundle::IndexSize.count_text(size.tokens)} tokens (#{size.scope})"
159
+ end
160
+
125
161
  def with_presence(path)
126
162
  File.exist?(path) ? path : "#{path} (missing)"
127
163
  end
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  # bin/chi subcommands that act on this machine: never sessions. chi
27
27
  # remote (this machine's list of remotes) runs here under a remote too
28
28
  # (Dispatch); the others are refused there.
29
- MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit remote home].freeze
29
+ MACHINE = %w[bootstrap update models desktop memory bundle guardrails toolkit judgments remote home].freeze
30
30
 
31
31
  # "command", "command sub" or either with a flag (its long name) →
32
32
  # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists