samagotchi 0.95.0 → 0.97.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -1
  3. data/bin/chi +3 -0
  4. data/docs/architecture.md +2 -0
  5. data/docs/bundle-authoring.md +2 -1
  6. data/docs/cli.md +13 -1
  7. data/docs/configuration.md +13 -0
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +3 -1
  11. data/docs/memory.md +43 -3
  12. data/docs/plugins.md +4 -3
  13. data/docs/sessions.md +8 -2
  14. data/lib/samagotchi/bundle_command/check.rb +5 -3
  15. data/lib/samagotchi/bundle_command/try/run.rb +19 -4
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/children_status.rb +1 -1
  18. data/lib/samagotchi/engine.rb +49 -38
  19. data/lib/samagotchi/llm/errors.rb +100 -2
  20. data/lib/samagotchi/memory_bundle/profile.rb +14 -4
  21. data/lib/samagotchi/memory_command.rb +237 -0
  22. data/lib/samagotchi/plugin/api.rb +7 -3
  23. data/lib/samagotchi/plugin/sessions.rb +4 -3
  24. data/lib/samagotchi/send_command.rb +12 -12
  25. data/lib/samagotchi/session.rb +13 -1
  26. data/lib/samagotchi/session_commands.rb +13 -0
  27. data/lib/samagotchi/session_continue.rb +8 -8
  28. data/lib/samagotchi/session_delete_command.rb +1 -1
  29. data/lib/samagotchi/session_inbox.rb +29 -0
  30. data/lib/samagotchi/session_manager.rb +24 -7
  31. data/lib/samagotchi/sessions_command.rb +8 -6
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
  33. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  34. data/lib/samagotchi/terminal_ui.rb +1 -0
  35. data/lib/samagotchi/title_command.rb +61 -0
  36. data/lib/samagotchi/tools/memory.rb +1 -1
  37. data/lib/samagotchi/turn_checkpoint.rb +6 -0
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +4 -4
  40. data/lib/samagotchi/web/public/app.js +12 -0
  41. data/lib/samagotchi/web/public/chains.js +21 -8
  42. data/lib/samagotchi/web/public/format.js +4 -3
  43. data/lib/samagotchi/web/public/index.html +7 -0
  44. data/lib/samagotchi/web/public/info_bar_model.js +2 -0
  45. data/lib/samagotchi/web/public/session_rename.js +86 -0
  46. data/lib/samagotchi/web/public/session_state.js +4 -2
  47. data/lib/samagotchi/web/session_summary.rb +3 -0
  48. data/lib/samagotchi/worker.rb +24 -7
  49. data/lib/samagotchi/worker_idle_exit.rb +16 -8
  50. metadata +4 -1
@@ -0,0 +1,86 @@
1
+ // The info bar's ✎ (Rename this session): a small input in its place,
2
+ // prefilled with the session's title ("" for none). Enter sends
3
+ // `/title TEXT` (blank: `/title --clear`) through the chips' command path
4
+ // (composer.js run: the reply shows as a command bubble, a refusal in the
5
+ // failed bubble, the composer untouched); leaving the field saves the same
6
+ // way (a phone's keyboard dismissed keeps what was typed). Esc, or opening
7
+ // another session, closes it unsent. The cards follow from the saved file
8
+ // (the hub's scan), as for any title.
9
+ import { normalize } from "./format.js";
10
+
11
+ // As long as a title may be (TitleCommand::LIMIT).
12
+ const TITLE_CHARS = 80;
13
+
14
+ // The line the input's +text+ sends.
15
+ export function titleLine(text) {
16
+ const title = normalize(text);
17
+ return title ? `/title ${title}` : "/title --clear";
18
+ }
19
+
20
+ // @param button the ✎ button (it hides while the input shows)
21
+ // @param session () => the open session's id, or null
22
+ // @param titleOf (id) => that session's title, or null
23
+ // @param run (line) => composer.js run's promise
24
+ export function createRename({ button, session, titleOf, run }) {
25
+ let input = null;
26
+ let openFor = null;
27
+
28
+ function close() {
29
+ if (!input) return;
30
+ const el = input;
31
+ input = null;
32
+ openFor = null;
33
+ el.remove();
34
+ button.hidden = false;
35
+ }
36
+
37
+ // Enter's and blur's: once (closing it blurs it again), and only to the
38
+ // session it was opened for.
39
+ function submit() {
40
+ if (!input) return null;
41
+ const id = openFor;
42
+ const before = normalize(input.dataset.before);
43
+ const text = input.value;
44
+ close();
45
+ if (!id || session() !== id || normalize(text) === before) return null;
46
+ return run(titleLine(text));
47
+ }
48
+
49
+ function open() {
50
+ const id = session();
51
+ if (input || !id) return;
52
+ const el = document.createElement("input");
53
+ el.type = "text";
54
+ el.className = "rename-input";
55
+ el.maxLength = TITLE_CHARS;
56
+ el.placeholder = "Session title";
57
+ el.setAttribute("aria-label", "Session title (Enter saves, empty clears, Esc cancels)");
58
+ el.value = titleOf(id) || "";
59
+ el.dataset.before = el.value;
60
+ el.addEventListener("keydown", (e) => {
61
+ if (e.key === "Enter") {
62
+ e.preventDefault();
63
+ submit();
64
+ } else if (e.key === "Escape") {
65
+ e.preventDefault();
66
+ close();
67
+ }
68
+ });
69
+ el.addEventListener("blur", () => submit());
70
+ input = el;
71
+ openFor = id;
72
+ button.hidden = true;
73
+ button.after(el);
74
+ el.focus();
75
+ el.select();
76
+ }
77
+
78
+ // The info bar redrew for +id+ (app.js updateInfoBar): an input opened
79
+ // for another session closes.
80
+ function follow(id) {
81
+ if (input && openFor !== id) close();
82
+ }
83
+
84
+ button.addEventListener("click", () => open());
85
+ return { open, close, follow, isOpen: () => !!input };
86
+ }
@@ -118,10 +118,12 @@ export function sessionStateFrom(data) {
118
118
  return state;
119
119
  }
120
120
 
121
- // The info bar's title: the first user message when the payload has
122
- // messages, else the card's preview, else the first prompt of its history.
121
+ // The info bar's title: the session's own title (/title), else the first
122
+ // user message when the payload has messages, else the card's preview, else
123
+ // the first prompt of its history.
123
124
  function sessionFirstPreview(data) {
124
125
  const s = data.session;
126
+ if (s.title) return firstPreview(s.title);
125
127
  let preview = s.first_preview || previewForCard(s);
126
128
  if (data.messages && data.messages.length) {
127
129
  const first = data.messages.find((m) => m.role === "user");
@@ -67,6 +67,9 @@ module Samagotchi
67
67
  # chain), else nil: the lists draw chains from it.
68
68
  continues: session.continues,
69
69
  first_preview: first_preview_for(session),
70
+ # The session's own name (/title), else nil: the pages show it in
71
+ # place of the first message.
72
+ title: session.title,
70
73
  owner: owner&.kind,
71
74
  # The saved recap's first sentence, for the session card.
72
75
  recap: RecapStore.preview(session_dir),
@@ -237,6 +237,7 @@ module Samagotchi
237
237
  @config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :worker) do |message|
238
238
  @engine.announce(type: :hook_notice, hook: "config", text: message, level: :warn, between_turns: true)
239
239
  end
240
+ prune_tombstones
240
241
  settle_interrupted_turn
241
242
  # Before the Bridge serves anything: a UI joining a resumed worker's
242
243
  # stream gets the session's history and status in its snapshot, not
@@ -491,6 +492,15 @@ module Samagotchi
491
492
  save_or_log(:interrupted_turn) { save_session }
492
493
  end
493
494
 
495
+ # The per-input tombstones (removed, settled) older than
496
+ # SessionInbox::TOMBSTONE_MAX_AGE, once per worker start: they only go
497
+ # with the session dir otherwise.
498
+ def prune_tombstones
499
+ removed = SessionInbox.prune_removed(@session_dir)
500
+ settled = TurnCheckpoint.prune_settled(@session_dir)
501
+ Log.info(:worker, "tombstones_pruned", removed: removed, settled: settled) if (removed + settled).positive?
502
+ end
503
+
494
504
  # A saved parent_answers that isn't a level reads as hard on every
495
505
  # answer without a word; said once here, as the session loads.
496
506
  def announce_session_warnings
@@ -933,8 +943,11 @@ module Samagotchi
933
943
  # finishes) comes after its command_queued. The cards it shows go out at
934
944
  # once (btw's "thinking…" card), after its command_queued, which the
935
945
  # UIs draw its line at (Engine#running_anytime). Its handler reads
936
- # copies (ctx.messages) and shows things through ctx only; nothing is
937
- # saved.
946
+ # copies (ctx.messages) and shows things through ctx only. One saves:
947
+ # /title, a field no turn writes, through Session#save, which takes the
948
+ # session's lock and writes a running turn's checkpoint (as
949
+ # WorkerSignals' save off the turn thread does), so the turn's own saves
950
+ # keep it.
938
951
  def start_anytime_command(command)
939
952
  @engine.spawn_anytime do
940
953
  result = command_result(command) { @engine.running_anytime { @commands.run(command.line, confirm: confirm?(command)) } }
@@ -955,6 +968,8 @@ module Samagotchi
955
968
  event = { type: :command_ran, **command.event_fields, status: status, output: text[0, COMMAND_OUTPUT_LIMIT],
956
969
  changed: changed.map(&:to_s), model_name: @engine.effective_model_name }
957
970
  event[:anytime] = true if anytime
971
+ # The session's title after /title (nil: cleared), for the TUIs' row.
972
+ event[:title] = @engine.session&.title if changed.include?(:title)
958
973
  # Its line was shown at its command_queued (waits: turn_end).
959
974
  event[:queued] = true if command.waits_for_turn_end?
960
975
  # The chips the command moved (SessionSetting#changed_by): what the next
@@ -1256,12 +1271,14 @@ module Samagotchi
1256
1271
  # The Bridge serves before the idle-exit policy exists.
1257
1272
  return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
1258
1273
 
1259
- hold = restart ? @idle_exit.hold_for_restart : @idle_exit.hold_for_request(requester: client_id)
1260
- if hold == :background_tasks && !restart
1261
- # /exit names the tasks it waits for (D3).
1262
- return WorkerIdleExit::Hold.new(reason: hold, tasks: @task_wakes.watch.running, hint: TASK_STOP_HINT)
1274
+ if restart
1275
+ hold = @idle_exit.hold_for_restart
1276
+ return WorkerIdleExit::Hold.new(reason: hold) if hold
1277
+ else
1278
+ # /exit names the tasks it waits for (D3), from the one look that found them.
1279
+ hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
1280
+ return hold if hold
1263
1281
  end
1264
- return WorkerIdleExit::Hold.new(reason: hold) if hold
1265
1282
 
1266
1283
  @exit_requested = true
1267
1284
  @exit_requested_by = client_id
@@ -94,15 +94,23 @@ module Samagotchi
94
94
  # still be open, untagged)
95
95
  # @return [Symbol, nil] :turn_running, :input_queued, :continue_offered,
96
96
  # :client_connected, :reminders or :background_tasks
97
- def hold_for_request(requester:, streams: true)
98
- return :turn_running if @engine.turn_running?
99
- return :input_queued if @input_pending.call
100
- return :continue_offered if @awaiting_continue.call
101
- return :client_connected if streams && @bridge && @bridge.open_streams_except(requester).positive?
102
- return :reminders if @engine.reminder_store&.any?
103
- return :background_tasks if @watched_running.call.any?
97
+ def hold_for_request(requester:, streams: true) = request_hold(requester: requester, streams: streams)&.reason
104
98
 
105
- nil
99
+ # #hold_for_request as a Hold: for :background_tasks, the watched tasks
100
+ # still running, read once (TaskWatch#running looks at each task's
101
+ # process), with +hint+.
102
+ # @return [Hold, nil]
103
+ def request_hold(requester:, streams: true, hint: nil)
104
+ reason = if @engine.turn_running? then :turn_running
105
+ elsif @input_pending.call then :input_queued
106
+ elsif @awaiting_continue.call then :continue_offered
107
+ elsif streams && @bridge && @bridge.open_streams_except(requester).positive? then :client_connected
108
+ elsif @engine.reminder_store&.any? then :reminders
109
+ end
110
+ return Hold.new(reason: reason) if reason
111
+
112
+ running = @watched_running.call
113
+ running.any? ? Hold.new(reason: :background_tasks, tasks: running, hint: hint) : nil
106
114
  end
107
115
 
108
116
  # What keeps the worker from restarting now (a client asked: POST /exit
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.95.0
4
+ version: 0.97.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -437,6 +437,7 @@ files:
437
437
  - lib/samagotchi/memory_bundle/system_bundle.rb
438
438
  - lib/samagotchi/memory_bundle/trash.rb
439
439
  - lib/samagotchi/memory_bundle/uninstaller.rb
440
+ - lib/samagotchi/memory_command.rb
440
441
  - lib/samagotchi/memory_paths.rb
441
442
  - lib/samagotchi/message_parts.rb
442
443
  - lib/samagotchi/model_catalog.rb
@@ -582,6 +583,7 @@ files:
582
583
  - lib/samagotchi/thinking_tails.rb
583
584
  - lib/samagotchi/thinking_ticker.rb
584
585
  - lib/samagotchi/thought_stream_splitter.rb
586
+ - lib/samagotchi/title_command.rb
585
587
  - lib/samagotchi/token_usage.rb
586
588
  - lib/samagotchi/tool_activity.rb
587
589
  - lib/samagotchi/tool_call_parser.rb
@@ -717,6 +719,7 @@ files:
717
719
  - lib/samagotchi/web/public/send_turn.js
718
720
  - lib/samagotchi/web/public/sentences.js
719
721
  - lib/samagotchi/web/public/session_actions.js
722
+ - lib/samagotchi/web/public/session_rename.js
720
723
  - lib/samagotchi/web/public/session_state.js
721
724
  - lib/samagotchi/web/public/session_title.js
722
725
  - lib/samagotchi/web/public/sessions_list.js