samagotchi 0.36.0 → 0.38.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -1
  3. data/README.md +3 -3
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +95 -13
  6. data/docs/configuration.md +9 -2
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +17 -2
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/engine.md +4 -2
  11. data/docs/plugins.md +107 -34
  12. data/lib/samagotchi/broadcast/scope_card.rb +15 -2
  13. data/lib/samagotchi/broadcast/triage.rb +284 -0
  14. data/lib/samagotchi/broadcast/triage_log.rb +170 -0
  15. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  16. data/lib/samagotchi/broadcast_command.rb +275 -69
  17. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
  18. data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
  19. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  22. data/lib/samagotchi/cli/command.rb +27 -3
  23. data/lib/samagotchi/config.rb +9 -0
  24. data/lib/samagotchi/context_command.rb +0 -17
  25. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  26. data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
  27. data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
  28. data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
  29. data/lib/samagotchi/desktop/macos.rb +27 -5
  30. data/lib/samagotchi/desktop_command.rb +1 -1
  31. data/lib/samagotchi/engine.rb +7 -8
  32. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  33. data/lib/samagotchi/guardrails/approval.rb +5 -2
  34. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  35. data/lib/samagotchi/guardrails/rules.rb +4 -2
  36. data/lib/samagotchi/guardrails/targets.rb +76 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/kernel_loop.rb +6 -4
  41. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  42. data/lib/samagotchi/log_line.rb +1 -1
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/plugin/api.rb +5 -3
  45. data/lib/samagotchi/recap_setup.rb +38 -52
  46. data/lib/samagotchi/send_command.rb +0 -22
  47. data/lib/samagotchi/tool_response.rb +5 -5
  48. data/lib/samagotchi/tool_runner.rb +18 -9
  49. data/lib/samagotchi/update_command.rb +1 -1
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/question_card.js +4 -3
  52. metadata +6 -1
data/docs/plugins.md CHANGED
@@ -162,8 +162,8 @@ in the chat path's `tools:`, after chi's own tools.
162
162
  it raises, the default is shown. Both are saved with the call's result, so
163
163
  a web page reloaded later shows the same row: the web server doesn't run
164
164
  plugins.
165
- - `targets`: `->(args) { { paths: [...], command: "…", cwd: "…" } }`, each
166
- key optional, says what a call acts on, for [guardrails](#guardrails).
165
+ - `targets`: `->(args) { { paths: [...], command: "…", cwd: "…", acts_as: "…", args: {…}, label: "…" } }`,
166
+ each key optional, says what a call acts on, for [guardrails](#guardrails).
167
167
 
168
168
  The block returns the result text. If it starts with `Error:`, it counts as
169
169
  a failure. If it raises, the model gets `Error: <message>`. To return images
@@ -234,6 +234,27 @@ is what `targets:` says:
234
234
  paths (chi's config, …) match them.
235
235
  - `command:`: a shell command the call runs; `command:` rules match it.
236
236
  - `cwd:`: where it runs, for the repo root and relative paths.
237
+ - `acts_as:`: the tool a call stands for, when the tool runs other tools
238
+ (the mcp bundle's `mcp_call`). Rules keyed by that tool's name match the
239
+ call too, as well as rules on the tool's own name. It can't be one of
240
+ chi's own tools or another bundle's tool: that is dropped (and logged).
241
+ - `args:`: the arguments it acts with (a Hash), when they aren't the call's
242
+ own (a dispatcher's inner arguments). A call with no `command:` or
243
+ `paths:` is asked about with them, and "allow this call" is keyed by them.
244
+ - `label:`: what the approval question names the call by (`github: x`), in
245
+ place of the tool's `label` (or, with `acts_as:`, that tool's). Display
246
+ only: rules and approvals don't read it. One line of at most 80
247
+ characters, else it is ignored (and logged).
248
+
249
+ ```ruby
250
+ targets: ->(args) { { acts_as: "mcp_#{args["server"]}_#{args["tool"]}", args: args["args"],
251
+ label: "#{args["server"]}: #{args["tool"]}" } }
252
+ ```
253
+
254
+ An approval of an `acts_as:` call keys on the tool's own name as well
255
+ (`mcp_call>mcp_github_x:…`), so it never stands in for an approval of the
256
+ tool it acts as, nor that one for it. Hooks see the call's own name, and so
257
+ does loop-guard's `ignore_tools`.
237
258
 
238
259
  A tool without `targets:` is matched by its name only. A `targets:` that
239
260
  raises counts as nothing (it is logged). See [guardrails.md](guardrails.md).
@@ -431,8 +452,10 @@ end
431
452
  start`; the card shows the bundle beside it), and its body the message;
432
453
  without `failed:` the title is `setup failed` and the body
433
454
  `<label>: <message>`.
434
- - **`provides_tools: true`**: the task brings tools (with
435
- `chi.replace_tools`). A turn sent while it runs starts at once (the user's
455
+ - **`provides_tools: true`**: the task provides what the plugin's tools
456
+ need: tools of its own (with `chi.replace_tools`), or what fixed tools
457
+ read (the mcp bundle's index of a first-run server's tools, which
458
+ `find_mcp_tools` searches). A turn sent while it runs starts at once (the user's
436
459
  message shows), then waits for it **before its first model request**, so
437
460
  the model sees the tools; the UIs keep showing the task meanwhile. The
438
461
  wait lasts at most `timeout` seconds from the task's start (default 60).
@@ -737,10 +760,12 @@ only against this API (`lib/samagotchi/bundles/btw/plugin.rb`).
737
760
  ## The mcp bundle
738
761
 
739
762
  `chi bundle install mcp` (or the `dev` profile) installs the bundle shipped with chi. It is written
740
- only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and adds
741
- tools from [MCP](https://modelcontextprotocol.io) servers. It has no memory
742
- file, so it costs the prompt nothing but its tools. Stdio servers only, for
743
- now.
763
+ only against this API (`lib/samagotchi/bundles/mcp/plugin.rb`), and gives
764
+ the model the tools of [MCP](https://modelcontextprotocol.io) servers. It
765
+ has no memory file, and the servers' tools aren't declared to the model one
766
+ by one: it has two tools, `find_mcp_tools` and `mcp_call` (~250 tokens in
767
+ every request plus a line per server, whatever the servers have; `/mcp`
768
+ says how many). Stdio servers only, for now.
744
769
 
745
770
  ```yaml
746
771
  # config.yml
@@ -756,6 +781,7 @@ bundles:
756
781
  env: {NODE_OPTIONS: "--no-warnings"} # added to chi's environment
757
782
  cwd: ~/scratch # default: where chi runs
758
783
  tools: [read_*, list_directory] # optional: only these (globs)
784
+ description: files in ~/scratch # optional: its line in find_mcp_tools
759
785
  timeout: 120 # optional: this server's per-call timeout
760
786
  chrome:
761
787
  command: [npx, -y, "chrome-devtools-mcp@latest", --slim, --headless]
@@ -767,29 +793,33 @@ bundles:
767
793
  saved in the bundle's data dir (`$XDG_STATE_HOME/samagotchi/plugins/mcp/
768
794
  tools-<server>.json`), keyed by a digest of its `command`, `env` (names
769
795
  and values: only the digest is stored) and `cwd`. A session with a saved
770
- list registers the tools at once and **doesn't start the server**: the
796
+ list can search its tools at once and **doesn't start the server**: the
771
797
  first call of one of its tools does (the call's row shows the wait). So a
772
798
  session that never uses MCP spawns nothing, and a new chat opens without
773
799
  waiting for `npx`. If the live list differs from the saved one, the saved
774
- one is replaced, and so are the tools, from the next turn on.
800
+ one is replaced, and so is what a search finds. The model's tools never
801
+ change, so the prompt cache keeps. The saved file also keeps the first
802
+ sentence of the server's `instructions` (from `initialize`), for its line
803
+ in `find_mcp_tools`.
775
804
  - **The first run** (no saved list, or the config changed) starts the
776
805
  server in an [init task](#chiinitlabel-provides_tools-false-quiet-false-timeout-nil--ctx--):
777
806
  every UI shows `Starting MCP server x (first run, saving its tools)`, and
778
807
  a turn sent meanwhile waits for its tools. A server that doesn't start,
779
808
  answer or list its tools within `startup_timeout` (each step) is a warn
780
- card, `…: failed`, and its tools are left out. The rest of chi works as
781
- usual.
809
+ card, `…: failed`, and its calls fail until chi restarts. The rest of chi
810
+ works as usual.
782
811
  - **Freshness.** A saved list older than a day is still used, and a quiet
783
812
  background task lists the tools again with a server of its own (then
784
- stops it), saves them, and replaces the tools if they changed. One worker
785
- does it at a time.
813
+ stops it), saves them, and replaces the searched ones if they changed.
814
+ One worker does it at a time.
786
815
  - **A server that says its tools changed** (`notifications/tools/list_changed`)
787
- is asked for its `tools/list` again; the saved list and the tools are
788
- replaced from the next turn on.
816
+ is asked for its `tools/list` again; the saved list and the searched tools
817
+ are replaced.
789
818
  - **A cached server that doesn't start** (the command is gone, it crashes)
790
819
  fails that call with `Error: MCP server x didn't start: …` and one notice;
791
- later calls answer the same at once, and its tools are left out from the
792
- next turn. The saved list stays: the next session tries again.
820
+ later calls answer the same at once until chi restarts, and a search shows
821
+ its tools as `(failed: …)`. The saved list stays: the next session tries
822
+ again.
793
823
  - **`start: eager`** on a server starts it with every session (in an init
794
824
  task, after the Bridge is up), for a server whose start does something
795
825
  you want at once.
@@ -803,17 +833,45 @@ bundles:
803
833
  command: [chrome-devtools-mcp, --slim, --headless] # after npm i -g chrome-devtools-mcp
804
834
  ```
805
835
 
806
- - **Tools.** Each tool is the model's as `mcp_<server>_<tool>`, lower case,
807
- with anything but a-z, 0-9 and `_` made `_`, cut at 48 characters. A name
808
- that clashes is left out, with a notice. The tool's `inputSchema` is its
809
- schema (flattened on the native paths, see
810
- [Schemas on the native paths](#schemas-on-the-native-paths)); its label is
811
- `<server>: <tool>` and its preview the arguments, short.
836
+ - **Tools: search, then call.** The servers' tools are kept in an index;
837
+ the model has two fixed tools:
838
+ - `find_mcp_tools(query, server?)` searches it by keywords over the
839
+ server's name, the tool's name and its description, and answers up to 5
840
+ tools, each with its name `<server>/<tool>`, its description and its
841
+ whole `inputSchema`. A word counts more the fewer tools have it (a word
842
+ most of a browser server's tools share, "page", counts little), and
843
+ three times as much in a name as in a description. An empty query (and
844
+ a search with no match) lists every server's tool names, without
845
+ schemas. A tool whose `inputSchema` (or a property's schema) isn't an
846
+ object shows
847
+ `(bad schema)` (and is said once, as a notice), a failed server's tools
848
+ `(failed: …)`, a first-run server still starting `(starting, try
849
+ again)`. Its description names each server on one line, never its
850
+ tools: the server's `description:`, else the first sentence of its
851
+ `instructions`, else its first 6 tool names, and how many tools it has
852
+ (a first run's server is just its name until the next session).
853
+ - `mcp_call(tool, args)` calls one: `tool` is `<server>/<tool>`, and
854
+ `mcp_<server>_<tool>` works too (looked up, not parsed). Two tools whose
855
+ `mcp_<server>_<tool>` comes out the same (servers `git-hub` and
856
+ `git_hub`, names alike up to the 48-character cut) are both refused,
857
+ since guardrail rules and approvals couldn't tell them apart: a search
858
+ and `/mcp` mark them `(name clash with …)`, and there is one notice.
859
+ `args` are typed by the tool's
860
+ `inputSchema` (numbers and booleans given as text, JSON text for an
861
+ object). A name it doesn't know answers the closest ones (`Error: no
862
+ MCP tool chrome/open_url. Closest: chrome/new_page, …`). Its row shows
863
+ `mcp chrome/take_screenshot fullPage=true`.
864
+ - A tool turn takes a few more steps than when every tool was declared
865
+ (the P1 spike: about 1.5 more on a 30-tool server), and each request is
866
+ smaller (about half there).
812
867
  - **Calls.** A call is `tools/call`. The text blocks of the answer are joined;
813
868
  audio or a resource without text is a short placeholder
814
- (`[audio: audio/wav]`). `isError` makes it `Error: …`. A call that takes
815
- longer than the timeout is an `Error:`, and a cancelled turn stops the
816
- wait; both send `notifications/cancelled` to the server.
869
+ (`[audio: audio/wav]`). `isError` makes it `Error: …`; it, a JSON-RPC
870
+ error and `args` that aren't an object end with the tool's schema
871
+ (`chrome/click's inputSchema: {…}`), so a call made without a search can
872
+ be fixed on the next step. A call that takes longer than the timeout is
873
+ an `Error:`, and a cancelled turn stops the wait; both send
874
+ `notifications/cancelled` to the server.
817
875
  - **Images.** An `image` block goes to the model as a picture
818
876
  ([Returning images](#returning-images): at most 4 per call, a line
819
877
  instead when the model can't see images). Its place in the text is a line,
@@ -835,17 +893,20 @@ bundles:
835
893
  - **Stop.** The servers stop with chi ([Shutdown](#shutdown)): stdin is
836
894
  closed, then TERM and KILL go to the server's process group.
837
895
  - **`/mcp`** (anytime) shows a card with the servers, their state (cached
838
- (not started), running with its pid, failed, stopped) and their tools.
896
+ (not started), running with its pid, failed with its count, stopped) and
897
+ their tools as `<server>/<tool>`, the name `mcp_call` takes.
839
898
  A cached or running server's line says roughly how many tokens its tool
840
- definitions take (`~4,232 tokens`), and the last line totals the servers
841
- whose tools the model has: what every request carries. It is an estimate:
899
+ definitions take (`~4,232 tokens`), which only a search's answer carries
900
+ now; the last line says what every request carries (`find_mcp_tools` and
901
+ `mcp_call`) and totals the servers'. It is an estimate:
842
902
  the definitions' JSON as the chat API gets it (`api: openai`), divided by
843
903
  4. The native prompts (Gemma, Qwen) render a flatter schema, so there they
844
904
  take less. The log records each server's estimate when it changes
845
905
  (`mcp_tools_estimated`).
846
906
  - The server's stderr goes to the debug log (`plugins` records, bundle=mcp).
847
907
  - **Guardrails.** A rule's `tool:` can be a glob, so one rule covers every
848
- MCP tool:
908
+ MCP tool (a search is `find_mcp_tools`, outside `mcp_*`, so it isn't
909
+ asked about):
849
910
 
850
911
  ```yaml
851
912
  guardrails:
@@ -856,10 +917,22 @@ bundles:
856
917
  reason: an MCP server's tool
857
918
  ```
858
919
 
859
- An MCP tool has no `targets:`, so the question shows its arguments,
860
- under the tool's label as its row shows it (`everything: get_sum: a=20
861
- b=22`; any plugin tool with a label is asked about by it), and "Allow this call for the
920
+ `mcp_call`'s `targets:` act as the tool it calls, by its
921
+ `mcp_<server>_<tool>` name ([acts_as:](#guardrails)), so `tool:
922
+ "mcp_github_*"` or `tool: mcp_github_merge_pull_request` match an
923
+ `mcp_call` to it, as they matched the tool when it was declared by that
924
+ name. A call made before the server's tools are known (a first run
925
+ still starting) is matched by the name the model gave, so the rule fires
926
+ before the call waits; if the tool it then finds isn't that one, the
927
+ call is refused. The question shows the inner arguments under `<server>: <tool>`
928
+ (`everything: get_sum: a=20 b=22`), and "Allow this call for the
862
929
  session" (or in this repo) allows that tool with those arguments only.
930
+ (Approvals stored before 0.6.0, under the old name, don't carry over.)
931
+ Two things see `mcp_call`, not the tool: hooks (a `before_tool_call`
932
+ event's `tool:` is `"mcp_call"`, the tool in its args), and loop-guard's
933
+ `ignore_tools` (`mcp_call` ignores every MCP call; calls to different
934
+ tools, or with different args, stay distinct, since the tool is in the
935
+ args).
863
936
 
864
937
  ## The loop-guard bundle
865
938
 
@@ -74,16 +74,29 @@ module Samagotchi
74
74
  end
75
75
  private_class_method :user_texts
76
76
 
77
+ # Both paths resolved first: a linked worktree's project root comes
78
+ # back resolved (/private/tmp/… on macOS) while the session's folder
79
+ # may go through a symlink (/tmp/…).
77
80
  def folder(cwd, project)
78
81
  return ContextNote.home_relative(cwd) if project.nil?
79
- return nil if cwd.to_s == project.to_s
80
82
 
81
- Pathname.new(cwd.to_s).relative_path_from(Pathname.new(project.to_s)).to_s
83
+ cwd = real(cwd)
84
+ project = real(project)
85
+ return nil if cwd == project
86
+
87
+ Pathname.new(cwd).relative_path_from(Pathname.new(project)).to_s
82
88
  rescue ArgumentError
83
89
  cwd
84
90
  end
85
91
  private_class_method :folder
86
92
 
93
+ def real(path)
94
+ File.realpath(path.to_s)
95
+ rescue SystemCallError
96
+ File.expand_path(path.to_s)
97
+ end
98
+ private_class_method :real
99
+
87
100
  def cut(text, limit)
88
101
  line = text.to_s.gsub(/\s+/, " ").strip
89
102
  line.length > limit ? "#{line[0, limit - 1]}…" : line
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../idle_client"
4
+ require_relative "../idle_target"
5
+ require_relative "../cancellation_controller"
6
+ require_relative "../log"
7
+ require_relative "tags"
8
+
9
+ module Samagotchi
10
+ module Broadcast
11
+ # Whether a broadcast concerns a recipient its tags didn't match: a
12
+ # small model reads the note and the session's ScopeCard and answers
13
+ # yes or no (Triage::LLM). Many recipients are judged in parallel under
14
+ # one deadline (Triage.judge_all); whatever isn't judged by then, or
15
+ # fails, is delivered unchecked (fail open: a missed recipient is worse
16
+ # than an extra note).
17
+ module Triage
18
+ # One recipient's verdict.
19
+ # @!attribute relevant [Boolean] it gets the note
20
+ # @!attribute p [Float, nil] the model's P(yes) from its logprobs,
21
+ # 1.0 or 0.0 from a plain answer, nil when no model judged it
22
+ # @!attribute reason [String] why, for the output and the log
23
+ # @!attribute by [String] who decided: "tags" (a shared tag),
24
+ # "model", "fallback" (delivered unchecked: deadline, error, an
25
+ # answer that is no yes or no), "scope" (the note's first line
26
+ # names another project), "all" (--all) or "repl" (a chi REPL owns
27
+ # it: no notes)
28
+ # @!attribute match [Match, nil] the shared tag (by "tags")
29
+ Verdict = Data.define(:relevant, :p, :reason, :by, :match) do
30
+ def initialize(match: nil, **) = super
31
+
32
+ def unchecked? = by == "fallback"
33
+ end
34
+
35
+ # The spike's prompt (plain yes/no beat JSON with a p and a reason
36
+ # for every model tried: writing a reason pushes towards "relevant").
37
+ SYSTEM = <<~TEXT
38
+ You route short notes that a developer broadcasts to their running coding-agent sessions.
39
+ You get one note and one session's card (project, branch, title, what the session was started for, its latest recap, the last request).
40
+ Decide whether the note may affect THIS session's current work: would the agent working on it want to know?
41
+ A note can start with an informal scope line (a project, a component, a team); use it as a hint.
42
+ Notes about things unrelated to the session's work (social, other projects, other components) are not relevant.
43
+ Answer with exactly one word: yes or no.
44
+ TEXT
45
+
46
+ DEFAULT_PARALLEL = 4
47
+ DEFAULT_DEADLINE = 20.0
48
+ DEFAULT_THRESHOLD = 0.5
49
+ # How long the threads still asking get to stop once the deadline
50
+ # passed, all of them together (then they are killed).
51
+ JOIN_GRACE = 1.0
52
+
53
+ module_function
54
+
55
+ # @return [Verdict] delivered unchecked, +why+ in the reason
56
+ def unchecked(why) = Verdict.new(relevant: true, p: nil, reason: "unchecked: #{why}", by: "fallback")
57
+
58
+ # The messages a model judges +note+ for +card+ by.
59
+ def messages(note, card)
60
+ [{ role: "system", content: SYSTEM }, { role: "user", content: "NOTE:\n#{note}\n\nSESSION CARD:\n#{card}" }]
61
+ end
62
+
63
+ # The one project the note's first line names, as a whole word (any
64
+ # case), among +projects+ (the recipients' project names); nil when it
65
+ # names none or several. A scope line ("shopfront/checkout", "agentd
66
+ # web") then keeps the note to that project's sessions; anything
67
+ # fuzzier is the model's job.
68
+ # @param projects [Array<String>]
69
+ # @return [String, nil]
70
+ def scope_project(note, projects)
71
+ line = note.to_s.lines.first.to_s.downcase
72
+ named = projects.compact.uniq.select do |project|
73
+ line.match?(/(?<![[:alnum:]_-])#{Regexp.escape(project.downcase)}(?![[:alnum:]_-])/)
74
+ end
75
+ named.one? ? named.first : nil
76
+ end
77
+
78
+ # Every card's verdict: a shared tag delivers (Tags.match); a note
79
+ # whose first line names one of the projects (scope_project) skips
80
+ # the other projects' sessions; the model judges the rest
81
+ # (judge_all, +judge+ its keywords).
82
+ # @param cards [Array<ScopeCard>]
83
+ # @param note_tags [Array<Tag>]
84
+ # @return [Hash{String => Verdict}] by card id, in +cards+' order
85
+ def verdicts(note, cards, note_tags:, new_backend:, **judge)
86
+ scope = scope_project(note, cards.map(&:project))
87
+ decided = cards.to_h do |card|
88
+ match = Tags.match(note_tags, card.tags)
89
+ verdict = if match
90
+ Verdict.new(relevant: true, p: nil, reason: match.reason, by: "tags", match: match)
91
+ elsif scope && card.project != scope
92
+ Verdict.new(relevant: false, p: nil, reason: "scope line names #{scope}", by: "scope")
93
+ end
94
+ [card.id, verdict]
95
+ end
96
+ judged = judge_all(note, cards.reject { |card| decided[card.id] }, new_backend: new_backend, **judge)
97
+ cards.to_h { |card| [card.id, decided[card.id] || judged.fetch(card.id)] }
98
+ end
99
+
100
+ # Judges +cards+ with at most +parallel+ backends at a time, one per
101
+ # thread (a backend keeps per-host state), all under +deadline+
102
+ # seconds. A card not judged by then is delivered unchecked; the
103
+ # requests still running are cancelled.
104
+ # @param new_backend [#call] (CancellationController) → a backend:
105
+ # #judge(note, card) → Verdict
106
+ # @param cards [Array<ScopeCard>]
107
+ # @return [Hash{String => Verdict}] by card id
108
+ def judge_all(note, cards, new_backend:, parallel: DEFAULT_PARALLEL, deadline: DEFAULT_DEADLINE,
109
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
110
+ return {} if cards.empty?
111
+
112
+ queue = Queue.new
113
+ cards.each { |card| queue << card }
114
+ queue.close
115
+ results = {}
116
+ lock = Mutex.new
117
+ changed = ConditionVariable.new
118
+ cancel = CancellationController.new
119
+ ends = clock.call + deadline.to_f
120
+ finished = 0
121
+ threads = Array.new(parallel.to_i.clamp(1, cards.size)) do
122
+ Thread.new do
123
+ card = nil
124
+ backend = new_backend.call(cancel)
125
+ while (card = queue.pop)
126
+ verdict = judge_one(backend, note, card)
127
+ lock.synchronize do
128
+ results[card.id] = verdict unless cancel.cancelled?
129
+ changed.broadcast
130
+ end
131
+ card = nil
132
+ end
133
+ rescue StandardError => e
134
+ # No backend (or a bug around it): this thread's card and the
135
+ # ones it takes from here on are delivered unchecked at once,
136
+ # so nobody waits for the deadline.
137
+ Log.exception(:broadcast, "triage_thread_failed", e)
138
+ failed = unchecked("triage failed (#{e.class})")
139
+ lock.synchronize { results[card.id] ||= failed if card }
140
+ while (card = queue.pop)
141
+ lock.synchronize { results[card.id] ||= failed }
142
+ end
143
+ ensure
144
+ lock.synchronize do
145
+ finished += 1
146
+ changed.broadcast
147
+ end
148
+ end
149
+ end
150
+ lock.synchronize do
151
+ loop do
152
+ break if results.size == cards.size || finished == threads.size
153
+
154
+ left = ends - clock.call
155
+ break if left <= 0
156
+
157
+ changed.wait(lock, left)
158
+ end
159
+ cancel.cancel!(:deadline)
160
+ queue.clear
161
+ end
162
+ grace_ends = clock.call + JOIN_GRACE
163
+ threads.each { |thread| thread.join([grace_ends - clock.call, 0].max) || thread.kill }
164
+ cards.to_h { |card| [card.id, results[card.id] || unchecked("triage deadline")] }
165
+ end
166
+
167
+ def judge_one(backend, note, card)
168
+ backend.judge(note, card)
169
+ rescue Samagotchi::LLM::RequestCancelled
170
+ unchecked("triage deadline")
171
+ rescue StandardError => e
172
+ Log.exception(:broadcast, "triage_failed", e)
173
+ unchecked("triage failed (#{e.class})")
174
+ end
175
+ private_class_method :judge_one
176
+
177
+ # A triage model on an OpenAI-compatible host (an IdleTarget), asked
178
+ # through its own IdleClient: thinking off, a few tokens, the first
179
+ # token's logprobs when the host gives them. P(yes) / (P(yes) +
180
+ # P(no)) from those is graded against +threshold+; a plain yes or no
181
+ # is 1.0 or 0.0.
182
+ class LLM
183
+ MAX_TOKENS = 16
184
+ TOP_LOGPROBS = 5
185
+ LOGPROB_OPTIONS = { logprobs: true, top_logprobs: TOP_LOGPROBS }.freeze
186
+ # Hosts (base urls) that refused logprobs with a 400: asked without
187
+ # them from then on, in this process. Shared by every thread's LLM:
188
+ # a parallel batch's first requests may each meet the 400 once, the
189
+ # requests after them don't.
190
+ @no_logprobs = Set.new
191
+ @no_logprobs_lock = Mutex.new
192
+
193
+ class << self
194
+ def logprobs?(base_url) = @no_logprobs_lock.synchronize { !@no_logprobs.include?(base_url) }
195
+
196
+ def no_logprobs!(base_url) = @no_logprobs_lock.synchronize { @no_logprobs << base_url }
197
+
198
+ # Specs: forget the hosts that refused.
199
+ def reset_logprobs! = @no_logprobs_lock.synchronize { @no_logprobs.clear }
200
+ end
201
+
202
+ # @param target [IdleTarget]
203
+ # @param timeout [Numeric] each request's
204
+ # @param client [IdleClient, nil] specs inject one
205
+ def initialize(target:, timeout:, threshold: DEFAULT_THRESHOLD, cancel_controller: nil, client: nil)
206
+ @target = target
207
+ @threshold = threshold.to_f
208
+ @cancel = cancel_controller
209
+ @client = client || IdleClient.for(target, timeout: timeout, purpose: "broadcast")
210
+ end
211
+
212
+ # @return [Verdict]
213
+ # @raise [Samagotchi::LLM::RequestCancelled] the deadline passed
214
+ def judge(note, card)
215
+ answer = ask(Triage.messages(note, card))
216
+ word = self.class.word(answer.text)
217
+ return Triage.unchecked("the model answered #{answer.text.to_s.strip[0, 40].inspect}") unless word
218
+
219
+ p = self.class.logprob_p(answer.top_logprobs, word: word)
220
+ relevant = p ? p >= @threshold : word == "yes"
221
+ reason = "model: #{relevant ? "yes" : "no"}#{format(" (p %.2f)", p) if p}"
222
+ Verdict.new(relevant: relevant, p: p || (word == "yes" ? 1.0 : 0.0), reason: reason, by: "model")
223
+ rescue IdleClient::SummarizeError => e
224
+ Triage.unchecked("the model failed (#{e.message[0, 120]})")
225
+ end
226
+
227
+ # "yes" or "no": the answer's first such word (thinking left out),
228
+ # nil when it has neither.
229
+ def self.word(text)
230
+ text.to_s.gsub(%r{<think>.*?</think>}m, "").strip.downcase[/\A\W*(yes|no)\b/, 1]
231
+ end
232
+
233
+ # P(yes) / (P(yes) + P(no)) over the first token's alternatives
234
+ # ("Yes", " yes" count as yes), when that token is the answer +word+
235
+ # itself: its likeliest alternative is yes or no and agrees with
236
+ # +word+. nil otherwise (an answer starting "**", "<think>" or a
237
+ # quote: those alternatives say nothing about yes or no).
238
+ # @param top [Array<Samagotchi::LLM::TokenLogprob>]
239
+ # @return [Float, nil]
240
+ def self.logprob_p(top, word:)
241
+ first = top.max_by(&:logprob)
242
+ return nil unless first && first.token.strip.downcase == word
243
+
244
+ sum = ->(want) { top.select { |t| t.token.strip.downcase == want }.sum { |t| Math.exp(t.logprob) } }
245
+ yes = sum.call("yes")
246
+ no = sum.call("no")
247
+ (yes / (yes + no)).round(4)
248
+ end
249
+
250
+ private
251
+
252
+ def ask(messages)
253
+ base_url = @target.base_url
254
+ return request(messages, {}) unless self.class.logprobs?(base_url)
255
+
256
+ begin
257
+ request(messages, LOGPROB_OPTIONS)
258
+ rescue IdleClient::SummarizeError => e
259
+ raise unless e.cause.is_a?(Samagotchi::LLM::BadRequest)
260
+
261
+ # The host may not give logprobs (a server that refuses "n and
262
+ # logprobs"): ask again without. Leave them out from now on when
263
+ # the 400 said so or the retry answered; a 400 the retry gets
264
+ # too was about something else.
265
+ refused = e.cause.message.to_s.match?(/logprob/i)
266
+ no_logprobs!(e.cause) if refused
267
+ answer = request(messages, {})
268
+ no_logprobs!(e.cause) unless refused
269
+ answer
270
+ end
271
+ end
272
+
273
+ def no_logprobs!(error)
274
+ self.class.no_logprobs!(@target.base_url)
275
+ Log.info(:broadcast, "logprobs_refused", model: @target.label, detail: error.message.to_s[0, 200])
276
+ end
277
+
278
+ def request(messages, options)
279
+ @client.ask(messages, max_tokens: MAX_TOKENS, cancel_controller: @cancel, kind: "broadcast", options: options)
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end