samagotchi 0.37.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 +39 -1
- data/README.md +1 -1
- data/docs/broadcast.md +5 -2
- data/docs/configuration.md +1 -1
- data/docs/desktop.md +24 -2
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/plugins.md +84 -32
- data/lib/samagotchi/broadcast/scope_card.rb +15 -2
- data/lib/samagotchi/broadcast/triage.rb +29 -12
- data/lib/samagotchi/broadcast/triage_log.rb +9 -4
- data/lib/samagotchi/broadcast_command.rb +4 -2
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/plugin.rb +424 -89
- 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/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 +3 -2
- data/lib/samagotchi/kernel_loop.rb +6 -4
- data/lib/samagotchi/plugin/api.rb +5 -3
- 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
- metadata +2 -1
|
@@ -99,7 +99,7 @@ bundles:
|
|
|
99
99
|
after: 50 # tool calls in one turn with no answer before the first check
|
|
100
100
|
every: 50 # then again every N more
|
|
101
101
|
mode: ask # ask (a card) | nudge (nudge the model by itself) | notify; message:, ignore_tools: [...]
|
|
102
|
-
mcp: # tools
|
|
102
|
+
mcp: # the model searches the tools (find_mcp_tools) and calls one (mcp_call, <server>/<tool>); /mcp lists them
|
|
103
103
|
timeout: 60 # per call, seconds; startup_timeout: 10
|
|
104
104
|
servers:
|
|
105
105
|
files: # server name
|
|
@@ -107,11 +107,12 @@ bundles:
|
|
|
107
107
|
env: {NODE_OPTIONS: "--no-warnings"} # optional, added to chi's env
|
|
108
108
|
cwd: ~/scratch # optional; default the session's cwd
|
|
109
109
|
tools: [read_*, list_directory] # optional filter (globs)
|
|
110
|
+
description: files in ~/scratch # optional: the server's line in find_mcp_tools (default: its instructions' first sentence)
|
|
110
111
|
attach_image_paths: true # default: an answer that is only an image's path (temp dir/cwd) is attached as a picture
|
|
111
112
|
start: lazy # default: tools from the saved tools/list, the server starts on the first call; eager: with every session
|
|
112
113
|
```
|
|
113
114
|
|
|
114
|
-
A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool; see `docs/guardrails.md`. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
|
|
115
|
+
A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool: an `mcp_call` acts as `mcp_<server>_<tool>`, so `tool: "mcp_github_*"` covers one server's (a `find_mcp_tools` search is never asked about); see `docs/guardrails.md`. A server's optional `description:` is its line in `find_mcp_tools`' description. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
|
|
115
116
|
|
|
116
117
|
**Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` (`/model --default`, `/model --alias`) change one key on its own line of the text (`ConfigTextEdit`: the user's comments and layout stay; a new key goes at the end of its section, a missing section at the end of the file), check that the result parses to the old data plus that key (else they dump the whole file from it), and write through `AtomicFile` (a unique temp file renamed over config.yml, through a symlink, keeping its mode). Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
|
|
117
118
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.38.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
|
-
self_map.md: sha256:
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
8
|
+
self_map.md: sha256:245813bd5bd9a0a86eef095ba78b057dd3bfaa389de36d213971f88b51da7cd6
|
|
9
|
+
config_modification_protocol.md: sha256:648b19b1b9f7ca35fccdd69fee4c9886cb8cfb3748ae86affef7457c12164399
|
|
10
10
|
memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
|
|
46
46
|
cards, side answers (`ask_model`),
|
|
47
47
|
child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
|
|
48
|
-
`mcp` (MCP server tools; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
48
|
+
`mcp` (MCP server tools, searched with find_mcp_tools and called with mcp_call; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
|
|
49
49
|
(turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
|
|
50
50
|
(denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
|
|
51
51
|
repeats itself, asking me again), `check-in` (after N tool
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
5
5
|
// directly by chi desktop) manages the login item and exits;
|
|
6
6
|
// `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift),
|
|
7
|
-
// `ChiHelper --model-pick` the model chooser's (Models.swift)
|
|
7
|
+
// `ChiHelper --model-pick` the model chooser's (Models.swift),
|
|
8
|
+
// `ChiHelper --broadcast` the Everyone row's (Broadcast.swift).
|
|
8
9
|
import AppKit
|
|
9
10
|
import ServiceManagement
|
|
10
11
|
|
|
@@ -107,6 +108,9 @@ struct ChiHelperMain {
|
|
|
107
108
|
if args.count >= 2, args[1] == "--model-pick" {
|
|
108
109
|
exit(modelPickCommand(Array(args.dropFirst(2))))
|
|
109
110
|
}
|
|
111
|
+
if args.count >= 2, args[1] == "--broadcast" {
|
|
112
|
+
exit(broadcastCommand(Array(args.dropFirst(2))))
|
|
113
|
+
}
|
|
110
114
|
let app = NSApplication.shared
|
|
111
115
|
let delegate = AppDelegate()
|
|
112
116
|
app.delegate = delegate
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The note interface's "Everyone it concerns" row: ⌘⏎ runs `chi broadcast`
|
|
2
|
+
// with the note on stdin instead of `chi note` for picked sessions; chi
|
|
3
|
+
// finds the sessions it concerns (tags, then a triage model). The panel
|
|
4
|
+
// shows "broadcasting…", then the summary line, and stays open; `chi
|
|
5
|
+
// broadcast log` has the details. `ChiHelper --broadcast` is the build
|
|
6
|
+
// spec's seam.
|
|
7
|
+
import Foundation
|
|
8
|
+
|
|
9
|
+
enum Broadcast {
|
|
10
|
+
/// broadcast.triage_deadline's default (20 s) + 10 s, for a launch file
|
|
11
|
+
/// written before it carried broadcast_timeout.
|
|
12
|
+
static let defaultTimeout: TimeInterval = 30
|
|
13
|
+
|
|
14
|
+
/// The launch file's broadcast_timeout (Desktop::MacOS.broadcast_timeout:
|
|
15
|
+
/// the triage deadline, then the deliveries and chi's start), not
|
|
16
|
+
/// ChiRunner's 10 s.
|
|
17
|
+
static func timeout(_ launch: LaunchConfig?) -> TimeInterval {
|
|
18
|
+
guard let seconds = launch?.broadcastTimeout, seconds > 0 else { return defaultTimeout }
|
|
19
|
+
return seconds
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct Outcome {
|
|
23
|
+
let ok: Bool
|
|
24
|
+
let message: String
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// What the panel says once chi broadcast ended: the broadcast's id and
|
|
28
|
+
/// its summary line ("b-7f3a1c9e: delivered 4 · skipped 2"); on a failure,
|
|
29
|
+
/// the summary, the failed recipients' lines and stderr (a refusal is
|
|
30
|
+
/// stderr alone).
|
|
31
|
+
static func outcome(_ result: Result<ChiResult, ChiError>, timeout: TimeInterval) -> Outcome {
|
|
32
|
+
let r: ChiResult
|
|
33
|
+
switch result {
|
|
34
|
+
case .failure(let error): return Outcome(ok: false, message: error.description)
|
|
35
|
+
case .success(let done): r = done
|
|
36
|
+
}
|
|
37
|
+
if r.timedOut {
|
|
38
|
+
return Outcome(ok: false, message: "chi broadcast took over \(Int(timeout)) s and was stopped; "
|
|
39
|
+
+ "the note may be partly delivered (chi broadcast log)")
|
|
40
|
+
}
|
|
41
|
+
let lines = r.stdout.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
42
|
+
// "broadcast b-7f3a1c9e "…"": the first line names it.
|
|
43
|
+
let id = lines.first.flatMap { line -> String? in
|
|
44
|
+
let words = line.split(separator: " ")
|
|
45
|
+
return words.count >= 2 && words[0] == "broadcast" && words[1].hasPrefix("b-") ? String(words[1]) : nil
|
|
46
|
+
}
|
|
47
|
+
let summary = lines.last { $0.hasPrefix("delivered ") }.map { line in id.map { "\($0): \(line)" } ?? line }
|
|
48
|
+
if r.status == 0 {
|
|
49
|
+
return Outcome(ok: true, message: [summary ?? "Broadcast.", "details: chi broadcast log"].joined(separator: "\n"))
|
|
50
|
+
}
|
|
51
|
+
let failed = lines.filter { $0.split(separator: " ").dropFirst().first == "failed" }
|
|
52
|
+
let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
53
|
+
let parts = ([summary].compactMap { $0 } + failed + [err]).filter { !$0.isEmpty }
|
|
54
|
+
return Outcome(ok: false, message: parts.isEmpty ? "chi broadcast failed (exit \(r.status))" : parts.joined(separator: "\n"))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
extension ChiRunner {
|
|
59
|
+
/// `chi broadcast` with +note+ on stdin, under Broadcast.timeout.
|
|
60
|
+
/// Calls back on +queue+ with the outcome and the timeout it had.
|
|
61
|
+
func broadcast(_ note: String, queue: DispatchQueue = .main,
|
|
62
|
+
completion: @escaping (Broadcast.Outcome, TimeInterval) -> Void) {
|
|
63
|
+
let timeout = Broadcast.timeout(try? loadLaunch())
|
|
64
|
+
run(["broadcast"], stdin: Data(note.utf8), timeout: timeout, queue: queue) { result in
|
|
65
|
+
completion(Broadcast.outcome(result, timeout: timeout), timeout)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// `ChiHelper --broadcast [--launch PATH]`: the note on stdin, the outcome
|
|
71
|
+
/// as JSON on stdout ({"ok", "message", "timeout"}); exit 1 when not ok.
|
|
72
|
+
func broadcastCommand(_ args: [String]) -> Int32 {
|
|
73
|
+
var args = args
|
|
74
|
+
var launchPath = ChiRunner.launchPath
|
|
75
|
+
while !args.isEmpty {
|
|
76
|
+
let arg = args.removeFirst()
|
|
77
|
+
switch arg {
|
|
78
|
+
case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
|
|
79
|
+
default:
|
|
80
|
+
FileHandle.standardError.write("usage: ChiHelper --broadcast [--launch PATH]\n".data(using: .utf8)!)
|
|
81
|
+
return 2
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
let note = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
|
|
85
|
+
let queue = DispatchQueue(label: "broadcast-command")
|
|
86
|
+
let done = DispatchSemaphore(value: 0)
|
|
87
|
+
var status: Int32 = 0
|
|
88
|
+
ChiRunner(launchPath: launchPath).broadcast(note, queue: queue) { outcome, timeout in
|
|
89
|
+
let out: [String: Any] = ["ok": outcome.ok, "message": outcome.message, "timeout": Int(timeout)]
|
|
90
|
+
let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
|
|
91
|
+
print(String(decoding: data, as: UTF8.self))
|
|
92
|
+
status = outcome.ok ? 0 : 1
|
|
93
|
+
done.signal()
|
|
94
|
+
}
|
|
95
|
+
done.wait()
|
|
96
|
+
return status
|
|
97
|
+
}
|
|
@@ -9,6 +9,13 @@ struct LaunchConfig: Decodable {
|
|
|
9
9
|
let env: [String: String]?
|
|
10
10
|
/// Agents in kitty windows (Kitty.swift); nil = none.
|
|
11
11
|
let kitty: KittySettings?
|
|
12
|
+
/// Seconds `chi broadcast` may take (Broadcast.swift); nil = the default.
|
|
13
|
+
let broadcastTimeout: Double?
|
|
14
|
+
|
|
15
|
+
enum CodingKeys: String, CodingKey {
|
|
16
|
+
case version, argv, env, kitty
|
|
17
|
+
case broadcastTimeout = "broadcast_timeout"
|
|
18
|
+
}
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
enum ChiError: Error, CustomStringConvertible {
|
|
@@ -102,9 +109,11 @@ final class ChiRunner {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/// Runs `chi <args>` through ProcessRunner (no pipe deadlock near the
|
|
105
|
-
/// 16 KiB note cap). Calls back on the main queue
|
|
112
|
+
/// 16 KiB note cap). Calls back on +queue+ (the main queue by default; a
|
|
113
|
+
/// headless seam waits on another one); a launch file that can't be read
|
|
114
|
+
/// calls back at once.
|
|
106
115
|
/// @param timeout this call's limit instead of the runner's
|
|
107
|
-
func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
|
|
116
|
+
func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil, queue: DispatchQueue = .main,
|
|
108
117
|
completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
109
118
|
let config: LaunchConfig
|
|
110
119
|
do { config = try loadLaunch() } catch let error as ChiError {
|
|
@@ -115,7 +124,7 @@ final class ChiRunner {
|
|
|
115
124
|
|
|
116
125
|
ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
|
|
117
126
|
env: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
|
|
118
|
-
stdin: stdin, timeout: timeout ?? self.timeout, completion: completion)
|
|
127
|
+
stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
/// The live sessions, oldest started first, then up to +recentCount+
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// The Spotlight-like panel: a message line on top, the images (a screenshot,
|
|
2
2
|
// Finder files, drops) as thumbnails, the text (selection or clipboard)
|
|
3
|
-
// below as quoted context, then a "New session" row
|
|
4
|
-
// sessions, agents in kitty windows
|
|
5
|
-
// sends a message (`chi send`, or `chi
|
|
6
|
-
// `--image` per image; pasted with Enter
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// below as quoted context, then a "New session" row, an "Everyone it
|
|
4
|
+
// concerns" row and the targets: live sessions, agents in kitty windows
|
|
5
|
+
// (Kitty.swift), recent sessions. ⏎ sends a message (`chi send`, or `chi
|
|
6
|
+
// send --new` on the new row, with `--image` per image; pasted with Enter
|
|
7
|
+
// into a kitty window), ⌘⏎ a note (`chi note`, text only; `chi broadcast`
|
|
8
|
+
// on the Everyone row, Broadcast.swift), ⌥⏎ a paste without Enter (kitty
|
|
9
|
+
// windows only), ⇧⏎ a newline. ⌘M (or a click on the new row's model) opens the model
|
|
9
10
|
// chooser for a new session in place of the targets (Models.swift).
|
|
10
11
|
import AppKit
|
|
11
12
|
import SwiftUI
|
|
@@ -41,6 +42,9 @@ final class PanelModel: ObservableObject {
|
|
|
41
42
|
var touched = false
|
|
42
43
|
/// The "New session" row; never together with sessions (`--new` takes no ids).
|
|
43
44
|
@Published var newSelected = false
|
|
45
|
+
/// The "Everyone it concerns" row: ⌘⏎ broadcasts (`chi broadcast`);
|
|
46
|
+
/// never together with the new row or picked targets.
|
|
47
|
+
@Published var everyoneSelected = false
|
|
44
48
|
/// The model a new session starts on (`chi self --model`), for the new
|
|
45
49
|
/// row's hint; nil while unknown.
|
|
46
50
|
@Published var newModel: String?
|
|
@@ -72,8 +76,11 @@ final class PanelModel: ObservableObject {
|
|
|
72
76
|
}
|
|
73
77
|
var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
74
78
|
var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
79
|
+
/// A note's text (stdin of `chi note`): the message line, then the context.
|
|
80
|
+
var noteText: String { [trimmedPrompt, hasContext ? text : ""].filter { !$0.isEmpty }.joined(separator: "\n\n") }
|
|
75
81
|
var sendEnabled: Bool {
|
|
76
|
-
canSend && (newSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty)
|
|
82
|
+
canSend && (newSelected || everyoneSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty)
|
|
83
|
+
&& (!trimmedPrompt.isEmpty || hasContext)
|
|
77
84
|
}
|
|
78
85
|
var live: [LiveSession] { sessions.filter { !$0.recent } }
|
|
79
86
|
|
|
@@ -134,6 +141,7 @@ final class PanelModel: ObservableObject {
|
|
|
134
141
|
func openPicker() {
|
|
135
142
|
touched = true
|
|
136
143
|
newSelected = true
|
|
144
|
+
everyoneSelected = false
|
|
137
145
|
selected = []
|
|
138
146
|
modelQuery = ""
|
|
139
147
|
pickerIndex = chooserRows.firstIndex(where: isCurrent) ?? 0
|
|
@@ -155,6 +163,7 @@ final class PanelModel: ObservableObject {
|
|
|
155
163
|
func choose(_ row: ModelRow) {
|
|
156
164
|
pickTouched = true
|
|
157
165
|
newSelected = true
|
|
166
|
+
everyoneSelected = false
|
|
158
167
|
selected = []
|
|
159
168
|
if row.kind == .standard {
|
|
160
169
|
pickedModel = nil
|
|
@@ -192,13 +201,20 @@ final class PanelModel: ObservableObject {
|
|
|
192
201
|
|
|
193
202
|
func toggle(_ id: String) {
|
|
194
203
|
touched = true
|
|
195
|
-
if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
|
|
204
|
+
if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false; everyoneSelected = false }
|
|
196
205
|
}
|
|
197
206
|
|
|
198
207
|
func toggleNew() {
|
|
199
208
|
touched = true
|
|
200
209
|
newSelected.toggle()
|
|
201
|
-
if newSelected { selected = [] }
|
|
210
|
+
if newSelected { selected = []; everyoneSelected = false }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// The preselected session is dropped: a broadcast picks its own.
|
|
214
|
+
func toggleEveryone() {
|
|
215
|
+
touched = true
|
|
216
|
+
everyoneSelected.toggle()
|
|
217
|
+
if everyoneSelected { selected = []; newSelected = false }
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
/// Where a new session starts: the helper runs in $HOME, which is in no
|
|
@@ -228,6 +244,7 @@ final class PanelModel: ObservableObject {
|
|
|
228
244
|
/// Who the message goes to, for the placeholder: chi, or the one agent
|
|
229
245
|
/// when only kitty windows running it are chosen.
|
|
230
246
|
var addressee: String {
|
|
247
|
+
if everyoneSelected { return "everyone it concerns" }
|
|
231
248
|
let agents = Set(selectedKitty.map(\.agent))
|
|
232
249
|
return agents.count == 1 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
|
|
233
250
|
}
|
|
@@ -257,8 +274,7 @@ struct PanelView: View {
|
|
|
257
274
|
|
|
258
275
|
var body: some View {
|
|
259
276
|
VStack(spacing: 0) {
|
|
260
|
-
|
|
261
|
-
TextField(model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
|
|
277
|
+
TextField(placeholder, text: $model.prompt, axis: .vertical)
|
|
262
278
|
.textFieldStyle(.plain)
|
|
263
279
|
.font(.system(size: 17))
|
|
264
280
|
.lineLimit(1...4)
|
|
@@ -294,6 +310,12 @@ struct PanelView: View {
|
|
|
294
310
|
.onAppear { promptFocused = true }
|
|
295
311
|
}
|
|
296
312
|
|
|
313
|
+
/// The message is required with images, as in the web.
|
|
314
|
+
var placeholder: String {
|
|
315
|
+
if model.everyoneSelected { return "Tell everyone it concerns… (⌘⏎)" }
|
|
316
|
+
return model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…"
|
|
317
|
+
}
|
|
318
|
+
|
|
297
319
|
@ViewBuilder var imageStrip: some View {
|
|
298
320
|
if !model.images.isEmpty {
|
|
299
321
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
@@ -326,6 +348,7 @@ struct PanelView: View {
|
|
|
326
348
|
}.padding(10)
|
|
327
349
|
default:
|
|
328
350
|
newRow
|
|
351
|
+
everyoneRow
|
|
329
352
|
let live = model.live.count, kitty = model.kittyWindows.count
|
|
330
353
|
ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
|
|
331
354
|
if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
|
|
@@ -416,6 +439,32 @@ struct PanelView: View {
|
|
|
416
439
|
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
417
440
|
}
|
|
418
441
|
|
|
442
|
+
/// `chi broadcast` instead of picked sessions (⌘B): ⌘⏎ shares the note
|
|
443
|
+
/// with the sessions chi finds it concerns.
|
|
444
|
+
var everyoneRow: some View {
|
|
445
|
+
let on = model.everyoneSelected
|
|
446
|
+
return Button(action: { model.toggleEveryone() }) {
|
|
447
|
+
HStack(spacing: 10) {
|
|
448
|
+
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
449
|
+
.font(.system(size: 14))
|
|
450
|
+
.foregroundColor(on ? .accentColor : .secondary)
|
|
451
|
+
.frame(width: 18)
|
|
452
|
+
VStack(alignment: .leading, spacing: 1) {
|
|
453
|
+
Text("Everyone it concerns").font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
454
|
+
Text("a note for the sessions chi finds it concerns: ⌘⏎").font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
|
|
455
|
+
}
|
|
456
|
+
Spacer()
|
|
457
|
+
Text("⌘B").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
|
|
458
|
+
}
|
|
459
|
+
.padding(.horizontal, 8).padding(.vertical, 6)
|
|
460
|
+
.background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
|
|
461
|
+
.contentShape(Rectangle())
|
|
462
|
+
}
|
|
463
|
+
.buttonStyle(.plain)
|
|
464
|
+
.help("chi broadcast: sessions sharing a tag with the note get it, a triage model judges the rest; "
|
|
465
|
+
+ "a first line naming a project keeps it there. Details: chi broadcast log")
|
|
466
|
+
}
|
|
467
|
+
|
|
419
468
|
/// The new session's model: grey for the default, accent for a pick; a
|
|
420
469
|
/// click (or ⌘M) opens the chooser.
|
|
421
470
|
var modelLabel: some View {
|
|
@@ -528,15 +577,18 @@ struct PanelView: View {
|
|
|
528
577
|
|
|
529
578
|
var footer: some View {
|
|
530
579
|
HStack(spacing: 10) {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
.
|
|
535
|
-
|
|
536
|
-
|
|
580
|
+
// A broadcast's source is "broadcast": no field, more room for its summary.
|
|
581
|
+
if !model.everyoneSelected {
|
|
582
|
+
HStack(spacing: 4) {
|
|
583
|
+
Image(systemName: "arrow.down.doc").font(.system(size: 11)).foregroundColor(.secondary)
|
|
584
|
+
TextField("source", text: $model.source)
|
|
585
|
+
.textFieldStyle(.plain)
|
|
586
|
+
.font(.system(size: 12))
|
|
587
|
+
.frame(width: 90)
|
|
588
|
+
}
|
|
589
|
+
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
590
|
+
.background(Capsule().fill(Color.primary.opacity(0.07)))
|
|
537
591
|
}
|
|
538
|
-
.padding(.horizontal, 8).padding(.vertical, 4)
|
|
539
|
-
.background(Capsule().fill(Color.primary.opacity(0.07)))
|
|
540
592
|
|
|
541
593
|
HStack(spacing: 0) {
|
|
542
594
|
if !model.origin.isEmpty {
|
|
@@ -564,17 +616,26 @@ struct PanelView: View {
|
|
|
564
616
|
Button("Paste ⌥⏎") { send(.paste) }
|
|
565
617
|
.disabled(!model.sendEnabled)
|
|
566
618
|
.help("paste it into the agent's input without Enter: add more, then press Enter there")
|
|
619
|
+
} else if model.everyoneSelected {
|
|
620
|
+
Button("Broadcast ⌘⏎") { send(.note) }
|
|
621
|
+
.disabled(!model.sendEnabled)
|
|
622
|
+
.help("share it as a note with every session it concerns (chi broadcast); starts no turn")
|
|
567
623
|
} else {
|
|
568
624
|
Button("Note ⌘⏎") { send(.note) }
|
|
569
625
|
.disabled(!model.sendEnabled || !model.selectedKitty.isEmpty || model.newSelected)
|
|
570
626
|
.help(model.newSelected ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
|
|
571
627
|
: "add it as background the model sees on its next turn; starts no turn")
|
|
572
628
|
}
|
|
573
|
-
|
|
574
|
-
|
|
629
|
+
// A broadcast is a note: no Send.
|
|
630
|
+
if model.everyoneSelected {
|
|
631
|
+
if model.phase == .sending { ProgressView().controlSize(.small) }
|
|
632
|
+
} else {
|
|
633
|
+
Button(action: { send(.message) }) {
|
|
634
|
+
if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
|
|
635
|
+
}
|
|
636
|
+
.disabled(!model.sendEnabled)
|
|
637
|
+
.help("send it as your message: a turn runs")
|
|
575
638
|
}
|
|
576
|
-
.disabled(!model.sendEnabled)
|
|
577
|
-
.help("send it as your message: a turn runs")
|
|
578
639
|
}
|
|
579
640
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
580
641
|
}
|
|
@@ -744,7 +805,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
744
805
|
return true
|
|
745
806
|
}
|
|
746
807
|
|
|
747
|
-
/// ⌘1…⌘9 toggle a target, ⌘0 the new row; ⌘M opens or closes the model
|
|
808
|
+
/// ⌘1…⌘9 toggle a target, ⌘0 the new row, ⌘B the Everyone row; ⌘M opens or closes the model
|
|
748
809
|
/// chooser, where ⌘1…⌘9 pick a row.
|
|
749
810
|
private func handleKey(_ event: NSEvent) -> Bool {
|
|
750
811
|
let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command
|
|
@@ -759,6 +820,11 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
759
820
|
model.choose(rows[n - 1])
|
|
760
821
|
return true
|
|
761
822
|
}
|
|
823
|
+
// By key code, as ⌘M.
|
|
824
|
+
if command, event.keyCode == 11 {
|
|
825
|
+
model.toggleEveryone()
|
|
826
|
+
return true
|
|
827
|
+
}
|
|
762
828
|
let targets = model.targets
|
|
763
829
|
guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars),
|
|
764
830
|
n <= min(targets.count, 9) else { return false }
|
|
@@ -770,6 +836,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
770
836
|
/// @param returnTo the app to give focus back to on close
|
|
771
837
|
func show(text: String, images: [PanelImage] = [], origin: String, source: String, returnTo app: NSRunningApplication?) {
|
|
772
838
|
returnTo = app
|
|
839
|
+
opens += 1
|
|
773
840
|
// A show on an open panel resets it without a close.
|
|
774
841
|
dropImages()
|
|
775
842
|
model.prompt = ""
|
|
@@ -780,6 +847,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
780
847
|
model.source = source
|
|
781
848
|
model.message = ""
|
|
782
849
|
model.newSelected = false
|
|
850
|
+
model.everyoneSelected = false
|
|
783
851
|
model.selected = []
|
|
784
852
|
model.touched = false
|
|
785
853
|
model.pickerOpen = false
|
|
@@ -875,6 +943,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
875
943
|
/// finished. Images the helper wrote are copied for kitty first (chi's
|
|
876
944
|
/// send deletes them) and deleted only after every job.
|
|
877
945
|
func send(_ kind: SendKind) {
|
|
946
|
+
if model.everyoneSelected { broadcast(kind); return }
|
|
878
947
|
let ids = model.selectedIds
|
|
879
948
|
let windows = model.selectedKitty
|
|
880
949
|
let new = model.newSelected
|
|
@@ -953,6 +1022,35 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
953
1022
|
}
|
|
954
1023
|
}
|
|
955
1024
|
|
|
1025
|
+
/// Counts the panel's opens: a broadcast that ends after a reopen leaves
|
|
1026
|
+
/// the new open alone.
|
|
1027
|
+
private var opens = 0
|
|
1028
|
+
|
|
1029
|
+
/// The Everyone row: `chi broadcast` with the note (the source field is
|
|
1030
|
+
/// not used: a broadcast's source is "broadcast"). It stays open with
|
|
1031
|
+
/// the summary line (Esc closes): no auto-close.
|
|
1032
|
+
private func broadcast(_ kind: SendKind) {
|
|
1033
|
+
if kind != .note {
|
|
1034
|
+
NSSound.beep()
|
|
1035
|
+
model.message = "A broadcast is a note: ⌘⏎"
|
|
1036
|
+
return
|
|
1037
|
+
}
|
|
1038
|
+
if !model.images.isEmpty {
|
|
1039
|
+
NSSound.beep()
|
|
1040
|
+
model.message = "A broadcast is text only: remove the images"
|
|
1041
|
+
return
|
|
1042
|
+
}
|
|
1043
|
+
guard model.sendEnabled else { NSSound.beep(); return }
|
|
1044
|
+
model.phase = .sending
|
|
1045
|
+
model.message = "broadcasting…"
|
|
1046
|
+
let open = opens
|
|
1047
|
+
runner.broadcast(model.noteText) { [weak self] outcome, _ in
|
|
1048
|
+
guard let self, self.opens == open else { return }
|
|
1049
|
+
self.model.phase = outcome.ok ? .sent : .failed
|
|
1050
|
+
self.model.message = outcome.message
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
956
1054
|
struct SendOutcome {
|
|
957
1055
|
let ok: Bool
|
|
958
1056
|
let message: String
|
|
@@ -980,8 +1078,7 @@ final class PanelController: NSObject, NSWindowDelegate {
|
|
|
980
1078
|
args = ["note"]
|
|
981
1079
|
let source = model.source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
982
1080
|
if !source.isEmpty { args += ["--source", source] }
|
|
983
|
-
|
|
984
|
-
stdin = Data(parts.joined(separator: "\n\n").utf8)
|
|
1081
|
+
stdin = Data(model.noteText.utf8)
|
|
985
1082
|
}
|
|
986
1083
|
if !new { args += ids }
|
|
987
1084
|
// Reading, converting and a new worker's start take longer than text.
|
|
@@ -41,6 +41,10 @@ module Samagotchi
|
|
|
41
41
|
ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
|
|
42
42
|
# Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
|
|
43
43
|
LANG = "en_US.UTF-8"
|
|
44
|
+
# chi broadcast's default triage deadline, and the helper's seconds on
|
|
45
|
+
# top of it (MacOS.broadcast_timeout).
|
|
46
|
+
BROADCAST_TRIAGE_DEADLINE = 20
|
|
47
|
+
BROADCAST_MARGIN = 10
|
|
44
48
|
|
|
45
49
|
# Runs a command without a shell; [output, success].
|
|
46
50
|
class Runner
|
|
@@ -62,11 +66,25 @@ module Samagotchi
|
|
|
62
66
|
{ "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
|
|
63
67
|
end
|
|
64
68
|
|
|
69
|
+
# The helper as chi desktop and chi update make it: the settings it
|
|
70
|
+
# bakes into the launch file read from the config.
|
|
71
|
+
def self.from_config(register: true)
|
|
72
|
+
new(register: register, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Seconds the helper gives `chi broadcast` before it stops it: the
|
|
76
|
+
# triage deadline, then the deliveries and chi's own start. Baked into
|
|
77
|
+
# the launch file: a changed deadline reaches the helper with chi update.
|
|
78
|
+
def self.broadcast_timeout(get = Config.method(:get))
|
|
79
|
+
(get.call("broadcast.triage_deadline") || BROADCAST_TRIAGE_DEADLINE).to_f + BROADCAST_MARGIN
|
|
80
|
+
end
|
|
81
|
+
|
|
65
82
|
attr_reader :app_dir, :support_dir
|
|
66
83
|
|
|
67
84
|
def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
|
|
68
85
|
source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
|
|
69
|
-
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil
|
|
86
|
+
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil,
|
|
87
|
+
broadcast_timeout: nil)
|
|
70
88
|
home = env["HOME"] || Dir.home
|
|
71
89
|
@app_dir = app_dir || File.join(home, "Applications")
|
|
72
90
|
@support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
|
|
@@ -80,6 +98,7 @@ module Samagotchi
|
|
|
80
98
|
@sources_dir = sources_dir
|
|
81
99
|
@register = register
|
|
82
100
|
@kitty = kitty
|
|
101
|
+
@broadcast_timeout = broadcast_timeout
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def app_path = File.join(@app_dir, "#{APP_NAME}.app")
|
|
@@ -93,12 +112,13 @@ module Samagotchi
|
|
|
93
112
|
# shell's PATH), and the allowlisted env. chi is an installed gem's
|
|
94
113
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
95
114
|
# `gem cleanup`), else this checkout's bin/chi. The kitty section only
|
|
96
|
-
# when kitty.listen_on is set.
|
|
115
|
+
# when kitty.listen_on is set; the broadcast timeout when given.
|
|
97
116
|
def launch_config
|
|
98
117
|
env = { "LANG" => LANG }
|
|
99
118
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
100
119
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
101
120
|
config["kitty"] = @kitty if @kitty
|
|
121
|
+
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
102
122
|
config
|
|
103
123
|
end
|
|
104
124
|
|
|
@@ -122,13 +142,15 @@ module Samagotchi
|
|
|
122
142
|
end
|
|
123
143
|
|
|
124
144
|
# The launch file names another chi version or path than this one, or
|
|
125
|
-
# other kitty settings: refresh_launch_file fixes
|
|
145
|
+
# other kitty settings or broadcast timeout: refresh_launch_file fixes
|
|
146
|
+
# that without a rebuild.
|
|
126
147
|
def launch_outdated?
|
|
127
148
|
launch = read_launch
|
|
128
|
-
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
|
|
149
|
+
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty ||
|
|
150
|
+
launch["broadcast_timeout"] != @broadcast_timeout
|
|
129
151
|
end
|
|
130
152
|
|
|
131
|
-
# Rewrite the launch file for this chi and
|
|
153
|
+
# Rewrite the launch file for this chi and its settings, keeping
|
|
132
154
|
# the env it was installed with. The app reads it at each send: no restart.
|
|
133
155
|
def refresh_launch_file
|
|
134
156
|
config = launch_config
|
|
@@ -41,7 +41,7 @@ module Samagotchi
|
|
|
41
41
|
@argv = argv.dup
|
|
42
42
|
@stdout = stdout
|
|
43
43
|
@stderr = stderr
|
|
44
|
-
@platform = platform ||
|
|
44
|
+
@platform = platform || Desktop::MacOS.method(:from_config)
|
|
45
45
|
@supported = supported
|
|
46
46
|
end
|
|
47
47
|
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1531,8 +1531,9 @@ module Samagotchi
|
|
|
1531
1531
|
# @param on_event [Proc, nil] receives event hashes
|
|
1532
1532
|
# @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
|
|
1533
1533
|
# @param cancel_controller [CancellationController, nil]
|
|
1534
|
-
# @param max_tool_output_chars [Integer, nil] per-output char cap
|
|
1535
|
-
# :tool_call_completed event's `output:`
|
|
1534
|
+
# @param max_tool_output_chars [Integer, nil] per-output char cap on what
|
|
1535
|
+
# the model gets back and the :tool_call_completed event's `output:`
|
|
1536
|
+
# (nil → max_tool_output_chars)
|
|
1536
1537
|
# @return [LLM::ModelResult]
|
|
1537
1538
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
1538
1539
|
# Array<String> of steering messages queued while the turn runs; drained
|
|
@@ -176,8 +176,9 @@ module Samagotchi
|
|
|
176
176
|
# @param on_stream_event [Proc, nil] optional callback for generation events
|
|
177
177
|
# @param cancel_controller [CancellationController, nil] optional cancellation source
|
|
178
178
|
# @param model_name [String, nil] optional per-run model override
|
|
179
|
-
# @param max_tool_output_chars [Integer, nil] per-output char cap
|
|
180
|
-
# :tool_call_completed event's `output:`
|
|
179
|
+
# @param max_tool_output_chars [Integer, nil] per-output char cap on what
|
|
180
|
+
# the model gets back and the :tool_call_completed event's `output:`
|
|
181
|
+
# (nil → max_tool_output_chars)
|
|
181
182
|
# @param pending_input [#call, nil] optional drain proc returning
|
|
182
183
|
# Array<String> of user steering messages queued while the turn runs.
|
|
183
184
|
# Drained at iteration boundaries (llama.cpp's /completion cannot accept
|
|
@@ -223,7 +224,8 @@ module Samagotchi
|
|
|
223
224
|
@model_key_fallback = fallback
|
|
224
225
|
end
|
|
225
226
|
|
|
226
|
-
# Resolve the per-output character cap for the
|
|
227
|
+
# Resolve the per-output character cap for the tool outputs the model
|
|
228
|
+
# gets back and the emitted tool call events.
|
|
227
229
|
#
|
|
228
230
|
# Precedence: an explicit override wins, then max_tool_output_chars
|
|
229
231
|
# (Config). A non-positive value falls back to
|
|
@@ -539,7 +541,7 @@ module Samagotchi
|
|
|
539
541
|
runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
|
|
540
542
|
on_stream_event: turn.on_stream_event, cap: turn.cap)
|
|
541
543
|
turn.tool_activity.concat(ToolResponse.activities(runs))
|
|
542
|
-
# One entry for the batch: the
|
|
544
|
+
# One entry for the batch: the capped outputs joined (ToolResponse.joined).
|
|
543
545
|
turn.conversation << ToolResponse.joined(runs)
|
|
544
546
|
turn.pending_tool_calls = true
|
|
545
547
|
:next
|
|
@@ -178,9 +178,11 @@ module Samagotchi
|
|
|
178
178
|
# down) and returns a short summary ("3 tools") or raises (the UIs
|
|
179
179
|
# show a warn card). Every UI shows it running (the label) and done.
|
|
180
180
|
# @param label [String] what it does, shown while it runs
|
|
181
|
-
# @param provides_tools [Boolean] it
|
|
182
|
-
#
|
|
183
|
-
#
|
|
181
|
+
# @param provides_tools [Boolean] it provides what the plugin's tools
|
|
182
|
+
# need (its own, or through chi.replace_tools; the mcp bundle's
|
|
183
|
+
# index of a server's tools): a turn sent meanwhile waits for it
|
|
184
|
+
# before its first model request, up to +timeout+; a Ctrl-C ends
|
|
185
|
+
# the wait
|
|
184
186
|
# @param quiet [Boolean] shown only if it fails (a background refresh)
|
|
185
187
|
# @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
|
|
186
188
|
# @param failed [String, nil] the warn card's short title if it raises
|