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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -1
- data/README.md +3 -3
- data/bin/chi +1 -1
- data/docs/broadcast.md +95 -13
- data/docs/configuration.md +9 -2
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +17 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +107 -34
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +284 -0
- data/lib/samagotchi/broadcast/triage_log.rb +170 -0
- data/lib/samagotchi/broadcast/triage_model.rb +56 -0
- data/lib/samagotchi/broadcast_command.rb +275 -69
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +439 -92
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/cli/command.rb +27 -3
- data/lib/samagotchi/config.rb +9 -0
- data/lib/samagotchi/context_command.rb +0 -17
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/Broadcast.swift +97 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +12 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +123 -26
- data/lib/samagotchi/desktop/macos.rb +27 -5
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +7 -8
- data/lib/samagotchi/guardrail_wiring.rb +7 -2
- data/lib/samagotchi/guardrails/approval.rb +5 -2
- data/lib/samagotchi/guardrails/approvals.rb +9 -5
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/targets.rb +76 -11
- data/lib/samagotchi/idle_client.rb +48 -28
- data/lib/samagotchi/idle_recap.rb +6 -5
- data/lib/samagotchi/idle_target.rb +95 -0
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/llm/openai_chat.rb +29 -3
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/note_command.rb +0 -21
- data/lib/samagotchi/plugin/api.rb +5 -3
- data/lib/samagotchi/recap_setup.rb +38 -52
- data/lib/samagotchi/send_command.rb +0 -22
- data/lib/samagotchi/tool_response.rb +5 -5
- data/lib/samagotchi/tool_runner.rb +18 -9
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/question_card.js +4 -3
- 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
|