samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -5,7 +5,8 @@
5
5
  // sends a message (`chi send`, or `chi send --new` on the new row, with
6
6
  // `--image` per image; pasted with Enter into a kitty window), ⌘⏎ a note
7
7
  // (`chi note`, text only), ⌥⏎ a paste without Enter (kitty windows only),
8
- // ⇧⏎ a newline.
8
+ // ⇧⏎ a newline. ⌘M (or a click on the new row's model) opens the model
9
+ // chooser for a new session in place of the targets (Models.swift).
9
10
  import AppKit
10
11
  import SwiftUI
11
12
 
@@ -40,6 +41,23 @@ final class PanelModel: ObservableObject {
40
41
  /// The model a new session starts on (`chi self --model`), for the new
41
42
  /// row's hint; nil while unknown.
42
43
  @Published var newModel: String?
44
+ /// `chi models`' last answer, kept across opens and refreshed on each.
45
+ @Published var modelList: ModelList?
46
+ @Published var modelsLoading = false
47
+ /// Why the last refresh failed (the older list, if any, stays).
48
+ @Published var modelsError: String?
49
+ /// The new session's model; nil: the default (no `--model`).
50
+ @Published var pickedModel: String?
51
+ /// The user picked during this open: a late list doesn't undo it.
52
+ var pickTouched = false
53
+ @Published var recentModels: [String] = UserDefaults.standard.stringArray(forKey: ModelPick.recentKey) ?? []
54
+ /// The chooser replaces the targets while open.
55
+ @Published var pickerOpen = false
56
+ @Published var modelQuery = "" {
57
+ didSet { pickerIndex = modelQuery.trimmingCharacters(in: .whitespaces).isEmpty || chooserRows.count < 2 ? 0 : 1 }
58
+ }
59
+ /// The highlighted chooser row (↑/↓, ⏎ picks it).
60
+ @Published var pickerIndex = 0
43
61
  @Published var phase: Phase = .loading
44
62
  @Published var message = ""
45
63
 
@@ -79,6 +97,85 @@ final class PanelModel: ObservableObject {
79
97
  }
80
98
  var recent: [LiveSession] { sessions.filter(\.recent) }
81
99
 
100
+ /// The model a new session starts on without `--model`.
101
+ var defaultModelName: String? { modelList?.default ?? newModel }
102
+
103
+ /// What the new row shows: the pick, else the default as resolved (as
104
+ /// `chi self --model` names it before the list is in: no flip).
105
+ var shownModel: String? { pickedModel ?? defaultModelName }
106
+
107
+ /// `--model` for `chi send --new`, verbatim; nil for the default.
108
+ var modelForSend: String? {
109
+ guard let picked = pickedModel else { return nil }
110
+ if let standard = defaultModelName, ModelPick.same(picked, standard) { return nil }
111
+ return picked
112
+ }
113
+
114
+ var chooserRows: [ModelRow] {
115
+ ModelPick.chooserRows(list: modelList, fallbackDefault: newModel, recents: recentModels, query: modelQuery)
116
+ }
117
+
118
+ /// A failed refresh, or the hosts that failed in the last list.
119
+ var modelsNote: String? {
120
+ if let modelsError { return modelsError }
121
+ let warnings = modelList?.warnings ?? []
122
+ return warnings.isEmpty ? nil : warnings.joined(separator: "; ")
123
+ }
124
+
125
+ func isCurrent(_ row: ModelRow) -> Bool {
126
+ row.kind == .standard ? modelForSend == nil : pickedModel.map { ModelPick.same($0, row.name) } ?? false
127
+ }
128
+
129
+ /// Opens the chooser on the new row, the current pick highlighted.
130
+ func openPicker() {
131
+ touched = true
132
+ newSelected = true
133
+ selected = []
134
+ modelQuery = ""
135
+ pickerIndex = chooserRows.firstIndex(where: isCurrent) ?? 0
136
+ pickerOpen = true
137
+ }
138
+
139
+ func closePicker() {
140
+ pickerOpen = false
141
+ modelQuery = ""
142
+ }
143
+
144
+ func movePicker(_ delta: Int) {
145
+ let count = chooserRows.count
146
+ guard count > 0 else { return }
147
+ pickerIndex = min(max(pickerIndex + delta, 0), count - 1)
148
+ }
149
+
150
+ /// The pick is remembered (global, D2): the last one and five recent.
151
+ func choose(_ row: ModelRow) {
152
+ pickTouched = true
153
+ newSelected = true
154
+ selected = []
155
+ if row.kind == .standard {
156
+ pickedModel = nil
157
+ UserDefaults.standard.removeObject(forKey: ModelPick.pickKey)
158
+ } else {
159
+ pickedModel = row.name
160
+ UserDefaults.standard.set(row.name, forKey: ModelPick.pickKey)
161
+ recentModels = ModelPick.pushRecent(recentModels, row.name)
162
+ UserDefaults.standard.set(recentModels, forKey: ModelPick.recentKey)
163
+ }
164
+ closePicker()
165
+ }
166
+
167
+ /// The remembered pick against the list as it is now (ModelPick.keep);
168
+ /// one that isn't offered any more is forgotten, with a note.
169
+ func applyStoredPick() {
170
+ guard !pickTouched else { return }
171
+ let kept = ModelPick.keep(UserDefaults.standard.string(forKey: ModelPick.pickKey), in: modelList)
172
+ pickedModel = kept.pick
173
+ if let note = kept.note {
174
+ UserDefaults.standard.removeObject(forKey: ModelPick.pickKey)
175
+ if message.isEmpty { message = note }
176
+ }
177
+ }
178
+
82
179
  /// The last choice, where still live or listed; else the only live
83
180
  /// session; else, with none live and no kitty window chosen, the new row.
84
181
  /// A recent (stopped) one is never preselected: a message would wake it.
@@ -152,6 +249,7 @@ struct PanelView: View {
152
249
  @ObservedObject var model: PanelModel
153
250
  let send: (SendKind) -> Void
154
251
  @FocusState private var promptFocused: Bool
252
+ @FocusState private var filterFocused: Bool
155
253
 
156
254
  var body: some View {
157
255
  VStack(spacing: 0) {
@@ -212,27 +310,32 @@ struct PanelView: View {
212
310
 
213
311
  @ViewBuilder var sessionList: some View {
214
312
  VStack(alignment: .leading, spacing: 2) {
215
- switch model.phase {
216
- case .loading:
217
- HStack(spacing: 8) {
218
- ProgressView().controlSize(.small)
219
- Text("Looking for live sessions…").foregroundColor(.secondary)
220
- }.padding(10)
221
- default:
313
+ if model.pickerOpen {
222
314
  newRow
223
- let live = model.live.count, kitty = model.kittyWindows.count
224
- ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
225
- if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
226
- if index == live + kitty && !model.recent.isEmpty {
227
- if kitty == 0 { kittyNoteLine }
228
- groupHeader("recent", first: index == 0)
229
- }
230
- switch target {
231
- case .chi(let session): row(session, index: index)
232
- case .kitty(let window): kittyRow(window, index: index)
315
+ modelChooser
316
+ } else {
317
+ switch model.phase {
318
+ case .loading:
319
+ HStack(spacing: 8) {
320
+ ProgressView().controlSize(.small)
321
+ Text("Looking for live sessions…").foregroundColor(.secondary)
322
+ }.padding(10)
323
+ default:
324
+ newRow
325
+ let live = model.live.count, kitty = model.kittyWindows.count
326
+ ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
327
+ if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
328
+ if index == live + kitty && !model.recent.isEmpty {
329
+ if kitty == 0 { kittyNoteLine }
330
+ groupHeader("recent", first: index == 0)
331
+ }
332
+ switch target {
333
+ case .chi(let session): row(session, index: index)
334
+ case .kitty(let window): kittyRow(window, index: index)
335
+ }
233
336
  }
337
+ if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
234
338
  }
235
- if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
236
339
  }
237
340
  }
238
341
  .padding(6)
@@ -286,30 +389,107 @@ struct PanelView: View {
286
389
 
287
390
  var newRow: some View {
288
391
  let on = model.newSelected
289
- return Button(action: { model.toggleNew() }) {
290
- HStack(spacing: 10) {
291
- Image(systemName: on ? "plus.circle.fill" : "plus.circle")
292
- .font(.system(size: 16))
293
- .foregroundColor(on ? .accentColor : .secondary)
294
- VStack(alignment: .leading, spacing: 1) {
295
- Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
296
- Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
392
+ return HStack(spacing: 10) {
393
+ Button(action: { model.toggleNew() }) {
394
+ HStack(spacing: 10) {
395
+ Image(systemName: on ? "plus.circle.fill" : "plus.circle")
396
+ .font(.system(size: 16))
397
+ .foregroundColor(on ? .accentColor : .secondary)
398
+ VStack(alignment: .leading, spacing: 1) {
399
+ Text("New session in \(model.newSessionFolder)").font(.system(size: 13, weight: .medium)).lineLimit(1)
400
+ Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
401
+ }
402
+ Spacer(minLength: 0)
297
403
  }
298
- Spacer()
299
- if let name = model.newModel {
300
- Text(name).font(.system(size: 11)).foregroundColor(.secondary)
301
- .lineLimit(1).truncationMode(.middle)
302
- .frame(maxWidth: 240, alignment: .trailing)
303
- .help("the model a new session starts on: default.model in config.yml")
404
+ .contentShape(Rectangle())
405
+ }
406
+ .buttonStyle(.plain)
407
+ .help("start a session with the message, as the web does (chi send --new); a note needs a session")
408
+ modelLabel
409
+ Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
410
+ }
411
+ .padding(.horizontal, 8).padding(.vertical, 6)
412
+ .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
413
+ }
414
+
415
+ /// The new session's model: grey for the default, accent for a pick; a
416
+ /// click (or ⌘M) opens the chooser.
417
+ var modelLabel: some View {
418
+ let picked = model.modelForSend != nil
419
+ return Button(action: { model.pickerOpen ? model.closePicker() : model.openPicker() }) {
420
+ HStack(spacing: 3) {
421
+ Text(model.shownModel ?? "model").font(.system(size: 11))
422
+ .foregroundColor(picked ? .accentColor : .secondary)
423
+ .lineLimit(1).truncationMode(.middle)
424
+ Image(systemName: "chevron.up.chevron.down").font(.system(size: 8)).foregroundColor(.secondary)
425
+ }
426
+ .frame(maxWidth: 240, alignment: .trailing)
427
+ .contentShape(Rectangle())
428
+ }
429
+ .buttonStyle(.plain)
430
+ .help(picked ? "the new session's model (chi send --new --model); ⌘M to change"
431
+ : "the model a new session starts on: default.model in config.yml; ⌘M to choose another")
432
+ }
433
+
434
+ /// A filter and up to nine rows: ↑/↓ and ⏎, or ⌘1…⌘9, pick; Esc closes.
435
+ @ViewBuilder var modelChooser: some View {
436
+ let rows = model.chooserRows
437
+ VStack(alignment: .leading, spacing: 2) {
438
+ HStack(spacing: 6) {
439
+ Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundColor(.secondary)
440
+ TextField("Search the models, or type a name…", text: $model.modelQuery)
441
+ .textFieldStyle(.plain)
442
+ .font(.system(size: 13))
443
+ .focused($filterFocused)
444
+ if model.modelsLoading { ProgressView().controlSize(.small) }
445
+ }
446
+ .padding(.horizontal, 8).padding(.vertical, 5)
447
+ .background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.06)))
448
+ .padding(.horizontal, 4).padding(.vertical, 3)
449
+
450
+ ForEach(Array(rows.enumerated()), id: \.offset) { index, row in
451
+ modelRow(row, index: index)
452
+ }
453
+ if model.modelList == nil && model.modelsLoading {
454
+ Text("Loading models…").font(.system(size: 11)).foregroundColor(.secondary)
455
+ .padding(.horizontal, 8).padding(.vertical, 4)
456
+ }
457
+ if let note = model.modelsNote {
458
+ HStack(spacing: 6) {
459
+ Image(systemName: "exclamationmark.triangle").font(.system(size: 10))
460
+ Text(note).font(.system(size: 11)).lineLimit(2)
304
461
  }
305
- Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
462
+ .foregroundColor(.secondary)
463
+ .help(note)
464
+ .padding(.horizontal, 8).padding(.vertical, 4)
306
465
  }
307
- .padding(.horizontal, 8).padding(.vertical, 6)
308
- .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
466
+ }
467
+ .onAppear { DispatchQueue.main.async { filterFocused = true } }
468
+ .onDisappear { DispatchQueue.main.async { promptFocused = true } }
469
+ }
470
+
471
+ func modelRow(_ row: ModelRow, index: Int) -> some View {
472
+ let current = model.isCurrent(row)
473
+ let highlighted = index == model.pickerIndex
474
+ return Button(action: { model.choose(row) }) {
475
+ HStack(spacing: 10) {
476
+ Image(systemName: current ? "checkmark" : "cpu")
477
+ .font(.system(size: 11))
478
+ .foregroundColor(current ? .accentColor : .secondary.opacity(0.6))
479
+ .frame(width: 16)
480
+ Text(row.label)
481
+ .font(.system(size: 13, weight: row.kind == .standard ? .medium : .regular))
482
+ .foregroundColor(row.kind == .typed ? .secondary : .primary)
483
+ .lineLimit(1).truncationMode(.middle)
484
+ Spacer(minLength: 8)
485
+ Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
486
+ }
487
+ .padding(.horizontal, 8).padding(.vertical, 5)
488
+ .background(RoundedRectangle(cornerRadius: 7).fill(highlighted ? Color.accentColor.opacity(0.14) : Color.clear))
309
489
  .contentShape(Rectangle())
310
490
  }
311
491
  .buttonStyle(.plain)
312
- .help("start a session with the message, as the web does (chi send --new); a note needs a session")
492
+ .help(row.kind == .typed ? "not in any host's list: sent as typed" : row.name)
313
493
  }
314
494
 
315
495
  func row(_ session: LiveSession, index: Int) -> some View {
@@ -434,6 +614,10 @@ final class KeyPanel: NSPanel {
434
614
  var onKey: ((NSEvent) -> Bool)?
435
615
  /// Return with these modifiers; true when handled.
436
616
  var onReturn: ((NSEvent.ModifierFlags) -> Bool)?
617
+ /// ↑ (-1) or ↓ (+1) without modifiers; true when handled.
618
+ var onArrow: ((Int) -> Bool)?
619
+ /// Esc; true when handled (the panel stays open).
620
+ var onCancel: (() -> Bool)?
437
621
 
438
622
  /// Return is taken here, before the text views see it, so ⏎ sends from
439
623
  /// either field. An input method composing text keeps its Return.
@@ -443,12 +627,28 @@ final class KeyPanel: NSPanel {
443
627
  onReturn?(event.modifierFlags.intersection([.shift, .command, .option, .control])) == true {
444
628
  return
445
629
  }
630
+ // An input method's candidate list keeps its arrows too.
631
+ if event.type == .keyDown, event.keyCode == 125 || event.keyCode == 126,
632
+ event.modifierFlags.intersection([.shift, .command, .option, .control]).isEmpty,
633
+ (firstResponder as? NSTextView)?.hasMarkedText() != true,
634
+ onArrow?(event.keyCode == 125 ? 1 : -1) == true {
635
+ return
636
+ }
637
+ // Esc closes an open chooser before a text view sees it (it could take
638
+ // it as completion); otherwise cancelOperation closes the panel.
639
+ if event.type == .keyDown, event.keyCode == 53,
640
+ (firstResponder as? NSTextView)?.hasMarkedText() != true, onCancel?() == true {
641
+ return
642
+ }
446
643
  super.sendEvent(event)
447
644
  }
448
645
 
449
646
  override var canBecomeKey: Bool { true }
450
647
  override var canBecomeMain: Bool { false }
451
- override func cancelOperation(_ sender: Any?) { close() }
648
+ override func cancelOperation(_ sender: Any?) {
649
+ if onCancel?() == true { return }
650
+ close()
651
+ }
452
652
 
453
653
  override func performKeyEquivalent(with event: NSEvent) -> Bool {
454
654
  if onKey?(event) == true { return true }
@@ -499,10 +699,27 @@ final class PanelController: NSObject, NSWindowDelegate {
499
699
  panel.contentView = effect
500
700
  panel.onKey = { [weak self] event in self?.handleKey(event) ?? false }
501
701
  panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
702
+ panel.onArrow = { [weak self] delta in
703
+ guard let self, self.model.pickerOpen else { return false }
704
+ self.model.movePicker(delta)
705
+ return true
706
+ }
707
+ panel.onCancel = { [weak self] in
708
+ guard let self, self.model.pickerOpen else { return false }
709
+ self.model.closePicker()
710
+ return true
711
+ }
502
712
  }
503
713
 
504
714
  /// ⏎ message, ⌘⏎ note, ⌥⏎ paste, ⇧⏎ a newline in the focused field.
715
+ /// With the chooser open, ⏎ picks the highlighted row (other Returns do
716
+ /// nothing: no send from inside the chooser).
505
717
  private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
718
+ if model.pickerOpen {
719
+ let rows = model.chooserRows
720
+ if flags.isEmpty, rows.indices.contains(model.pickerIndex) { model.choose(rows[model.pickerIndex]) }
721
+ return true
722
+ }
506
723
  switch flags {
507
724
  case []: send(.message)
508
725
  case .command: send(.note)
@@ -514,11 +731,23 @@ final class PanelController: NSObject, NSWindowDelegate {
514
731
  return true
515
732
  }
516
733
 
517
- /// ⌘1…⌘9 toggle a target, ⌘0 the new row.
734
+ /// ⌘1…⌘9 toggle a target, ⌘0 the new row; ⌘M opens or closes the model
735
+ /// chooser, where ⌘1…⌘9 pick a row.
518
736
  private func handleKey(_ event: NSEvent) -> Bool {
737
+ let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command
738
+ // By key code: ⌘M in any keyboard layout (a Cyrillic one types "ь").
739
+ if command, event.keyCode == 46 {
740
+ if model.pickerOpen { model.closePicker() } else { model.openPicker() }
741
+ return true
742
+ }
743
+ if model.pickerOpen {
744
+ let rows = model.chooserRows
745
+ guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars), n >= 1, n <= rows.count else { return false }
746
+ model.choose(rows[n - 1])
747
+ return true
748
+ }
519
749
  let targets = model.targets
520
- guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
521
- let chars = event.charactersIgnoringModifiers, let n = Int(chars),
750
+ guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars),
522
751
  n <= min(targets.count, 9) else { return false }
523
752
  if n == 0 { model.toggleNew() } else { model.toggle(targets[n - 1].id) }
524
753
  return true
@@ -538,12 +767,17 @@ final class PanelController: NSObject, NSWindowDelegate {
538
767
  model.newSelected = false
539
768
  model.selected = []
540
769
  model.touched = false
770
+ model.pickerOpen = false
771
+ model.modelQuery = ""
772
+ model.pickTouched = false
773
+ model.applyStoredPick()
541
774
  model.phase = .loading
542
775
  placeOnActiveScreen()
543
776
  panel.makeKeyAndOrderFront(nil)
544
777
  loadSessions()
545
778
  loadKitty()
546
779
  loadModel()
780
+ loadModels()
547
781
  }
548
782
 
549
783
  private func placeOnActiveScreen() {
@@ -561,7 +795,8 @@ final class PanelController: NSObject, NSWindowDelegate {
561
795
  switch result {
562
796
  case .success(let sessions):
563
797
  self.model.sessions = sessions
564
- self.model.preselect()
798
+ // A choice made while it listed (⌘0, ⌘M) stays.
799
+ if !self.model.touched { self.model.preselect() }
565
800
  self.model.phase = .ready
566
801
  case .failure(let error):
567
802
  self.model.sessions = []
@@ -598,6 +833,28 @@ final class PanelController: NSObject, NSWindowDelegate {
598
833
  runner.defaultModel { [weak self] model in self?.model.newModel = model }
599
834
  }
600
835
 
836
+ private var modelsGeneration = 0
837
+
838
+ /// The chooser's list, refreshed on each open; the last one serves
839
+ /// meanwhile, and a failed refresh keeps it.
840
+ private func loadModels() {
841
+ modelsGeneration += 1
842
+ let generation = modelsGeneration
843
+ model.modelsLoading = true
844
+ runner.models { [weak self] result in
845
+ guard let self, generation == self.modelsGeneration else { return }
846
+ self.model.modelsLoading = false
847
+ switch result {
848
+ case .success(let list):
849
+ self.model.modelList = list
850
+ self.model.modelsError = nil
851
+ case .failure(let error):
852
+ self.model.modelsError = error.description
853
+ }
854
+ self.model.applyStoredPick()
855
+ }
856
+ }
857
+
601
858
  /// Fans out: one `chi send` / `chi note` for the chi sessions (or the new
602
859
  /// row) and one paste per kitty window, with one combined line when all
603
860
  /// finished. Images the helper wrote are copied for kitty first (chi's
@@ -693,6 +950,8 @@ final class PanelController: NSObject, NSWindowDelegate {
693
950
  // stdin is the quoted context; without -m, chi takes it as the message.
694
951
  command = "chi send"
695
952
  args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
953
+ // The row's name verbatim; none for the default.
954
+ if new, let name = model.modelForSend { args += ["--model", name] }
696
955
  if !prompt.isEmpty { args += ["-m", prompt] }
697
956
  args += images.flatMap { ["--image", $0.url.path] }
698
957
  stdin = model.hasContext ? Data(model.text.utf8) : nil
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "log"
4
5
  require_relative "turn_note"
5
6
 
6
7
  module Samagotchi
7
8
  # Asking the model again, in the same turn, when a generation ends with no
8
9
  # visible text and no tool calls (thinking only, or nothing): a hidden
9
- # nudge (TurnNote.empty_retry), then another generation. Both loops use
10
- # it; the Engine's TurnNote.empty is what's left when the retries run out.
11
- module EmptyAnswerRetry
10
+ # nudge (TurnNote.empty_retry), then another generation. A generation a
11
+ # plugin cut (stop_generation) spends the same budget with its own nudge.
12
+ # Both loops keep one per turn; the Engine's TurnNote.empty is what's left
13
+ # when the retries run out.
14
+ class EmptyAnswerRetry
12
15
  MAX = 3
13
16
  # Greedy decoding on a near-identical prompt would likely loop again:
14
17
  # the retry runs at this temperature unless one is configured.
@@ -17,10 +20,8 @@ module Samagotchi
17
20
  # is retried unless the context is this full.
18
21
  CONTEXT_FULL_RATIO = 0.9
19
22
 
20
- module_function
21
-
22
23
  # retry.empty_answer, 0..MAX.
23
- def limit
24
+ def self.limit
24
25
  Config.get("retry.empty_answer").to_i.clamp(0, MAX)
25
26
  rescue StandardError
26
27
  1
@@ -28,16 +29,74 @@ module Samagotchi
28
29
 
29
30
  # The request options of a retry generation: the turn's sampling, with
30
31
  # TEMPERATURE when it sets none (a configured nil keeps "don't send").
31
- def sampling(base)
32
+ def self.sampling(base)
32
33
  base = base || {}
33
34
  base.key?(:temperature) ? base : base.merge(temperature: TEMPERATURE)
34
35
  end
35
36
 
36
37
  # The prompt filled the window (a length stop is then no loop to break).
37
- def context_full?(used_tokens, window_tokens)
38
+ def self.context_full?(used_tokens, window_tokens)
38
39
  return false unless used_tokens.to_i.positive? && window_tokens.to_i.positive?
39
40
 
40
41
  used_tokens.to_f / window_tokens >= CONTEXT_FULL_RATIO
41
42
  end
43
+
44
+ attr_reader :limit
45
+
46
+ def initialize(limit: self.class.limit)
47
+ @limit = limit
48
+ @attempts = 0
49
+ @armed = false
50
+ end
51
+
52
+ # An attempt is left.
53
+ def left? = @attempts < @limit
54
+
55
+ # A nudge went out this turn.
56
+ def used? = @attempts.positive?
57
+
58
+ # An empty answer is asked again: an attempt is left, the turn wasn't
59
+ # cancelled, and the answer wasn't cut short by a full context (a
60
+ # length stop while thinking is retried: a thinking loop cut by the
61
+ # output cap, not a full window).
62
+ def retry_empty?(iteration:, cancelled:, finish_reason: nil, used_tokens: nil, window_tokens: nil)
63
+ return false if !left? || cancelled
64
+
65
+ if finish_reason.to_s == "length" && self.class.context_full?(used_tokens, window_tokens)
66
+ Log.info(:turn, "empty_answer_not_retried", iteration: iteration, why: "context full")
67
+ return false
68
+ end
69
+ true
70
+ end
71
+
72
+ # Spends an attempt: :empty_answer_retry (with +fields+) goes out, +note+
73
+ # goes on the tail, and the next request runs at the retry sampling.
74
+ # Returns true.
75
+ def nudge!(conversation, note, emit:, iteration:, **fields)
76
+ @attempts += 1
77
+ @armed = true
78
+ emit.call({ type: :empty_answer_retry, iteration: iteration, attempt: @attempts, of: @limit, **fields })
79
+ conversation << note
80
+ true
81
+ end
82
+
83
+ # Whether this request is the retry (once per nudge).
84
+ def take_sampling!
85
+ armed = @armed
86
+ @armed = false
87
+ armed
88
+ end
89
+
90
+ # This request's sampling: the retry's once after a nudge, else +base+.
91
+ def request_sampling(base)
92
+ take_sampling! ? self.class.sampling(base) : base
93
+ end
94
+
95
+ # The last spent nudge goes (a retry that failed too: the Engine's
96
+ # TurnNote.empty says it all).
97
+ def drop_nudge!(conversation)
98
+ index = conversation.rindex { |entry| TurnNote.retry_nudge?(entry) }
99
+ conversation.delete_at(index) if index
100
+ end
42
101
  end
43
102
  end