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
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "time"
6
+ require_relative "../paths"
7
+
8
+ module Samagotchi
9
+ module Broadcast
10
+ # What each broadcast decided, one JSON line per broadcast in
11
+ # <state>/samagotchi/broadcast/log.jsonl: its id, time, note text, the
12
+ # note's tags and every recipient's verdict (no scope cards: they quote
13
+ # the user's prompts). `chi broadcast deliver` adds a "corrected" line
14
+ # for the sessions it delivered to anyway: labels for the threshold and
15
+ # a future classifier. Not a list sessions read: broadcasts stay
16
+ # fire-and-forget. Over MAX_BYTES the file becomes log.jsonl.1 (one old
17
+ # file kept).
18
+ class TriageLog
19
+ MAX_BYTES = 2 * 1024 * 1024
20
+
21
+ # One recipient's line in a broadcast.
22
+ # @!attribute result [String] "delivered", "skipped" or "failed"
23
+ # @!attribute p [Float, nil] the triage model's P(yes), nil when no
24
+ # model judged it
25
+ # @!attribute by [String] Triage::Verdict#by
26
+ # @!attribute tags [Array<String>] the session's tags (Tag#label)
27
+ Entry = Data.define(:session, :result, :p, :reason, :by, :tags)
28
+
29
+ # A `chi broadcast deliver`: when, and each session's result.
30
+ # @!attribute sessions [Array<Delivery>]
31
+ Correction = Data.define(:at, :sessions)
32
+
33
+ # One session a correction delivered to (or tried).
34
+ Delivery = Data.define(:session, :result)
35
+
36
+ # A broadcast as read back, with the corrections made to it since.
37
+ # @!attribute at [Time]
38
+ # @!attribute recipients [Array<Entry>]
39
+ # @!attribute corrections [Array<Correction>]
40
+ Record = Data.define(:id, :at, :text, :note_tags, :triage_model, :recipients, :corrections) do
41
+ # @return [Entry, nil]
42
+ def entry(session) = recipients.find { |e| e.session == session }
43
+
44
+ # Whether +session+ got it, from the broadcast or a correction.
45
+ def delivered?(session)
46
+ entry(session)&.result == "delivered" ||
47
+ corrections.any? { |c| c.sessions.any? { |d| d.session == session && d.result == "delivered" } }
48
+ end
49
+
50
+ def to_json_hash
51
+ { id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
52
+ recipients: recipients.map(&:to_h),
53
+ corrections: corrections.map { |c| { at: c.at.iso8601, sessions: c.sessions.map(&:to_h) } } }
54
+ end
55
+ end
56
+
57
+ # No broadcast with that id, or several start with it.
58
+ class NotFound < StandardError; end
59
+
60
+ # @return [String] <state>/samagotchi/broadcast/log.jsonl
61
+ def self.default_path(env: ENV) = File.join(Paths.state_dir(env: env), "broadcast", "log.jsonl")
62
+
63
+ attr_reader :path
64
+
65
+ def initialize(path = self.class.default_path)
66
+ @path = path
67
+ end
68
+
69
+ # @param at [Time]
70
+ # @param recipients [Array<Entry>]
71
+ def append_broadcast(id:, at:, text:, note_tags:, triage_model:, recipients:)
72
+ append(type: "broadcast", id: id, at: at.iso8601, text: text, note_tags: note_tags, triage_model: triage_model,
73
+ recipients: recipients.map(&:to_h))
74
+ end
75
+
76
+ # @param sessions [Array<Delivery>]
77
+ def append_correction(id:, at:, sessions:)
78
+ append(type: "corrected", id: id, at: at.iso8601, sessions: sessions.map(&:to_h))
79
+ end
80
+
81
+ # Every broadcast in the log (the rotated file's first), oldest
82
+ # first, with its corrections. A line that doesn't parse is skipped.
83
+ # @return [Array<Record>]
84
+ def records
85
+ found = {}
86
+ [rotated_path, @path].each do |file|
87
+ next unless File.file?(file)
88
+
89
+ File.foreach(file) do |line|
90
+ data = parse(line)
91
+ next unless data
92
+
93
+ if data["type"] == "broadcast"
94
+ found[data["id"]] = record(data)
95
+ elsif data["type"] == "corrected" && (previous = found[data["id"]])
96
+ found[data["id"]] = previous.with(corrections: previous.corrections + [correction(data)])
97
+ end
98
+ end
99
+ end
100
+ found.values
101
+ end
102
+
103
+ # The broadcast +given+ names: its id, with or without "b-", or the
104
+ # start of one.
105
+ # @return [Record]
106
+ # @raise [NotFound]
107
+ def find(given)
108
+ want = given.to_s.strip.delete_prefix("b-")
109
+ raise NotFound, "no broadcast id given" if want.empty?
110
+
111
+ matches = records.select { |r| r.id.delete_prefix("b-").start_with?(want) }
112
+ return matches.first if matches.one?
113
+ raise NotFound, "no broadcast #{given} in the log (chi broadcast log)" if matches.empty?
114
+
115
+ raise NotFound, "#{given} names #{matches.size} broadcasts (#{matches.map(&:id).join(", ")}); give more of the id"
116
+ end
117
+
118
+ private
119
+
120
+ def rotated_path = "#{@path}.1"
121
+
122
+ # Appends a line under log.jsonl.lock, held from the size check to
123
+ # the write, so two broadcasts at once rotate the file once (the
124
+ # file itself can't be the lock: a rename moves it).
125
+ def append(data)
126
+ FileUtils.mkdir_p(File.dirname(@path))
127
+ File.open("#{@path}.lock", File::WRONLY | File::CREAT, 0o600) do |lock|
128
+ lock.flock(File::LOCK_EX)
129
+ File.rename(@path, rotated_path) if File.file?(@path) && File.size(@path) > MAX_BYTES
130
+ File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o600) do |file|
131
+ file.write("#{JSON.generate(data)}\n")
132
+ end
133
+ end
134
+ end
135
+
136
+ def parse(line)
137
+ data = JSON.parse(line)
138
+ data.is_a?(Hash) && data["id"].is_a?(String) ? data : nil
139
+ rescue JSON::ParserError
140
+ nil
141
+ end
142
+
143
+ def record(data)
144
+ Record.new(id: data["id"], at: time(data["at"]), text: data["text"].to_s, note_tags: Array(data["note_tags"]),
145
+ triage_model: data["triage_model"],
146
+ recipients: Array(data["recipients"]).filter_map { |e| entry(e) }, corrections: [])
147
+ end
148
+
149
+ def entry(data)
150
+ return nil unless data.is_a?(Hash)
151
+
152
+ Entry.new(session: data["session"].to_s, result: data["result"].to_s, p: data["p"], reason: data["reason"].to_s,
153
+ by: data["by"].to_s, tags: Array(data["tags"]))
154
+ end
155
+
156
+ def correction(data)
157
+ sessions = Array(data["sessions"]).filter_map do |d|
158
+ Delivery.new(session: d["session"].to_s, result: d["result"].to_s) if d.is_a?(Hash)
159
+ end
160
+ Correction.new(at: time(data["at"]), sessions: sessions)
161
+ end
162
+
163
+ def time(value)
164
+ Time.iso8601(value.to_s)
165
+ rescue ArgumentError
166
+ Time.at(0)
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../config"
4
+ require_relative "../idle_target"
5
+ require_relative "../model_profile"
6
+
7
+ module Samagotchi
8
+ # Loaded on first use, as the CLI commands do: host_registry pulls in the
9
+ # LLM layer.
10
+ autoload :HostRegistry, File.expand_path("../host_registry", __dir__)
11
+
12
+ module Broadcast
13
+ # Which model a broadcast's triage asks: broadcast.triage_model /
14
+ # triage_host_ref / triage_base_url; with none of them set, the recap's
15
+ # (recap.*); with none of those, default.model. Settings that can't be
16
+ # resolved are a problem, not a fallback to the next ones: triage then
17
+ # delivers unchecked and says why.
18
+ module TriageModel
19
+ # @!attribute target [IdleTarget, nil] nil when +problem+ says why
20
+ # @!attribute setting [String] where it came from: "broadcast.triage_model",
21
+ # "recap.model" or "default.model" (the prefix of the keys read)
22
+ Choice = Data.define(:target, :setting, :problem)
23
+
24
+ SETTINGS = [%w[broadcast.triage_model broadcast.triage_host_ref broadcast.triage_base_url],
25
+ %w[recap.model recap.host_ref recap.base_url]].freeze
26
+
27
+ module_function
28
+
29
+ # @param get [#call] a Config key → its value
30
+ # @return [Choice]
31
+ def resolve(host_registry: nil, get: ->(key) { Config.get(key) })
32
+ registry = host_registry || HostRegistry.new
33
+ SETTINGS.each do |model, host_ref, base_url|
34
+ target = IdleTarget.resolve(model: get.call(model), host_ref: get.call(host_ref), base_url: get.call(base_url),
35
+ host_registry: registry)
36
+ return Choice.new(target: target, setting: model, problem: nil) if target
37
+ rescue IdleTarget::Unresolved => e
38
+ return Choice.new(target: nil, setting: model, problem: problem(e, model, host_ref))
39
+ end
40
+ model = ModelProfile.required_model_name(get.call("default.model"))
41
+ Choice.new(target: IdleTarget.of_model(registry, model), setting: "default.model", problem: nil)
42
+ rescue StandardError => e
43
+ Choice.new(target: nil, setting: "default.model", problem: e.message.to_s.lines.first.to_s.strip[0, 200])
44
+ end
45
+
46
+ def problem(error, model_key, host_ref_key)
47
+ case error.reason
48
+ when :host_ref_unknown then "#{host_ref_key} '#{error.host_ref}' is not in hosts:"
49
+ when :model_host_mismatch then "#{model_key} '#{error.model}' names host '#{error.named}', not #{host_ref_key} '#{error.host_ref}'"
50
+ else "#{model_key} is missing (a host or base_url alone names no model)"
51
+ end
52
+ end
53
+ private_class_method :problem
54
+ end
55
+ end
56
+ end