samagotchi 0.122.0 → 0.124.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 +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- metadata +7 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// The panel's chi side without the panel: what a send to chi sessions runs
|
|
2
|
+
// (SendPlan, built from the panel's state, run by PanelController), and the
|
|
3
|
+
// build spec's seams: `ChiHelper --sessions` prints the session rows the
|
|
4
|
+
// panel lists, `ChiHelper --send-plan` the chi calls a send would make.
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
/// A send to chi sessions as the chi calls it makes: `chi send` (a message,
|
|
8
|
+
/// or `--new` on a new row, this Mac's or a remote's) or `chi note`, one per
|
|
9
|
+
/// machine. Pure: nothing runs here.
|
|
10
|
+
struct SendPlan {
|
|
11
|
+
/// One chi call: its args after the launch file's argv, its stdin, its
|
|
12
|
+
/// name in the panel's lines ("chi send"), the remote it goes to and its
|
|
13
|
+
/// time limit.
|
|
14
|
+
struct Call: Equatable {
|
|
15
|
+
let command: String
|
|
16
|
+
let args: [String]
|
|
17
|
+
let stdin: Data?
|
|
18
|
+
var remote: String?
|
|
19
|
+
var timeout: TimeInterval = 10
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// A session on a machine: nil is this Mac.
|
|
23
|
+
struct Target: Equatable {
|
|
24
|
+
let remote: String?
|
|
25
|
+
let id: String
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let kind: SendKind
|
|
29
|
+
/// UUID-shaped session ids and their machines, in list order; not used
|
|
30
|
+
/// with +new+.
|
|
31
|
+
let targets: [Target]
|
|
32
|
+
let new: Bool
|
|
33
|
+
/// The remote a new session starts on; nil: this Mac.
|
|
34
|
+
let newRemote: String?
|
|
35
|
+
/// Where a new session starts (PanelModel.newSessionDir, or
|
|
36
|
+
/// newSessionDir(remote:) on a remote); nil: nowhere known, no call.
|
|
37
|
+
let newDir: String?
|
|
38
|
+
/// `--model` for `chi send --new` on this Mac, verbatim; nil for the
|
|
39
|
+
/// default. Never on a remote (D4: the list is this Mac's).
|
|
40
|
+
let model: String?
|
|
41
|
+
/// The message line, trimmed; `-m` of `chi send`.
|
|
42
|
+
let prompt: String
|
|
43
|
+
/// A message's stdin: the quoted context; nil when there is none.
|
|
44
|
+
let context: String?
|
|
45
|
+
/// `chi note`'s stdin: the message line, then the context.
|
|
46
|
+
let note: String
|
|
47
|
+
/// `chi note --source`, trimmed; "" for none.
|
|
48
|
+
let source: String
|
|
49
|
+
/// `--image` paths; a note's are dropped before (text only).
|
|
50
|
+
let images: [String]
|
|
51
|
+
|
|
52
|
+
/// The panel's state for this send.
|
|
53
|
+
static func from(_ model: PanelModel, kind: SendKind, sessions: [LiveSession], new: Bool, images: [PanelImage]) -> SendPlan {
|
|
54
|
+
let newRemote = new ? model.newRemote : nil
|
|
55
|
+
let newDir = newRemote.map(model.newSessionDir(remote:)) ?? model.newSessionDir
|
|
56
|
+
return SendPlan(kind: kind, targets: sessions.map { Target(remote: $0.remote, id: $0.id) }, new: new, newRemote: newRemote,
|
|
57
|
+
newDir: newDir, model: newRemote == nil ? model.modelForSend : nil,
|
|
58
|
+
prompt: model.trimmedPrompt, context: model.hasContext ? model.text : nil, note: model.noteText,
|
|
59
|
+
source: model.source.trimmingCharacters(in: .whitespacesAndNewlines), images: images.map(\.url.path))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// This Mac's call first, then one per remote in list order. A session
|
|
63
|
+
/// goes once, to the first machine it is under; a remote whose name chi
|
|
64
|
+
/// wouldn't take is left out (the listing never makes one).
|
|
65
|
+
var calls: [Call] {
|
|
66
|
+
if new {
|
|
67
|
+
guard newDir != nil, newRemote.map(Remotes.valid) ?? true else { return [] }
|
|
68
|
+
return [call(ids: [], remote: newRemote)]
|
|
69
|
+
}
|
|
70
|
+
var machines: [String?] = []
|
|
71
|
+
var ids: [String?: [String]] = [:]
|
|
72
|
+
var seen = Set<String>()
|
|
73
|
+
let ordered = targets.filter { $0.remote == nil } + targets.filter { $0.remote != nil }
|
|
74
|
+
for target in ordered where target.remote.map(Remotes.valid) ?? true && seen.insert(target.id).inserted {
|
|
75
|
+
if ids[target.remote] == nil { machines.append(target.remote) }
|
|
76
|
+
ids[target.remote, default: []].append(target.id)
|
|
77
|
+
}
|
|
78
|
+
return machines.map { call(ids: ids[$0] ?? [], remote: $0) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Reading, converting and a new worker's start take longer than text;
|
|
82
|
+
/// a remote's text, waking a stopped worker there, too.
|
|
83
|
+
private func call(ids: [String], remote: String?) -> Call {
|
|
84
|
+
var args: [String]
|
|
85
|
+
let command: String
|
|
86
|
+
let stdin: Data?
|
|
87
|
+
switch kind {
|
|
88
|
+
case .message, .paste:
|
|
89
|
+
// stdin is the quoted context; without -m, chi takes it as the message.
|
|
90
|
+
command = "chi send"
|
|
91
|
+
args = new ? ["send", "--new", "--dir", newDir ?? ""] : ["send"]
|
|
92
|
+
// The row's name verbatim; none for the default, nor on a remote.
|
|
93
|
+
if new, remote == nil, let model { args += ["--model", model] }
|
|
94
|
+
if !prompt.isEmpty { args += ["-m", prompt] }
|
|
95
|
+
args += images.flatMap { ["--image", $0] }
|
|
96
|
+
stdin = context.map { Data($0.utf8) }
|
|
97
|
+
case .note:
|
|
98
|
+
command = "chi note"
|
|
99
|
+
args = ["note"]
|
|
100
|
+
if !source.isEmpty { args += ["--source", source] }
|
|
101
|
+
stdin = Data(note.utf8)
|
|
102
|
+
}
|
|
103
|
+
if !new { args += ids }
|
|
104
|
+
if let remote { args = ["--remote", remote] + args }
|
|
105
|
+
let timeout: TimeInterval = !images.isEmpty && kind != .note ? 30 : remote == nil ? 10 : 15
|
|
106
|
+
return Call(command: command, args: args, stdin: stdin, remote: remote, timeout: timeout)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func printJSON(_ object: Any) {
|
|
111
|
+
let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
|
|
112
|
+
print(String(decoding: data, as: UTF8.self))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// `ChiHelper --sessions [--launch PATH] [--last KEY]... [--order
|
|
116
|
+
/// local-first|remotes-first] [--click KEY]...`: the panel's session rows in
|
|
117
|
+
/// its order (live, then recent), the new row's folder, a section per remote
|
|
118
|
+
/// (rows not listed before, or its line, and its new row's folder) and what
|
|
119
|
+
/// the panel preselects with KEYs as the remembered choice (none by default:
|
|
120
|
+
/// never the user's) when the answers come in that order (this Mac's first
|
|
121
|
+
/// by default), then after the clicks (a row's key, "new", "new:REMOTE" for
|
|
122
|
+
/// a remote's new row, "model" for ⌘M), as JSON ({"rows": [{"id", "desc",
|
|
123
|
+
/// "cwd", "busy", "recent"}], "new_dir", "remotes": [{"name", "rows" (each
|
|
124
|
+
/// with "remote"), "error", "new_dir" (null: no new row)}], "selected",
|
|
125
|
+
/// "new_selected", "new_remote", "picker_open", "message", "hint"}); a
|
|
126
|
+
/// failed listing of this Mac's is its message on stderr, exit 1.
|
|
127
|
+
func sessionsCommand(_ args: [String]) -> Int32 {
|
|
128
|
+
var args = args
|
|
129
|
+
var launchPath = ChiRunner.launchPath
|
|
130
|
+
var last: [String] = []
|
|
131
|
+
var remotesFirst = false
|
|
132
|
+
var clicks: [String] = []
|
|
133
|
+
let usage = "usage: ChiHelper --sessions [--launch PATH] [--last KEY]... [--order local-first|remotes-first] [--click KEY]...\n"
|
|
134
|
+
while !args.isEmpty {
|
|
135
|
+
let arg = args.removeFirst()
|
|
136
|
+
switch arg {
|
|
137
|
+
case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
|
|
138
|
+
case "--last" where !args.isEmpty: last.append(args.removeFirst())
|
|
139
|
+
case "--click" where !args.isEmpty: clicks.append(args.removeFirst())
|
|
140
|
+
case "--order" where !args.isEmpty:
|
|
141
|
+
let order = args.removeFirst()
|
|
142
|
+
guard ["local-first", "remotes-first"].contains(order) else {
|
|
143
|
+
FileHandle.standardError.write(usage.data(using: .utf8)!)
|
|
144
|
+
return 2
|
|
145
|
+
}
|
|
146
|
+
remotesFirst = order == "remotes-first"
|
|
147
|
+
default:
|
|
148
|
+
FileHandle.standardError.write(usage.data(using: .utf8)!)
|
|
149
|
+
return 2
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
let runner = ChiRunner(launchPath: launchPath)
|
|
153
|
+
// As the panel: this Mac's lists beside `remote ls`, then each remote's
|
|
154
|
+
// at once. All are awaited, then fed to the panel's model in the order
|
|
155
|
+
// --order says (the panel takes them as they come).
|
|
156
|
+
let queue = DispatchQueue(label: "sessions-command")
|
|
157
|
+
let group = DispatchGroup()
|
|
158
|
+
var local: Result<[LiveSession], ChiError>?
|
|
159
|
+
var names: [String] = []
|
|
160
|
+
var answers: [String: Result<[LiveSession], ChiError>] = [:]
|
|
161
|
+
group.enter()
|
|
162
|
+
runner.sessions(queue: queue) { local = $0; group.leave() }
|
|
163
|
+
group.enter()
|
|
164
|
+
runner.remotes(queue: queue) { found in
|
|
165
|
+
names = found
|
|
166
|
+
for name in found {
|
|
167
|
+
group.enter()
|
|
168
|
+
runner.sessions(remote: name, queue: queue) { answers[name] = $0; group.leave() }
|
|
169
|
+
}
|
|
170
|
+
group.leave()
|
|
171
|
+
}
|
|
172
|
+
group.wait()
|
|
173
|
+
let model = PanelModel()
|
|
174
|
+
model.lastChoice = last
|
|
175
|
+
model.startRemotes(names)
|
|
176
|
+
let showRemotes = { for name in names { model.showRemote(name, answers[name]!) } }
|
|
177
|
+
if remotesFirst { showRemotes() }
|
|
178
|
+
model.showSessions(local!)
|
|
179
|
+
if !remotesFirst { showRemotes() }
|
|
180
|
+
for key in clicks {
|
|
181
|
+
switch key {
|
|
182
|
+
case "new": model.toggleNew()
|
|
183
|
+
case "model": _ = model.pressModel()
|
|
184
|
+
case let key where key.hasPrefix("new:"): model.toggleNew(remote: String(key.dropFirst(4)))
|
|
185
|
+
default: model.toggle(key)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
switch local! {
|
|
189
|
+
case .failure(let error):
|
|
190
|
+
FileHandle.standardError.write("\(error.description)\n".data(using: .utf8)!)
|
|
191
|
+
return 1
|
|
192
|
+
case .success:
|
|
193
|
+
let row = { (s: LiveSession) -> [String: Any] in
|
|
194
|
+
var row: [String: Any] = ["id": s.id, "desc": s.desc ?? NSNull(), "cwd": s.cwd ?? NSNull(), "busy": s.busy ?? NSNull(),
|
|
195
|
+
"recent": s.recent]
|
|
196
|
+
if let remote = s.remote { row["remote"] = remote }
|
|
197
|
+
return row
|
|
198
|
+
}
|
|
199
|
+
let remotes: [[String: Any]] = model.remoteGroups.map { group in
|
|
200
|
+
["name": group.section.name, "rows": group.rows.map(row), "error": group.section.error ?? NSNull(),
|
|
201
|
+
"new_dir": model.newSessionDir(remote: group.section.name) ?? NSNull()]
|
|
202
|
+
}
|
|
203
|
+
printJSON(["rows": model.sessions.map(row), "new_dir": model.newSessionDir, "remotes": remotes,
|
|
204
|
+
"selected": model.selected.sorted(), "new_selected": model.newSelected, "new_remote": model.newRemote ?? NSNull(),
|
|
205
|
+
"picker_open": model.pickerOpen, "message": model.message, "hint": model.machineHint])
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/// `ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR]
|
|
211
|
+
/// [--default MODEL] [--model MODEL] [--prompt P] [--source S] [--image PATH]...`:
|
|
212
|
+
/// the panel's text on stdin; prints the calls SendPlan makes as JSON
|
|
213
|
+
/// ([{"command", "args", "stdin", "timeout"}], stdin null when none). An id
|
|
214
|
+
/// as a row's key: "studio:<id>" on a remote. --new is the new row, DIR the
|
|
215
|
+
/// folder of its only (live) session; with REMOTE: (DIR absolute), that
|
|
216
|
+
/// remote's new row; --default is the configured model, --model the pick.
|
|
217
|
+
func sendPlanCommand(_ args: [String]) -> Int32 {
|
|
218
|
+
let usage = "usage: ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR] [--default MODEL] "
|
|
219
|
+
+ "[--model MODEL] "
|
|
220
|
+
+ "[--prompt P] [--source S] [--image PATH]...\n"
|
|
221
|
+
var args = args
|
|
222
|
+
let kinds: [String: SendKind] = ["message": .message, "note": .note, "paste": .paste]
|
|
223
|
+
guard let first = args.first, let kind = kinds[first] else {
|
|
224
|
+
FileHandle.standardError.write(usage.data(using: .utf8)!)
|
|
225
|
+
return 2
|
|
226
|
+
}
|
|
227
|
+
args.removeFirst()
|
|
228
|
+
let model = PanelModel()
|
|
229
|
+
var sessions: [LiveSession] = [], newDir: String?, images: [PanelImage] = []
|
|
230
|
+
while !args.isEmpty {
|
|
231
|
+
let arg = args.removeFirst()
|
|
232
|
+
guard !args.isEmpty else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
|
|
233
|
+
let value = args.removeFirst()
|
|
234
|
+
switch arg {
|
|
235
|
+
case "--id":
|
|
236
|
+
// A UUID has no ":", so the last one splits off the remote.
|
|
237
|
+
let colon = value.lastIndex(of: ":")
|
|
238
|
+
var session = LiveSession(id: colon.map { String(value[value.index(after: $0)...]) } ?? value, desc: nil, cwd: nil,
|
|
239
|
+
busy: nil, owner: nil, updatedAt: nil)
|
|
240
|
+
session.remote = colon.map { String(value[..<$0]) }
|
|
241
|
+
sessions.append(session)
|
|
242
|
+
case "--new" where value.hasPrefix("/"): newDir = value
|
|
243
|
+
case "--new":
|
|
244
|
+
guard let colon = value.firstIndex(of: ":") else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
|
|
245
|
+
let name = String(value[..<colon])
|
|
246
|
+
var session = LiveSession(id: UUID().uuidString, desc: nil, cwd: String(value[value.index(after: colon)...]), busy: nil,
|
|
247
|
+
owner: nil, updatedAt: nil)
|
|
248
|
+
session.remote = name
|
|
249
|
+
model.startRemotes([name])
|
|
250
|
+
model.showRemote(name, .success([session]))
|
|
251
|
+
model.newRemote = name
|
|
252
|
+
case "--default": model.newModel = value
|
|
253
|
+
case "--model": model.pickedModel = value
|
|
254
|
+
case "--prompt": model.prompt = value
|
|
255
|
+
case "--source": model.source = value
|
|
256
|
+
case "--image": images.append(PanelImage(url: URL(fileURLWithPath: value), temp: false))
|
|
257
|
+
default:
|
|
258
|
+
FileHandle.standardError.write(usage.data(using: .utf8)!)
|
|
259
|
+
return 2
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
model.text = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
|
|
263
|
+
if let newDir {
|
|
264
|
+
model.sessions = [LiveSession(id: UUID().uuidString, desc: nil, cwd: newDir, busy: nil, owner: nil, updatedAt: nil)]
|
|
265
|
+
}
|
|
266
|
+
let plan = SendPlan.from(model, kind: kind, sessions: sessions, new: newDir != nil || model.newRemote != nil, images: images)
|
|
267
|
+
printJSON(plan.calls.map { call -> [String: Any] in
|
|
268
|
+
["command": call.command, "args": call.args, "timeout": call.timeout,
|
|
269
|
+
"stdin": call.stdin.map { String(decoding: $0, as: UTF8.self) } ?? NSNull()]
|
|
270
|
+
})
|
|
271
|
+
return 0
|
|
272
|
+
}
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -159,6 +159,7 @@ module Samagotchi
|
|
|
159
159
|
@chat_backend = nil
|
|
160
160
|
@chat_backend_mutex = Mutex.new
|
|
161
161
|
@toolkit_audit_thread = nil
|
|
162
|
+
@toolkit_warmup_thread = nil
|
|
162
163
|
@host_registry = host_registry || HostRegistry.new
|
|
163
164
|
# A model given here (a worker's session model) is the one it runs,
|
|
164
165
|
# so its host is checked; the config default is only checked once it
|
|
@@ -240,7 +241,7 @@ module Samagotchi
|
|
|
240
241
|
# start: their names for the messages they wrote.
|
|
241
242
|
@participants = SessionParticipants.new(state_dir: -> { session_state_dir })
|
|
242
243
|
# Whether the system prompt carries the participants line.
|
|
243
|
-
@participants_line =
|
|
244
|
+
@participants_line = nil
|
|
244
245
|
# Who is in the review now, and who came or went (#announce_participants).
|
|
245
246
|
@participants_watch = ParticipantsWatch.new(session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
|
|
246
247
|
@session = nil
|
|
@@ -381,6 +382,9 @@ module Samagotchi
|
|
|
381
382
|
@kernel.session_dir = -> { @session&.id && Session.session_dir(@session.id, state_dir: session_state_dir) }
|
|
382
383
|
@kernel.guardrail_gate = @guardrail_wiring.gate
|
|
383
384
|
@kernel.participant_turn = -> { Steer.participant_author?(turn_author) }
|
|
385
|
+
@kernel.participant_mode = -> { @guardrail_wiring.turn_mode }
|
|
386
|
+
# A pair turn stops once its participant passed their cap.
|
|
387
|
+
@kernel.generation_guard = -> { guard_participant_spend }
|
|
384
388
|
# The turn-end warm-up; the kernel asks it for a slot pin per request.
|
|
385
389
|
@prompt_warmup = PromptWarmup.new
|
|
386
390
|
@kernel.warmup = @prompt_warmup
|
|
@@ -410,7 +414,10 @@ module Samagotchi
|
|
|
410
414
|
# from config on every ask, and the running turn's cancel controller
|
|
411
415
|
# closes a judgment that is no longer wanted.
|
|
412
416
|
system_one: -> { SystemOne::Client.from_config(host_registry: @host_registry) },
|
|
413
|
-
cancel_controller: -> { active_cancel_controller }
|
|
417
|
+
cancel_controller: -> { active_cancel_controller },
|
|
418
|
+
# A participant's turn: their name on the owner's card, their
|
|
419
|
+
# grant's mode for the turn's (GuardrailWiring#turn_mode).
|
|
420
|
+
participant: ->(author) { @participants.entry_for(author, session: @session) }
|
|
414
421
|
)
|
|
415
422
|
end
|
|
416
423
|
private :build_guardrail_wiring
|
|
@@ -1115,6 +1122,8 @@ module Samagotchi
|
|
|
1115
1122
|
thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
|
|
1116
1123
|
end
|
|
1117
1124
|
@toolkit_audit_thread&.join(TOOLKIT_AUDIT_JOIN_SECONDS)
|
|
1125
|
+
# The warm-up's answer is dropped anyway.
|
|
1126
|
+
stop_toolkit_warmup
|
|
1118
1127
|
left = threads.count(&:alive?)
|
|
1119
1128
|
Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
|
|
1120
1129
|
@plugin_tasks.kill_leftovers
|
|
@@ -1395,6 +1404,34 @@ module Samagotchi
|
|
|
1395
1404
|
|
|
1396
1405
|
def session_state_dir = @session_state_dir || Session.default_state_dir
|
|
1397
1406
|
|
|
1407
|
+
# Whether a participant's pair turn must stop, read before each of its
|
|
1408
|
+
# generations (#guard_participant_spend): author ("p:<id>") → why (their
|
|
1409
|
+
# link ended, or they passed their cap), nil while it may go on. The
|
|
1410
|
+
# Worker sets it (Worker#participant_turn_stop); without one (the REPL)
|
|
1411
|
+
# nothing is checked mid-turn.
|
|
1412
|
+
attr_writer :participant_stop
|
|
1413
|
+
|
|
1414
|
+
# What a participant turn's stop is attributed to (HookRoutes#stop_turn).
|
|
1415
|
+
PARTICIPANTS_HOOK = "participants"
|
|
1416
|
+
|
|
1417
|
+
# Before each generation (KernelLoop#generation_guard): a pair turn whose
|
|
1418
|
+
# participant's link ended, or whose spend (Engine#metrics: their
|
|
1419
|
+
# finished turns, this one's finished generations, their side requests)
|
|
1420
|
+
# passed their cap, stops on the plugins' stop path (cost-cap's,
|
|
1421
|
+
# work-cap's), with its notice. A generation's own cost is known only
|
|
1422
|
+
# once it ends, so a turn can pass the cap by its last generation. The
|
|
1423
|
+
# turn-start check is the Worker's (Worker#participant_refusal); a
|
|
1424
|
+
# review turn only reads, and keeps to it.
|
|
1425
|
+
def guard_participant_spend
|
|
1426
|
+
author = turn_author
|
|
1427
|
+
return if author.nil? || @participant_stop.nil?
|
|
1428
|
+
return unless @guardrail_wiring.turn_mode == ParticipantMode::PAIR
|
|
1429
|
+
|
|
1430
|
+
reason = @participant_stop.call(author) or return
|
|
1431
|
+
hook_stop_turn(reason, PARTICIPANTS_HOOK)
|
|
1432
|
+
end
|
|
1433
|
+
private :guard_participant_spend
|
|
1434
|
+
|
|
1398
1435
|
# The running turn's metrics so far, saved as a turn a worker died in
|
|
1399
1436
|
# would leave them (SessionMetrics#persist's running_turn): a worker's
|
|
1400
1437
|
# turn checkpoint (TurnCheckpoint) calls it when it writes.
|
|
@@ -1500,14 +1537,15 @@ module Samagotchi
|
|
|
1500
1537
|
change
|
|
1501
1538
|
end
|
|
1502
1539
|
|
|
1503
|
-
# Reads the session's participants at a turn's start; the first to join
|
|
1504
|
-
#
|
|
1505
|
-
#
|
|
1540
|
+
# Reads the session's participants at a turn's start; the first to join,
|
|
1541
|
+
# the first invited to act (a pair participant: the line's words) and
|
|
1542
|
+
# the last to leave rebuild the system prompt (its participants line),
|
|
1543
|
+
# once each: a turn in between sends the same prefix.
|
|
1506
1544
|
def refresh_participants!(session)
|
|
1507
|
-
|
|
1508
|
-
return if
|
|
1545
|
+
line = @participants.refresh!(session).prompt_mode(session)
|
|
1546
|
+
return if line == @participants_line
|
|
1509
1547
|
|
|
1510
|
-
@participants_line =
|
|
1548
|
+
@participants_line = line
|
|
1511
1549
|
@prompt_builder.reset!
|
|
1512
1550
|
end
|
|
1513
1551
|
private :refresh_participants!
|
|
@@ -1527,8 +1565,9 @@ module Samagotchi
|
|
|
1527
1565
|
# A participant's opening message keeps the rules it was delivered under
|
|
1528
1566
|
# (ParticipantMode), stamped before any request carries it, so its header
|
|
1529
1567
|
# never changes when the session's mode does.
|
|
1530
|
-
def participant_mode_fields(author
|
|
1531
|
-
Steer.participant_author?(author)
|
|
1568
|
+
def participant_mode_fields(author)
|
|
1569
|
+
mode = Steer.participant_author?(author) && @guardrail_wiring.turn_mode
|
|
1570
|
+
mode ? { participant_mode: mode.to_s } : {}
|
|
1532
1571
|
end
|
|
1533
1572
|
|
|
1534
1573
|
# Why a turn stopped (TurnEndings#stop_facts).
|
|
@@ -2139,11 +2178,15 @@ module Samagotchi
|
|
|
2139
2178
|
turn_started[:cut_after] = steer_cut_after
|
|
2140
2179
|
# A participant's turn names them (author, author_name) for the UIs'
|
|
2141
2180
|
# badge; the owner's turn_started stays as it was.
|
|
2142
|
-
turn_started.merge!(author_fields(turn.origin.author, session: session)
|
|
2181
|
+
turn_started.merge!(author_fields(turn.origin.author, session: session),
|
|
2182
|
+
participant_mode_fields(turn.origin.author))
|
|
2143
2183
|
emit_event(turn.on_event, turn.tag(turn_started))
|
|
2144
2184
|
raise image_error if image_error
|
|
2145
2185
|
|
|
2146
|
-
|
|
2186
|
+
# On mode's ask starts here too; the turn joins it before
|
|
2187
|
+
# apply_toolkit! below, so its latency hides behind the probes and
|
|
2188
|
+
# the init wait.
|
|
2189
|
+
toolkit_suggestion = start_toolkit_suggestion(turn)
|
|
2147
2190
|
|
|
2148
2191
|
# The window and the profile probes run after run_turn's probe-cancel
|
|
2149
2192
|
# swap, so a Stop cuts them; a failure ends the turn as turn_failed.
|
|
@@ -2159,7 +2202,10 @@ module Samagotchi
|
|
|
2159
2202
|
refuse_images!(turn.settings.vision) unless image_refs.empty?
|
|
2160
2203
|
# A Stop during the probes: the turn ends before the load-failure
|
|
2161
2204
|
# warnings, which show once, so the next turn still shows them.
|
|
2162
|
-
|
|
2205
|
+
if turn.controller.cancelled?
|
|
2206
|
+
toolkit_suggestion&.abandon(:canceled)
|
|
2207
|
+
return false
|
|
2208
|
+
end
|
|
2163
2209
|
|
|
2164
2210
|
announce_guardrail_failures(turn.on_event)
|
|
2165
2211
|
# Plugins' slow setup that brings tools (an MCP server's first
|
|
@@ -2172,9 +2218,12 @@ module Samagotchi
|
|
|
2172
2218
|
# session's toolkit (a /toolkit change since the last turn): what the
|
|
2173
2219
|
# prompt built next declares, unchanged until the next turn's start.
|
|
2174
2220
|
apply_staged_tools!
|
|
2221
|
+
# On mode: the suggested kit, before anything is declared (a Stop
|
|
2222
|
+
# during the wait is caught by the check below).
|
|
2223
|
+
settle_toolkit_suggestion(turn, toolkit_suggestion)
|
|
2175
2224
|
apply_toolkit!(session, turn.on_event)
|
|
2176
2225
|
refresh_participants!(session)
|
|
2177
|
-
# A Stop during the
|
|
2226
|
+
# A Stop during the waits above: the turn ends here,
|
|
2178
2227
|
# before the hooks run or a reminder is used up.
|
|
2179
2228
|
return false if turn.controller.cancelled?
|
|
2180
2229
|
|
|
@@ -2191,6 +2240,10 @@ module Samagotchi
|
|
|
2191
2240
|
|
|
2192
2241
|
turn_messages(turn, image_refs)
|
|
2193
2242
|
true
|
|
2243
|
+
ensure
|
|
2244
|
+
# A turn that ended (a failure, an Interrupt) before it settled the
|
|
2245
|
+
# ask: its line says so. Nothing once it was settled.
|
|
2246
|
+
toolkit_suggestion&.abandon(:failed)
|
|
2194
2247
|
end
|
|
2195
2248
|
private :prepare_turn
|
|
2196
2249
|
|
|
@@ -2306,7 +2359,7 @@ module Samagotchi
|
|
|
2306
2359
|
|
|
2307
2360
|
unless turn.continue
|
|
2308
2361
|
user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
|
|
2309
|
-
**participant_mode_fields(turn.origin.author
|
|
2362
|
+
**participant_mode_fields(turn.origin.author), MessageTime::KEY => turn.at }
|
|
2310
2363
|
user_message[:images] = image_refs unless image_refs.empty?
|
|
2311
2364
|
turn.messages << user_message
|
|
2312
2365
|
session.last_prompt = turn.prompt
|
|
@@ -2729,20 +2782,22 @@ module Samagotchi
|
|
|
2729
2782
|
def apply_toolkit!(session, on_event = nil) = @toolkit_binding.apply!(session, on_event)
|
|
2730
2783
|
private :apply_toolkit!
|
|
2731
2784
|
|
|
2732
|
-
# Start
|
|
2733
|
-
#
|
|
2734
|
-
|
|
2785
|
+
# Start the toolkit classifier after :turn_started, for audit and on
|
|
2786
|
+
# mode alike (one eligibility, one snapshot, one thread). All session
|
|
2787
|
+
# and tool-registry reads happen here; the thread receives only this
|
|
2788
|
+
# snapshot. Audit never blocks: the thread logs its line. On mode
|
|
2789
|
+
# returns the ask's Pending, which the turn joins before apply_toolkit!
|
|
2790
|
+
# (#settle_toolkit_suggestion), with the turn's controller as the
|
|
2791
|
+
# request's cancel controller.
|
|
2792
|
+
# @return [ToolkitSuggest::Pending, nil] nil unless the turn joins an ask
|
|
2793
|
+
def start_toolkit_suggestion(turn)
|
|
2735
2794
|
mode = Config.get("toolkit.suggest.mode").to_s
|
|
2736
2795
|
return if mode == "off"
|
|
2737
2796
|
|
|
2797
|
+
joined = mode == "on"
|
|
2738
2798
|
session = turn.session
|
|
2739
2799
|
default = Toolkit.of(session.toolkit)
|
|
2740
|
-
|
|
2741
|
-
log = SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2742
|
-
warn: lambda { |reason|
|
|
2743
|
-
Log.warn(:toolkit, "toolkit_suggest_log_failed",
|
|
2744
|
-
echo: "[samagotchi:toolkit] #{reason}")
|
|
2745
|
-
})
|
|
2800
|
+
log = toolkit_suggestion_log
|
|
2746
2801
|
# The predicate checks the session first; the kits (memory kits are
|
|
2747
2802
|
# read from disk) and the log (read whole) only when it gets there.
|
|
2748
2803
|
kits = nil
|
|
@@ -2769,11 +2824,19 @@ module Samagotchi
|
|
|
2769
2824
|
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2770
2825
|
return unless client
|
|
2771
2826
|
|
|
2772
|
-
|
|
2827
|
+
# D7: on a shared single-slot server audit's free ask would compete
|
|
2828
|
+
# with the turn's prefill, and any ask evicts the session's cached
|
|
2829
|
+
# prefix: on mode asks only on a first turn there (nothing is cached
|
|
2830
|
+
# yet worth keeping), never on the deferred path.
|
|
2831
|
+
if (!joined || ToolkitSuggest.model_reply?(session.messages)) && toolkit_judge_shares_local_host?(client.host)
|
|
2773
2832
|
Log.info(:toolkit, "toolkit_suggest_shared_host_skipped", session_id: session.id, host: client.host)
|
|
2774
2833
|
return
|
|
2775
2834
|
end
|
|
2776
2835
|
|
|
2836
|
+
# The warm-up's only value is the cache: the real ask never queues
|
|
2837
|
+
# behind it.
|
|
2838
|
+
stop_toolkit_warmup
|
|
2839
|
+
|
|
2777
2840
|
# Rules, not tool lists: this runs before await_init_tasks and
|
|
2778
2841
|
# apply_staged_tools!, so a first turn's plugin/MCP tools aren't
|
|
2779
2842
|
# registered yet; the report expands the rules (ToolkitSuggest.rules).
|
|
@@ -2794,16 +2857,124 @@ module Samagotchi
|
|
|
2794
2857
|
state: { project: state[:project].dup.freeze, prompt: state[:prompt].dup.freeze }.freeze
|
|
2795
2858
|
)
|
|
2796
2859
|
threshold = Config.get("toolkit.suggest.threshold")
|
|
2860
|
+
pending = ToolkitSuggest::Pending.new(log: log, joined: joined,
|
|
2861
|
+
timeout_seconds: SystemOne::Client.configured_timeout_ms / 1000.0)
|
|
2862
|
+
# On mode: cut by a Stop while the turn waits (Pending#wait), not
|
|
2863
|
+
# by one after it went on; audit's ask is never cut.
|
|
2864
|
+
controller = joined ? pending.cancel_controller : nil
|
|
2797
2865
|
@toolkit_audit_thread = Thread.new do
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2866
|
+
judged = nil
|
|
2867
|
+
begin
|
|
2868
|
+
judged = ToolkitSuggest.judge(client: client, snapshot: snapshot, threshold: threshold,
|
|
2869
|
+
cancel_controller: controller)
|
|
2870
|
+
rescue LLM::RequestCancelled
|
|
2871
|
+
nil
|
|
2872
|
+
rescue StandardError => e
|
|
2873
|
+
ToolkitSuggest.warn(e)
|
|
2874
|
+
ensure
|
|
2875
|
+
pending.finish(judged)
|
|
2876
|
+
end
|
|
2877
|
+
end
|
|
2878
|
+
joined ? pending : nil
|
|
2879
|
+
rescue StandardError => e
|
|
2880
|
+
ToolkitSuggest.warn(e)
|
|
2881
|
+
nil
|
|
2882
|
+
end
|
|
2883
|
+
private :start_toolkit_suggestion
|
|
2884
|
+
|
|
2885
|
+
# On mode, before apply_toolkit!: wait for the ask (#start_toolkit_
|
|
2886
|
+
# suggestion) up to System 1's timeout from its start, or until a Stop,
|
|
2887
|
+
# and put the session on a candidate picked with confidence ≥ threshold
|
|
2888
|
+
# (ToolkitBinding#suggest: origin suggested, saved, the notice). Any
|
|
2889
|
+
# other outcome (the default, below threshold, undecided, no judgment,
|
|
2890
|
+
# a timeout, a Stop) leaves the session on its default. Nothing here
|
|
2891
|
+
# sends a request; prepare_turn's cancel check ends a stopped turn. A
|
|
2892
|
+
# Stop wins over an answer already in (Pending#wait checks it first).
|
|
2893
|
+
def settle_toolkit_suggestion(turn, pending)
|
|
2894
|
+
return if pending.nil?
|
|
2895
|
+
|
|
2896
|
+
outcome, judged = pending.wait(turn.controller)
|
|
2897
|
+
unless outcome == :answered
|
|
2898
|
+
Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
|
|
2899
|
+
return
|
|
2900
|
+
end
|
|
2901
|
+
return if judged.nil?
|
|
2902
|
+
|
|
2903
|
+
pending.record(toolkit_suggestion_outcome(turn.session, judged))
|
|
2904
|
+
rescue StandardError => e
|
|
2905
|
+
ToolkitSuggest.warn(e)
|
|
2906
|
+
pending&.record("failed")
|
|
2907
|
+
end
|
|
2908
|
+
private :settle_toolkit_suggestion
|
|
2909
|
+
|
|
2910
|
+
# What the turn does with an answered ask, as the log line's did:
|
|
2911
|
+
# suggested (switched), chosen (the toolkit isn't the default any
|
|
2912
|
+
# more), unavailable (the kit is gone), defer or default.
|
|
2913
|
+
def toolkit_suggestion_outcome(session, judged)
|
|
2914
|
+
case judged.would
|
|
2915
|
+
when "suggested"
|
|
2916
|
+
return "chosen" unless session.toolkit_origin == "default"
|
|
2917
|
+
return "unavailable" unless @toolkit_binding.kits.key?(judged.suggestion.kit)
|
|
2918
|
+
|
|
2919
|
+
switched = @toolkit_binding.suggest(session, judged.suggestion.kit, confidence: judged.suggestion.confidence,
|
|
2920
|
+
deferred: ToolkitSuggest.model_reply?(session.messages))
|
|
2921
|
+
switched ? "suggested" : "chosen"
|
|
2922
|
+
when "defer" then "defer"
|
|
2923
|
+
else "default"
|
|
2924
|
+
end
|
|
2925
|
+
end
|
|
2926
|
+
private :toolkit_suggestion_outcome
|
|
2927
|
+
|
|
2928
|
+
# On mode, as an idle worker starts (Worker#start_background): one ask
|
|
2929
|
+
# with the real question and a stand-in prompt, on its own thread,
|
|
2930
|
+
# never logged and never waited for, so System 1's server has the
|
|
2931
|
+
# question's prefix cached for the first prompt. Only for a session a
|
|
2932
|
+
# first prompt could be asked for: origin default, no model reply, not
|
|
2933
|
+
# a delegate child or a review session, at least two candidates; and
|
|
2934
|
+
# not when the worker starts on a first prompt (that ask comes at once).
|
|
2935
|
+
def warm_toolkit_suggestion!
|
|
2936
|
+
return unless Config.get("toolkit.suggest.mode").to_s == "on"
|
|
2937
|
+
|
|
2938
|
+
session = @session
|
|
2939
|
+
return if session.nil? || session.toolkit_origin != "default" || session.delegate? || session.review?
|
|
2940
|
+
return if ToolkitSuggest.model_reply?(session.messages) || !session.last_prompt.to_s.strip.empty?
|
|
2941
|
+
|
|
2942
|
+
default = Toolkit.of(session.toolkit)
|
|
2943
|
+
kits = @toolkit_binding.kits
|
|
2944
|
+
return if ToolkitSuggest.candidates(kits, default_name: default.name).size < 2
|
|
2945
|
+
|
|
2946
|
+
client = SystemOne::Client.from_config(host_registry: @host_registry, mode: "on", tag: :toolkit)
|
|
2947
|
+
return unless client
|
|
2948
|
+
# D7: on the model's own single-slot server it would evict the
|
|
2949
|
+
# session's cached prefix for a question that may never come.
|
|
2950
|
+
return if toolkit_judge_shares_local_host?(client.host)
|
|
2951
|
+
|
|
2952
|
+
project = File.basename(session.project_root.to_s)
|
|
2953
|
+
@toolkit_warmup_thread = Thread.new do
|
|
2954
|
+
ToolkitSuggest.warm(client: client, default: default, kits: kits, project: project)
|
|
2801
2955
|
end
|
|
2802
2956
|
rescue StandardError => e
|
|
2803
2957
|
ToolkitSuggest.warn(e)
|
|
2804
2958
|
nil
|
|
2805
2959
|
end
|
|
2806
|
-
|
|
2960
|
+
public :warm_toolkit_suggestion!
|
|
2961
|
+
|
|
2962
|
+
# A still-running warm-up, stopped: a real ask (or the worker leaving)
|
|
2963
|
+
# doesn't wait behind it.
|
|
2964
|
+
def stop_toolkit_warmup
|
|
2965
|
+
@toolkit_warmup_thread&.kill
|
|
2966
|
+
@toolkit_warmup_thread = nil
|
|
2967
|
+
end
|
|
2968
|
+
private :stop_toolkit_warmup
|
|
2969
|
+
|
|
2970
|
+
def toolkit_suggestion_log
|
|
2971
|
+
path = ToolkitSuggest.log_path(session_state_dir)
|
|
2972
|
+
SystemOne::Log.new(dir: File.dirname(path), file: File.basename(path),
|
|
2973
|
+
warn: lambda { |reason|
|
|
2974
|
+
Log.warn(:toolkit, "toolkit_suggest_log_failed", echo: "[samagotchi:toolkit] #{reason}")
|
|
2975
|
+
})
|
|
2976
|
+
end
|
|
2977
|
+
private :toolkit_suggestion_log
|
|
2807
2978
|
|
|
2808
2979
|
# D7: System 1's host is the session's model server, and a local one
|
|
2809
2980
|
# (a single-slot Splash or llama.cpp): the same name, or two hosts:
|