samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
data/docs/releasing.md ADDED
@@ -0,0 +1,135 @@
1
+ # Releasing
2
+
3
+ samagotchi is published to rubygems.org as the gem `samagotchi` (command `chi`)
4
+ by GitHub Actions when a `v*` tag is pushed. Nothing publishes from a laptop:
5
+ there is no API key anywhere, and bundler's `rake release` is removed. An agent
6
+ can run the whole release; the user approves the notes before the tag and the
7
+ `release` environment after it.
8
+
9
+ ## Versions
10
+
11
+ - **The gem and the system bundle share one version.** `lib/samagotchi/version.rb`
12
+ and `lib/samagotchi/bundles/system/manifest.yml` are bumped together (a spec
13
+ and `rake release:check` enforce it). The system bundle upgrades itself when
14
+ chi starts.
15
+ - **Every other shipped bundle** (btw, guardrails, known-names, loop-guard,
16
+ mcp) has its own semver in its `manifest.yml` and, when it needs a newer chi,
17
+ a `requires_chi:` line. They never upgrade by themselves: users run
18
+ `chi bundle upgrade NAME`. So:
19
+ - a change to a bundle's files bumps that bundle's `version:`
20
+ (`rake bundles:check` fails otherwise, once there's a tag to compare with);
21
+ - a bundle that uses something new in chi raises its `requires_chi` in the
22
+ same change; `release:bump` never touches `requires_chi`;
23
+ - the release notes list the `chi bundle upgrade NAME` steps for every
24
+ bundle whose version moved.
25
+ - Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
26
+ patch version (0.2.0 → 0.2.1) is fixes only.
27
+
28
+ ## CHANGELOG.md
29
+
30
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): user-facing lines
31
+ under `## [Unreleased]`, grouped as `### Added`, `### Changed`, `### Fixed`
32
+ (and `### Removed` / `### Security` when needed). Write them for someone who
33
+ uses chi, not for someone who reads the diff: what changed for them, in one
34
+ line each. A merge with a user-visible change adds its line; anything missed is
35
+ drafted at release time (`rake release:draft_changelog`). Internal refactors,
36
+ specs and docs-only changes don't get a line.
37
+
38
+ `rake release:bump[X.Y.Z]` turns `## [Unreleased]` into `## [X.Y.Z] - date`
39
+ under a fresh empty Unreleased, and updates the compare links at the bottom.
40
+ The release workflow uses that section, as printed by `rake release:notes[X.Y.Z]`,
41
+ as the GitHub release body.
42
+
43
+ ## Tasks
44
+
45
+ | Task | What it does |
46
+ | --- | --- |
47
+ | `rake bundles:sha` | Recomputes the sha256 lines of every shipped bundle manifest (`files:`, `hooks:`, `plugin:`). |
48
+ | `rake bundles:check` | Sha lines match; a bundle changed since the last `v*` tag has a higher version (skipped, with a note, before the first tag). |
49
+ | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
50
+ | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG. No commit. |
51
+ | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, `bundles:check`, rspec + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
52
+ | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
53
+
54
+ ## Runbook
55
+
56
+ The agent does each step and stops where the user has to say yes.
57
+
58
+ 1. **Start from main, up to date and green.** `git checkout main && git pull`;
59
+ CI on main is green.
60
+ 2. **Draft the notes.** `bundle exec rake release:draft_changelog`, then edit
61
+ `## [Unreleased]` in CHANGELOG.md into short user-facing lines. Add the
62
+ `chi bundle upgrade NAME` steps for bundles whose version moved since the
63
+ last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
64
+ Pick the version: fixes only → patch, anything else → minor.
65
+ 3. **The user approves the notes and the version.** Show them the section.
66
+ 4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`.
67
+ 5. **Check.** Commit first (the check wants a clean tree), then run it:
68
+ `git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
69
+ Fix anything it finds in new commits and run it again.
70
+ 6. **Push main.** `git push origin main`; wait for CI to go green.
71
+ 7. **Tag and push the tag.**
72
+ `git tag -a vX.Y.Z -m "samagotchi X.Y.Z" && git push origin vX.Y.Z`
73
+ (push the tag alone; never `--tags`, `--all` or `--mirror`).
74
+ 8. **The user approves the `release` environment** in the Actions run
75
+ (Review deployments → Approve).
76
+ 9. **Watch the run.** `gh run watch` (or `gh run list --workflow release.yml`).
77
+ It checks that the tag is `v` + VERSION and on main, builds the gem, pushes
78
+ it with a trusted-publishing token and creates the GitHub release with the
79
+ notes and the .gem attached.
80
+ 10. **Verify the published gem** in a clean GEM_HOME:
81
+ ```sh
82
+ tmp=$(mktemp -d)
83
+ GEM_HOME=$tmp GEM_PATH=$tmp gem install samagotchi -v X.Y.Z --no-document
84
+ HOME=$tmp/home XDG_CONFIG_HOME=$tmp/home/.config XDG_STATE_HOME=$tmp/home/.local/state \
85
+ GEM_HOME=$tmp GEM_PATH=$tmp $tmp/bin/chi --version # chi X.Y.Z
86
+ ```
87
+ and `chi self` the same way. Then tell the user; on their machine
88
+ `gem update samagotchi` (and `chi desktop upgrade` if they use the desktop
89
+ helper).
90
+
91
+ If the run fails before `gem push`, fix it on main, delete the tag
92
+ (`git push origin :refs/tags/vX.Y.Z && git tag -d vX.Y.Z`) and tag again. A
93
+ version that reached rubygems.org can never be pushed again: fix forward with
94
+ the next patch version.
95
+
96
+ ## One-time setup
97
+
98
+ Done once, before the first release, in one sitting (the pending publisher
99
+ expires):
100
+
101
+ 1. **rubygems.org**: an account with MFA on. Profile → Trusted Publishers →
102
+ *Create a pending trusted publisher* (the gem doesn't exist yet):
103
+ gem name `samagotchi`, repository owner `dm1try`, repository name
104
+ `samagotchi`, workflow filename `release.yml`, environment `release`;
105
+ leave *workflow repository* blank. **A pending publisher expires 12 hours
106
+ after it's created**: push the first tag within that window, or create it
107
+ again. After the first push it becomes the gem's trusted publisher for good.
108
+ 2. **GitHub, Settings → Environments → New environment `release`**: required
109
+ reviewer = the maintainer; deployment branches and tags → selected, the tag
110
+ pattern `v*`.
111
+ 3. **GitHub, Settings → Rules → Rulesets → New tag ruleset**: target `v*`,
112
+ restrict creations, updates and deletions to the maintainer (and the agent's
113
+ credentials if they push tags), block force pushes.
114
+
115
+ ## Yanking
116
+
117
+ A broken release is yanked, then fixed forward:
118
+
119
+ ```sh
120
+ gem yank samagotchi -v X.Y.Z # needs a rubygems.org login with MFA (an API key with the yank scope)
121
+ ```
122
+
123
+ Yanking hides the version from installs; it can't be pushed again. Mark the
124
+ GitHub release as such (`gh release edit vX.Y.Z --prerelease` or edit its
125
+ notes: "yanked: …"), add a `### Fixed` line under Unreleased, and release the
126
+ next patch version.
127
+
128
+ ## Known CI gaps
129
+
130
+ Some examples pass on macOS but fail on Linux CI. They carry `:ci_todo` and are
131
+ skipped when `CI` is set: `chi send`/`chi note` subprocess specs (a child
132
+ `ruby` without the bundle can't load nokogiri), `--all` on a peer list, the
133
+ desktop fields in `chi self` (Linux has no helper), and KernelLoop's
134
+ `send_note`. `spec/gem_contents_spec.rb` is skipped on Ruby 3.3 under CI
135
+ (`Zlib::BufError` from `Gem::Package.build`). Fix these, then drop the tags.
data/docs/sessions.md ADDED
@@ -0,0 +1,155 @@
1
+ # Sessions
2
+
3
+ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/sessions/<uuid>.json` (XDG-aware via `XDG_STATE_HOME`) plus a sidecar dir `<uuid>/` with `input/`/`notes/`/`output/`/`pid`/`bridge.json` (`Session.session_dir`). Besides the messages and turn state, the JSON keeps `used_memory_names` (what the session read), `preloaded_memory_names` (`--memory`) and `muted_memory_names` (`--mute`), the last two written before the worker starts so a respawn builds the same prompt, and `parent_id`, the session that delegated this one (see *Delegating*; `null` otherwise); files from before those keys read as empty lists and no parent.
4
+
5
+ **Retention (file-based, opt-out via env):**
6
+
7
+ | Env | Default | Purpose |
8
+ |-----|---------|---------|
9
+ | `SAMAGOTCHI_SESSION_RETENTION_DAYS` | `14` (`0`=forever) | Delete if `updated_at` older than N days |
10
+ | `SAMAGOTCHI_SESSION_MAX_COUNT` | `500` (`0`=uncapped) | Keep newest N, prune overflow |
11
+ | `SAMAGOTCHI_SESSION_KEEP_STATUS` | (none) | CSV of statuses never auto-pruned |
12
+ | `SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS` | `24` | Throttle lazy sweep |
13
+
14
+ A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
15
+
16
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered and no continue offer waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up. The session's status stays as it was, not `stopped`.
17
+
18
+ **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.empty_session?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session was empty, so it is discarded.` The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
19
+
20
+ **Attached by default:** plain `chi` and `chi --resume ID` run their session in a background worker and attach to it (`session.shared`, default `true`; env `SAMAGOTCHI_SESSION_SHARED`). The worker runs in the session's `working_directory`, so `!commands` and tools see the directory the session was started in. `session.shared: false` keeps the in-process REPL, `--no-shared` does for one run; a session the REPL has open can't be attached ("close it there first"). See [CLI: Sharing a session](cli.md#sharing-a-session).
21
+
22
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped, sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal and the web report when a command or a question dismiss gets a 404 (`BridgeClient.stale_worker_message`). The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s.
23
+
24
+ **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
25
+
26
+ **Lazy sweep:** automatic prune runs at most once per 24h on `GET /api/sessions` (Web). No background thread or cron. Manual prune is always available.
27
+
28
+ **CLI:**
29
+
30
+ ```sh
31
+ chi sessions list [--sort updated_at|created_at] [--order desc|asc] [--limit N] [--scope=all]
32
+ chi sessions list [--live] [--cwd PATH] [--limit N] [--format text|json|tsv] [--scope=all]
33
+ chi sessions stop ID
34
+ chi sessions delete [--force] ID... # for good; --force stops a live worker first
35
+ chi sessions prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
36
+ chi sessions clean [--dry-run] [--days N] # test sessions: all, or older than N days
37
+ ```
38
+
39
+ Examples:
40
+
41
+ ```sh
42
+ chi sessions list --sort updated_at --order desc --limit 20
43
+ chi sessions prune --dry-run --days 14 --keep 500
44
+ chi sessions prune --days 14 --keep 500 # actually delete
45
+ chi sessions clean --dry-run # every test session, whatever its age
46
+ chi sessions clean --dry-run --days 7 # test sessions older than 7 days
47
+ ```
48
+
49
+ `--dry-run` is the safe preview. Web has no prune endpoint; use the CLI.
50
+
51
+ `chi sessions list` shows each session's saved recap, its first sentence without the "The user was…" opening (as on the web cards), in place of its last prompt, cut to 60 characters; a session with no recap shows its last prompt. A delegated session's row ends with `↳ <parent's short id>` (plain and `--live`; `--format json` has `parent_id`); rows keep their `updated_at` order, so a parent may be on another page than its children.
52
+
53
+ **Projects:** a session belongs to the git project it was started in: the repository, whichever worktree or subfolder of it (the same project root memories use). `chi sessions list` (with `--live` and `--format` too) and `chi web` show the current folder's project; `--scope=all` shows every session, and so does a folder in no repo (`~`). `--cwd PATH` is a folder filter instead of the project. The project is stored with the session (`project_root` in its JSON, `project` in `--format json`), so a session keeps it after its worktree is deleted; sessions older than that are placed by their folder, and one whose folder is gone shows in `--scope=all` only. Retention, `--resume`/`--attach ID`, `chi send`/`chi note ID` and `chi note --all` (every live session) are not scoped. The agent's `list_sessions` lists its own project's sessions; `cwd: "/"` lists every one.
54
+
55
+ `--live`, `--cwd` and `--format` make `list` a picker for scripts (`SessionManager.session_summaries`): `--live` keeps the sessions a worker runs now (the owner lock, not the saved status; a session open in a plain REPL is left out), `--cwd PATH` those in PATH or below, and test runs are left out. `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, updated_at, live, busy, owner, recap}]` (`owner`: `"worker"`, `"tui"` for a plain REPL, which takes no notes or messages, or null; `recap`: the first sentence of the session's recap, or null), `--format tsv` one `id<TAB>desc` line per session, where `desc` is `<folder> · <last prompt>` cut to 60 characters (the text form of `--live`/`--cwd` shows `<folder> · <recap>` when there is one; tsv and json keep `desc`).
56
+
57
+ ## Context notes
58
+
59
+ A context note is text pushed into a session as background: not a prompt, and it starts no turn.
60
+
61
+ ```sh
62
+ chi note [--source NAME] [-m TEXT] (ID|PREFIX)... | --all
63
+ pbpaste | chi note --source slack 3f2a 8c1d
64
+ ```
65
+
66
+ - The text comes from `-m` or stdin (a terminal on stdin is a usage error, not a wait). It is stripped; an empty note or one over 16 KiB is refused, never cut. `--source` (default `cli`) names where it came from. `--all` is every live session.
67
+ - It lands in `<uuid>/notes/`, apart from `input/`, so nothing that runs turns sees it. A live worker adds it to the conversation within a few seconds, between turns: one sent during a turn waits for that turn to end. A session with no worker keeps it until a worker next starts (a prompt, `--attach`), which adds it before anything else. A session open in a plain REPL (`--no-shared`) refuses notes. `chi note` prints one line per session: queued, waits for the next start (with the queue count), or refused.
68
+ - In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
69
+ - The web shows it as a dim "note from …" block, live (`context_added`) and after a reload; the attached terminal as one dim `note from slack: <first line>` line, also when joining.
70
+ - Agents: `list_sessions` (other sessions of this project, newest first, up to 20; optional `cwd`, `"/"` for every project) and `send_note(session, text)`, which sends a note from this session. Neither starts a turn anywhere.
71
+
72
+ On macOS, `chi desktop install` does this with a native panel from the Services menu or a hotkey (see [Desktop helper](desktop.md)). A plain Automator Quick Action that sends the clipboard to the live sessions you pick works too (Automator: Quick Action, "Run Shell Script", shell `/bin/zsh`; Automator's PATH is minimal, so put your Ruby's bin dir on it and use the full path to `chi`):
73
+
74
+ ```sh
75
+ export PATH="$HOME/.local/share/mise/shims:$PATH" # wherever your ruby lives
76
+ chi="/full/path/to/chi" # `command -v chi` in your shell prints it
77
+ list=$("$chi" sessions list --live --scope=all --format tsv)
78
+ [ -z "$list" ] && { osascript -e 'display notification "No live chi sessions" with title "chi note"'; exit 0; }
79
+ picked=$(osascript - "$list" <<'OSA'
80
+ on run argv
81
+ set AppleScript's text item delimiters to linefeed
82
+ set choice to choose from list (paragraphs of item 1 of argv) with prompt "Send the clipboard to:" with multiple selections allowed
83
+ if choice is false then return ""
84
+ return choice as text
85
+ end run
86
+ OSA
87
+ )
88
+ [ -n "$picked" ] && pbpaste | "$chi" note --source clipboard $(print -r -- "$picked" | cut -f1)
89
+ ```
90
+
91
+ ## Notes a turn leaves for the model
92
+
93
+ A turn that ends without an answer leaves one line for the model to read on its next turn, as a tail
94
+ system message marked `kind: turn_note` (the UIs don't show it; they showed the end itself as it happened):
95
+
96
+ - failed before any answer (a dead host, retries run out, an auth error): `[SYSTEM: the previous turn failed
97
+ before any answer: network error after 2 attempts (host main: Errno::ECONNREFUSED). The message went back to
98
+ the user, who may send it again.]` in the REPL and attached mode, where the prompt is given back;
99
+ `… The user's last message was not answered.]` after `-p`, where the prompt stays in the session;
100
+ - cancelled (Ctrl-C, the web's stop): `[SYSTEM: the previous turn was cancelled (ctrl-c) after 12s; the answer
101
+ above ends where it was cut off.]`, or `…; no answer had been shown.]` when only thinking had streamed;
102
+ - ended with nothing visible: `[SYSTEM: the previous turn ended with no visible answer (thinking only, or
103
+ nothing). The user's last message is still unanswered.]`.
104
+
105
+ When the context window fills past 40% (then 60%, 80%), the native loop leaves a similar line (`kind: context`):
106
+ `[CONTEXT: about 55% of the context window is in use (estimated; bucket=40plus). context moderate — prefer targeted
107
+ and range reads over full-file dumps]`, once per rise; see [context telemetry](internals/context-telemetry.md).
108
+
109
+ Failed retries replace the note, they don't pile up. The note's text is escaped like user text (an error may
110
+ quote what a server sent). A cancelled continue turn and `!rollback` go back to before the turn, note included.
111
+ Without it, the model saw an unanswered user message and no reason: asked "did your last answer finish?" after a
112
+ cancel during its thinking, it said it had never been asked.
113
+
114
+ ## Sending a message
115
+
116
+ `chi send` is the other half of `chi note`: the text goes in as your message, the same as typing it in the attached terminal or the web composer, so a turn runs.
117
+
118
+ ```sh
119
+ chi send [-m TEXT] (ID|PREFIX)...
120
+ chi send -m "is this the same bug?" 3fa2 # a message
121
+ pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
122
+ pbpaste | chi send 3fa2 # the clipboard is the message
123
+ ```
124
+
125
+ - With both stdin and `-m`, stdin is context: each line becomes a `> ` quote (as web annotations quote a selection), then a blank line, then the message. With only one, it goes in as is. A terminal on stdin is ignored. Both empty is a usage error; over 16 KiB is refused, never cut.
126
+ - It goes through the same path as the web composer (`SessionManager.deliver_turn`): the worker's Bridge when it is up, so every attached UI shows it as your message (client id `cli:send`, shown like any user message); the input file when the worker is on its way out. During a running turn it is merged into that turn at the next step, like a message typed then.
127
+ - A session with no worker gets one started (like `--attach` or the web composer). A session open in a plain REPL (`--no-shared`) refuses it. Only sessions on this machine: Bridges listen on 127.0.0.1.
128
+ - Fire and forget: it returns once the message is queued and never prints the answer; that shows in whatever is attached. A guardrail "ask" waits for a UI to answer it, so with nothing attached the turn stalls there until one attaches (`chi --attach ID`).
129
+ - One line per session: `sent`, `sent (the running turn picks it up)`, `sent (started its worker)`, `refused: …` or `failed: …`. Exit 0 when all were sent, 1 when any was refused, failed or not found, 2 for a usage error. There is no `--all`.
130
+
131
+ The Automator action above works for messages too: swap its last line for `pbpaste | "$chi" send -m "what do you make of this?" $(print -r -- "$picked" | cut -f1)`.
132
+
133
+ ## Delegating
134
+
135
+ The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; nothing checks the host serves it, so an unknown one fails the child's first turn). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail like an `execute` result. Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (a `↳` chip on its card, `delegated by` in its info bar, listed right after its parent in the all-sessions view) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
136
+
137
+ - `delegate(task, model:, session:, wait:, timeout:)`: starts a child and, by default, waits for its reply (`wait: false` returns at once, so several children can run side by side; `timeout`, default 600 s, leaves a slow child running). With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one.
138
+ - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child. The reply comes back once; a later call waits for the next one.
139
+ - What a wait returns, `session: <id>` then `status:` and the text: `done` with the reply after a `---` line; `waiting_for_answer` as soon as the child opens a question or a guardrail approval (nothing is answered for it: attach or use the web, then wait again); `canceled` when the parent's own turn is canceled (the child keeps running; the next turn can wait again); `no_reply` when the child's turn ended with nothing to say (canceled, failed or empty: its session shows what happened); `error` (its worker crashed) or `stopped` (`chi sessions stop`) at once; `running` on timeout. A reply saved before the child's worker idle-exited is returned at once.
140
+ - Every child starts with the shipped `delegated` system memory preloaded (`preloaded_memory_names: ["system/delegated"]`; `--mute delegated` would hide it): put everything in the final reply, don't delegate further, don't write memories or change config, stay in the folder. Its system prompt also says `Delegated by session <parent id>`. The parent's own `--mute` list does not carry over.
141
+ - Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
142
+ - The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it; `GET /api/sessions` and `/api/sessions/:id` carry it, and `SessionManager.children_of` lists a parent's children.
143
+
144
+ **Ordering:**
145
+
146
+ - `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
147
+ - Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat; "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). The info bar copies `chi --attach <id>` for a terminal. The start page's model picker (bottom left of the composer, `host:model ▾`) chooses the model a new chat starts on: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id}], warning?}`; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
148
+ - The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
149
+ - Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
150
+
151
+ **Test-session hygiene:**
152
+
153
+ - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
154
+ - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
155
+ - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # A thread-safe bounded FIFO used to decouple the turn thread (which
8
+ # enqueues SSE events from inside `Engine#emit_event`) from the dedicated
9
+ # writer thread that serialises frames onto the socket.
10
+ #
11
+ # Enqueue (#push) is O(1) and never blocks the turn, even if the writer
12
+ # thread is blocked on a slow / hung client socket. When the buffer is
13
+ # full the oldest entry is dropped and an overflow marker is pushed in its
14
+ # place so the writer can surface a reset to the client rather than
15
+ # growing memory without bound.
16
+ class BoundedQueue
17
+ def initialize(capacity:)
18
+ @capacity = [1, capacity].max
19
+ @mutex = Mutex.new
20
+ @cv = ConditionVariable.new
21
+ @items = []
22
+ @overflow_dropped = false
23
+ end
24
+
25
+ # Enqueue an item. Non-blocking; drops the oldest entry on overflow and
26
+ # records that overflow happened.
27
+ def push(item)
28
+ @mutex.synchronize do
29
+ @items << item
30
+ @overflow_dropped = false if @items.size == 1
31
+ while @items.size > @capacity
32
+ @items.shift
33
+ @overflow_dropped = true
34
+ end
35
+ @cv.broadcast
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Pop the oldest item, waiting up to +timeout+ seconds (nil → block).
41
+ # Returns nil on timeout or when drained.
42
+ def pop(timeout)
43
+ @mutex.synchronize do
44
+ @cv.wait(@mutex, timeout) if timeout && @items.empty?
45
+
46
+ @items.shift
47
+ end
48
+ end
49
+
50
+ # @return [Boolean] true when an overflow has dropped entries since the
51
+ # last successful drain to empty.
52
+ def overflow_dropped?
53
+ @mutex.synchronize { @overflow_dropped }
54
+ end
55
+
56
+ # Clear the overflow flag after a consumer has handled it.
57
+ def clear_overflow!
58
+ @mutex.synchronize { @overflow_dropped = false }
59
+ end
60
+
61
+ def size
62
+ @mutex.synchronize { @items.size }
63
+ end
64
+
65
+ def empty?
66
+ @mutex.synchronize { @items.empty? }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Bridge
5
+ # The last cards (Engine#show_card) and hook notices (:hook_notice),
6
+ # for a UI that joins later: the Bridge's snapshot[:cards]. A
7
+ # persistent observer, like TurnAccumulator.
8
+ #
9
+ # A turn's own notice (no between_turns) is in_turn like a turn's card,
10
+ # with the step it came in: +iteration+ and +calls+, the calls of that
11
+ # iteration started before it (a before_tool_call hook's notice comes
12
+ # before its call's row, so the web puts it above row calls + 1).
13
+ #
14
+ # A card with an earlier card's id replaces it where it was. Each entry
15
+ # says where it belongs as the recap does: +turns_since+, the turns
16
+ # completed after it (after its own turn for a card shown during one),
17
+ # and +current+ for a card of the turn running now. Turns count from
18
+ # this worker's start; the ones before it are in no entry. A card that
19
+ # isn't the turn's but comes while one runs (an anytime command's, such
20
+ # as /btw) goes after that turn: its prompt is already in the history.
21
+ # A failed turn leaves no prompt, so its cards stay before the next one.
22
+ class CardStore
23
+ CAPACITY = 20
24
+ # A failed turn's prompt goes back to the composer: no turn in the
25
+ # conversation.
26
+ TURN_ENDS = %i[turn_completed turn_canceled].freeze
27
+
28
+ def initialize(capacity: CAPACITY)
29
+ @capacity = capacity
30
+ @mutex = Mutex.new
31
+ @entries = []
32
+ @turns_done = 0
33
+ @running = false
34
+ @iteration = nil
35
+ @calls = 0
36
+ end
37
+
38
+ def call(event)
39
+ @mutex.synchronize { fold(event) }
40
+ rescue StandardError
41
+ nil # never break the running turn
42
+ end
43
+
44
+ # @return [Array<Hash>] oldest first: the card's (or notice's) fields
45
+ # plus turns_since: and current:, and during: true for a card that
46
+ # came while the running turn runs but isn't that turn's (it goes
47
+ # after the running turn's prompt)
48
+ def list
49
+ @mutex.synchronize do
50
+ @entries.map do |entry|
51
+ turns = entry[:turns]
52
+ current = entry[:in_turn] && @running && turns == @turns_done
53
+ since = @turns_done - turns - (entry[:in_turn] && !current ? 1 : 0)
54
+ listed = entry.except(:turns, :during).merge(turns_since: [since, 0].max, current: current ? true : false)
55
+ listed[:during] = true if entry[:during]
56
+ listed
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def fold(event)
64
+ case event[:type]
65
+ when :turn_started
66
+ @running = true
67
+ @iteration = nil
68
+ @calls = 0
69
+ when :generation_started
70
+ @iteration = event[:iteration]
71
+ @calls = 0
72
+ when :tool_call_started
73
+ @iteration = event[:iteration]
74
+ @calls = event[:call_index].to_i
75
+ when :turn_failed
76
+ @running = false
77
+ settle_during(after_turn: false)
78
+ when *TURN_ENDS
79
+ @running = false
80
+ @turns_done += 1
81
+ settle_during(after_turn: true)
82
+ when :card then add_card(event)
83
+ when :hook_notice then add_notice(event)
84
+ end
85
+ end
86
+
87
+ def add_card(event)
88
+ card = event.slice(:type, :id, :source, :title, :body, :level, :actions)
89
+ index = @entries.index { |entry| entry[:type] == :card && entry[:id] == card[:id] }
90
+ if index
91
+ # Replaced where it was: the first one's place stays.
92
+ old = @entries[index]
93
+ @entries[index] = card.merge(updated: true, **old.slice(:in_turn, :turns, :during))
94
+ else
95
+ entry = card.merge(in_turn: event[:in_turn] ? true : false, turns: @turns_done)
96
+ entry[:during] = true if @running && !event[:in_turn]
97
+ push(entry)
98
+ end
99
+ end
100
+
101
+ # The turn a :during card came in has ended: it counts as after that
102
+ # turn when the turn is in the history.
103
+ def settle_during(after_turn:)
104
+ @entries.each do |entry|
105
+ next unless entry.delete(:during)
106
+
107
+ entry[:turns] = @turns_done if after_turn
108
+ end
109
+ end
110
+
111
+ def add_notice(event)
112
+ notice = event.slice(:type, :hook, :text, :level)
113
+ if event[:between_turns] || !@running
114
+ push(notice.merge(in_turn: false, turns: @turns_done))
115
+ else
116
+ push(notice.merge(in_turn: true, turns: @turns_done, iteration: @iteration, calls: @calls).compact)
117
+ end
118
+ end
119
+
120
+ def push(entry)
121
+ @entries << entry
122
+ @entries.shift while @entries.size > @capacity
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class Bridge
5
+ # The SSE id of an event: `<seq>-<epoch>`, the seq first so that a reader
6
+ # taking it as a number (`to_i`, `parseInt`) still gets the seq.
7
+ module EventId
8
+ PATTERN = /\A(\d+)(?:-([0-9A-Za-z]+))?\z/
9
+
10
+ module_function
11
+
12
+ # @return [String] "<seq>-<epoch>", or the bare seq without an epoch
13
+ def format(seq, epoch)
14
+ epoch ? "#{seq}-#{epoch}" : seq.to_s
15
+ end
16
+
17
+ # @return [Array(Integer, String|nil)] the seq and the epoch (nil for a
18
+ # plain seq). Anything else reads with `to_i`, as before.
19
+ def parse(cursor)
20
+ m = PATTERN.match(cursor.to_s.strip)
21
+ m ? [m[1].to_i, m[2]] : [cursor.to_s.to_i, nil]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Samagotchi
6
+ class Bridge
7
+ # A bounded, thread-safe in-memory ring buffer of emitted events, keyed by
8
+ # the Engine's monotonic `event_seq`. One shared instance per bridge
9
+ # (owned by the capture observer) so that any client reconnecting from a
10
+ # given `Last-Event-ID` can replay the same window. Oldest entries are
11
+ # dropped past the capacity, which is what makes a too-old reconnect
12
+ # replay nothing and fall back to a reset marker.
13
+ #
14
+ # This is in-memory sequence (ordering + short reconnect), not durable
15
+ # cross-process resume — durable resume is a staged next step.
16
+ class RingBuffer
17
+ def initialize(capacity: 256)
18
+ @capacity = [1, capacity].max
19
+ @mutex = Monitor.new
20
+ @events = [] # Array<{seq:, data:}>
21
+ end
22
+
23
+ # Append +data+ keyed by +seq+ (drop oldest beyond capacity).
24
+ def push(seq:, data:)
25
+ @mutex.synchronize do
26
+ @events << { seq: seq, data: data }
27
+ @events.shift until @events.size <= @capacity
28
+ nil
29
+ end
30
+ end
31
+
32
+ # Buffered events with seq in the half-open range (after_seq, to_seq].
33
+ # Returns shallow copies so callers may serialise without holding the
34
+ # lock (or retaining the structure).
35
+ def events_in_range(after_seq:, to_seq:)
36
+ return [] if to_seq <= after_seq
37
+
38
+ @mutex.synchronize do
39
+ @events.select { |e| e[:seq] > after_seq && e[:seq] <= to_seq }.map { |e| e.dup }
40
+ end
41
+ end
42
+
43
+ # Whether any buffered event could satisfy a reconnect from +from_seq+.
44
+ def has_any_after?(from_seq:)
45
+ @mutex.synchronize { @events.any? { |e| e[:seq] > from_seq } }
46
+ end
47
+
48
+ # @return [Integer, nil] the highest seq buffered so far.
49
+ def last_seq
50
+ @mutex.synchronize { @events.last&.[](:seq) }
51
+ end
52
+
53
+ # @return [Integer, nil] the lowest seq buffered so far.
54
+ def oldest_seq
55
+ @mutex.synchronize { @events.first&.[](:seq) }
56
+ end
57
+
58
+ def empty?
59
+ @mutex.synchronize { @events.empty? }
60
+ end
61
+ end
62
+ end
63
+ end