samagotchi 0.3.0 → 0.5.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
data/docs/desktop.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Desktop helper (macOS)
2
2
 
3
- `chi desktop` installs **Chi Helper**, a small native app. It sends text you selected in any app to a chi session,
3
+ `chi desktop` installs **Chi Helper**, a small native app. It sends text you selected in any app (or a screenshot,
4
+ an image: see [Images](#images)) to a chi session,
4
5
  either with a question as [your message](sessions.md#sending-a-message) (a turn runs, and the answer shows in the
5
6
  attached terminal or web page), or as a [context note](sessions.md#context-notes) (the model sees it on its next
6
7
  turn, and no turn starts).
@@ -35,6 +36,32 @@ note needs a session. After the send the panel shows `started <id>…` for 3 s.
35
36
 
36
37
  A session open in a `chi --no-shared` REPL isn't listed: it takes no notes or messages.
37
38
 
39
+ ## Images
40
+
41
+ The panel also sends images, as attachments of the message (`chi send --image`, see
42
+ [Sending a message](sessions.md#sending-a-message)):
43
+
44
+ - **A screenshot:** ⌃⇧⌘4 (a region to the clipboard), then ⌃⌥⌘N. The panel shows it as a thumbnail, named
45
+ `clipboard.png`; the context box stays empty.
46
+ - **Finder:** right-click image files → Services → **Send to chi** (the same Service as for text, so its shortcut
47
+ works here too), or ⌘C on them and ⌃⌥⌘N. Up to 20 at once.
48
+ - **Other apps:** a picture selected in Preview, Safari's Copy Image, anything that puts image data on the clipboard.
49
+ - **Drop** image files or the floating screenshot thumbnail (⇧⌘4 to a file) anywhere on the open panel: they are
50
+ added to the ones there.
51
+
52
+ When the clipboard holds both text and a picture (cells copied in Numbers or Excel), the text wins, as before. The
53
+ thumbnails sit under the message line, 48 px high, the file name as a tooltip; hover one for its ✕.
54
+
55
+ - A message is required with images: the line says "Say something about the image…", and ⏎ does nothing until
56
+ there is text (the context box counts).
57
+ - Notes are text only: ⌘⏎ with images beeps and says "Notes are text only: ⏎ sends the image as a message".
58
+ - The session's model must see images. A text-only one fails the turn in the session (the attached terminal or the
59
+ web shows why); the panel has already said it was sent.
60
+ - A session busy with a turn runs the image message as its next turn: the line says `(runs after the current turn)`.
61
+ - Clipboard and dropped image data goes to temp files under `$TMPDIR/chi-helper`, deleted after the send or when
62
+ the panel closes; files from Finder are sent as they are, never touched. A send with images may take up to 30 s
63
+ (converting, a worker starting) before the panel gives up.
64
+
38
65
  ## Install
39
66
 
40
67
  ```sh
@@ -53,16 +80,22 @@ don't break it. From a checkout it runs **that** checkout's `bin/chi`; installin
53
80
  warning, because the helper stops working once that worktree is removed. After switching between a checkout and a
54
81
  gem install, run `chi desktop upgrade` from the one you now use.
55
82
 
83
+ `chi update` keeps it current: it rebuilds and restarts the helper only when its Swift sources changed since the
84
+ build (`launch.json` records their digest) or the Ruby it runs moved. A new chi that left the sources alone only
85
+ rewrites `launch.json`, which the app reads at each send, so the app keeps its older version number and that's fine.
86
+
56
87
  ## Commands
57
88
 
58
89
  | Command | Does |
59
90
  |---|---|
60
91
  | `chi desktop install [--force] [--login]` | builds, installs and starts it; `--force` replaces an existing copy |
61
- | `chi desktop upgrade` | rebuilds it for this chi and restarts it, keeping the login setting |
92
+ | `chi desktop upgrade` | rebuilds it for this chi and restarts it, keeping the login setting (always; `chi update` does it only when needed) |
62
93
  | `chi desktop uninstall` | quits it and removes the app, its login item, launch file and settings |
63
94
  | `chi desktop status` | version against chi's, how it runs chi, state dirs, Service, hotkey, process, login item |
64
95
 
65
- `chi self` has a `desktop` line: `0.1.x (matches)`, `0.1.w (chi is 0.1.x: chi desktop upgrade)` or `not installed`.
96
+ `chi self` has a `desktop` line: `0.1.x (matches)`, `0.1.w (up to date for chi 0.1.x)` (an older build whose sources
97
+ haven't changed), `0.1.w (chi is 0.1.x: chi update)` (a rebuild is due) or `not installed`. `chi desktop status`
98
+ says the same.
66
99
 
67
100
  ## How it runs chi
68
101
 
@@ -92,7 +125,7 @@ Each call is stopped after 10 s. A stopped `chi note` says the note may be partl
92
125
  ## Troubleshooting
93
126
 
94
127
  - **"chi not found at …, run `chi desktop upgrade`"**: the Ruby or checkout in `launch.json` moved (a Ruby upgrade,
95
- a removed worktree). Run `chi desktop upgrade` from the chi you use now.
128
+ a removed worktree). Run `chi desktop upgrade` (or `chi update`) from the chi you use now.
96
129
  - **No "Send to chi" in the Services menu:** check `chi desktop status` (service). Try
97
130
  `/System/Library/CoreServices/pbs -update`, start the app again, or log out and back in. It must be ticked in
98
131
  System Settings → Keyboard → Keyboard Shortcuts… → Services → Text.
@@ -100,4 +133,6 @@ Each call is stopped after 10 s. A stopped `chi note` says the note may be partl
100
133
  restarts or you log out: macOS caches Services.
101
134
  - **⌃⌥⌘N does nothing:** `status` says whether another app holds it. macOS doesn't report clashes with its own
102
135
  shortcuts.
136
+ - **"Send to chi" missing on images in Finder or Preview** after an upgrade: the Services cache still has the old
137
+ (text-only) entry; `/System/Library/CoreServices/pbs -update`, restart the helper, or log out and back in.
103
138
  - **"No live sessions":** start one with `chi` in a terminal; `chi sessions list --live --scope=all` shows the same list.
data/docs/guardrails.md CHANGED
@@ -38,6 +38,17 @@ once it closes. On a short terminal the list shrinks (the hint row, then the `in
38
38
  lines, then the header go, then the options fold onto fewer rows). Once answered, one
39
39
  line stays in the scrollback: `! execute: git push origin main → Allow once`.
40
40
 
41
+ An `edit` or `write` also shows the change it would make, computed without
42
+ touching the file: a `change: +3 −1` line in the question (`new file, 12 lines`;
43
+ `would fail: old text not found in …` when the edit can't apply), and the
44
+ unified diff itself. The web card shows the diff under the path (20 lines,
45
+ then "show all"); the terminals print it above the question, green and red,
46
+ 40 lines at most (the rest is on the web). Binary files and files over 1 MB
47
+ say so instead of a diff. After the call runs, its row shows what really
48
+ changed: `diff +3 −1` under the row on the web (closed, it survives a
49
+ reload) and ` +3 −1` at the end of the terminal's tool line. The model never
50
+ sees these diffs.
51
+
41
52
  Who answers:
42
53
 
43
54
  - REPL (`chi --no-shared`, `-p` without `--non-interactive`): at the `? ` prompt.
data/docs/hooks.md CHANGED
@@ -351,7 +351,7 @@ Notes:
351
351
  - Ordering: bundle hooks fire by `(priority, bundle_name, hook_name)` (lower priority first), then plain `config.yml` hooks in registration order.
352
352
  - Settings: a hook class with `initialize(settings = {})` gets the bundle's section of `config.yml` `bundles:` (see [Settings](#settings)).
353
353
  - A bundle can also ship a `plugin.rb` whose `chi.on(event)` blocks are bundle hooks too, next to commands and tools; see [Plugins](plugins.md).
354
- - Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)) and `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)).
354
+ - Shipped bundles: `chi bundle install guardrails` (rules, see [Guardrails](guardrails.md#the-guardrails-bundle)) and `chi bundle install known-names` (a hook, see [Guardrails](guardrails.md#the-known-names-bundle)), `chi bundle install source-links` (a hook: announces source refs, see [The source-links bundle](#the-source-links-bundle)), `chi bundle install btw` (a plugin: `/btw`, see [Plugins](plugins.md#the-btw-bundle)), `chi bundle install mcp` (a plugin: tools from MCP servers, see [Plugins](plugins.md#the-mcp-bundle)) `chi bundle install loop-guard` (a plugin: breaks tool-call loops, see [Plugins](plugins.md#the-loop-guard-bundle)), `chi bundle install check-in` (a plugin: checks on a long turn, see [Plugins](plugins.md#the-check-in-bundle)) and `chi bundle install skills` (a plugin: `/skill`, versions of skills, see [Plugins](plugins.md#the-skills-bundle)).
355
355
  - Installing a bundle executes its hook code at `Engine` startup. Only install bundles you trust, as you would a gem. Hooks are **not** executed at install time (copy-only); they are `module_eval`'d at `Engine.new` inside per-bundle `Samagotchi::Bundles::<name>` namespaces (no top-level `require` collisions). Keep hook files side-effect-free at load time; do work in `#call` — top-level side effects (require, IO, `at_exit`, global assignment) run once per `Engine.new` (class redefinition is idempotent).
356
356
 
357
357
  Lifecycle:
@@ -386,9 +386,11 @@ in a markdown link's label when the target names the same ref
386
386
  and `[fix for JIRA-123](https://github.com/o/r/pull/9)` still link. A ref
387
387
  glued to URL punctuation (`/browse/JIRA-1`, `?key=JIRA-1`, `JIRA-1/foo`) is
388
388
  skipped too; `Ticket:JIRA-5` and `#JIRA-123` are ordinary plain text and do
389
- link. Refs are deduped within the turn (case-insensitively) and listed in
390
- first-occurrence order, whatever order the sources are configured in. With no
391
- sources configured the hook is a silent no-op.
389
+ link. The line names each URL once (compared case-insensitively: `#12` and
390
+ `dm1try/samagotchi#12` may be one issue, and with `case_insensitive: true`
391
+ `JIRA-1` and `jira-1` are one ticket), in first-occurrence order, whatever
392
+ order the sources are configured in. With no sources configured the hook is a
393
+ silent no-op.
392
394
 
393
395
  ```yaml
394
396
  bundles:
@@ -417,9 +419,8 @@ keeps the web links.
417
419
 
418
420
  The `prefix:` form compiles to `\b<prefix>-(\d+)\b` and the URL is
419
421
  `base_url` + the full ref text (`JIRA-123`). The `pattern:` form takes a
420
- regex; `{match}` in `url` is replaced with the first capture group (or the
421
- full match when the pattern has none). `case_insensitive: true` adds the
422
- `/i` flag. Past `max` refs the line ends with `… +N more`.
422
+ regex and a `url:` template with placeholders (below). `case_insensitive:
423
+ true` adds the `/i` flag. Past `max` refs the line ends with `… +N more`.
423
424
 
424
425
  Each regex is compiled with a per-regex timeout (0.5 s, per match attempt),
425
426
  so a catastrophic pattern is abandoned instead of hanging the turn: that
@@ -428,3 +429,84 @@ and the others still report. An entry with neither `prefix:` nor `pattern:`,
428
429
  or a pattern that does not compile, is skipped with a warning at load. The
429
430
  hook is `on_error: log`: a bug in it warns and the turn is unaffected. As with
430
431
  every bundle hook, a running worker picks it up after its next start.
432
+
433
+ ### Placeholders, and the project's own repo
434
+
435
+ A `url:` template (the `pattern:` form only; `base_url:` is always
436
+ `base_url` + the ref) can use:
437
+
438
+ | placeholder | value | when it can't be filled |
439
+ |---|---|---|
440
+ | `{match}` | the first capture group, else the whole ref | never: it falls back to the ref |
441
+ | `{1}` … `{9}` | a numbered capture group | the group didn't take part: the ref is **not linked** |
442
+ | `{name}` | a named capture group `(?<name>…)` | the group didn't take part: **not linked** |
443
+ | `{repo}`, `{host}` | the named group `repo` / `host` when the pattern has one and it took part; else the project's git remote | neither: **not linked** |
444
+
445
+ A ref with a placeholder that can't be filled is left out of both the line
446
+ and the answer: no URL is built with a hole in it (another source on the same
447
+ ref can still link it). A `{word}` or `{N}` that is none of the above (not a
448
+ group of the pattern, or `{7}` in a two-group pattern) stays as literal text,
449
+ with one warning when the source is loaded. Every value is percent-encoded
450
+ (everything outside `A-Za-z0-9-._~`, `/` included), except that `{repo}`
451
+ keeps its `/` between segments (GitLab's `group/sub/proj`); a `{repo}` with an
452
+ empty, `.` or `..` segment counts as unfilled.
453
+
454
+ So one source links both `#12` in this project and a cross-repo ref:
455
+
456
+ ```yaml
457
+ bundles:
458
+ source-links:
459
+ sources:
460
+ - name: GitHub
461
+ # `#12` → this project's repo; `owner/repo#12` → that repo
462
+ pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
463
+ url: 'https://github.com/{repo}/issues/{num}'
464
+ remote_host: github.com
465
+ ```
466
+
467
+ ```
468
+ sources: GitHub #12 → https://github.com/dm1try/samagotchi/issues/12, GitHub rails/rails#5 → https://github.com/rails/rails/issues/5
469
+ ```
470
+
471
+ GitHub redirects `/issues/N` to `/pull/N` and back, so one URL covers issues
472
+ and pull requests. The lookbehind keeps `&#123;`, `x/#1` and a partial
473
+ `b/c#1` inside `a/b/c#1` out; code and URLs are skipped as always. `PR #12`
474
+ links, `PR#12` doesn't (a `#` right after a letter). The pattern is loose on
475
+ purpose: a bare `#\d+` also matches "step #2", and `and/or#5` or `TCP/IP#3`
476
+ read as qualified refs. A stricter variant wants `PR #`, `issue #` or a
477
+ qualified ref:
478
+
479
+ ```yaml
480
+ pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w)#|\b(?:PR|[Ii]ssue) #)(?<num>\d+)\b'
481
+ ```
482
+
483
+ **Where `{repo}` and `{host}` come from.** Without a named group that took
484
+ part, they come from `git remote get-url <remote>` run in the session's
485
+ working directory (the worker's; the in-process REPL's is the terminal's
486
+ current directory). `remote:` picks the remote, default `origin` — a fork
487
+ sets `remote: upstream`. Git applies `insteadOf` rewrites and includes, and a
488
+ worktree reports its main repo's remote. The URL forms understood are
489
+ `https://`, `http://`, `ssh://`, `git://` (credentials and port dropped) and
490
+ scp-like `[user@]host:owner/repo`; the repo is the path without a trailing
491
+ `.git` or `/`. A local path, `file://`, no git, no repo or no such remote
492
+ leaves the ref unlinked, silently (a debug log line only). Git is asked only
493
+ when a hit needs it — a JIRA source or a qualified ref never runs it — and
494
+ the answer, even "none", is remembered for the worker's life: a remote
495
+ changed mid-session counts after the worker's next start.
496
+
497
+ `remote_host:` (a host or a list, compared case-insensitively) applies the
498
+ remote-derived links only when the remote's host is one of them, so a
499
+ `https://github.com/{repo}/…` template never points a GitLab project's `#12`
500
+ at github.com. A qualified ref is linked whatever the local remote is. An SSH
501
+ alias (`git@github-work:o/r.git` from a multi-account `~/.ssh/config`) or an
502
+ `insteadOf` mirror reports its own host; list it too:
503
+ `remote_host: [github.com, github-work]`.
504
+
505
+ Two Ruby regex notes. With named groups in a pattern, a plain `(…)` doesn't
506
+ capture and gets no number, so `{1}` is the first *named* group, and so is
507
+ `{match}` (in the example above `{match}` is the repo part): use either named
508
+ or numbered groups in one pattern, and named placeholders with named groups.
509
+ And a pattern with a `host` (or `repo`) group lets the model's text choose the
510
+ link's domain (or repo): the escaping rules out URL injection, but the choice
511
+ of the target is the model's.
512
+
data/docs/memory.md CHANGED
@@ -34,6 +34,46 @@ names of a comma list are read). Its file and index line are untouched.
34
34
  These startup index reads are harness-injected context assembly and are not
35
35
  rendered as `tool>` activity lines.
36
36
 
37
+ ## Skills
38
+
39
+ A **skill** is a memory named `skill_<name>` that holds the steps of a
40
+ repeatable task you and chi did together: a release, a deploy, a data fix.
41
+ chi is told about skills by the system bundle (`identity.md`, every turn, and
42
+ `memory_guide.md`), so this works without installing anything:
43
+
44
+ - **Saving.** Say "let's memorize this" or "save this as a skill" after the
45
+ task, and chi writes the skill with `memory_write` (project scope; system
46
+ when you ask, or when it isn't about this project) and shows it. On a vague
47
+ one ("I like how we did that") it may ask first.
48
+ - **Following.** The skill's index line (the `description:` of its
49
+ `memory_write`, "Release a new version of this repo: …") is in every
50
+ prompt, so next time chi reads the skill and follows it.
51
+ - **Updating.** When a step turned out different (a renamed script, an extra
52
+ step), chi fixes the skill in the same turn: those steps changed, the rest
53
+ kept, a dated Changelog line added. No confirmation.
54
+
55
+ A skill is plain Markdown, no frontmatter:
56
+
57
+ ```markdown
58
+ # Skill: release
59
+
60
+ ## Steps
61
+ 1. Run `scripts/verify.sh`; stop if it fails.
62
+ 2. …
63
+ ## Gotchas
64
+ - …
65
+ ## Changelog
66
+ - 2026-09-29 created
67
+ - 2026-09-30 step 1: check.sh was renamed to verify.sh
68
+ ```
69
+
70
+ It is a memory like any other: `memory_read`, `chi --mute skill_release`,
71
+ `chi bundle build` share it. The optional `skills` bundle (`chi bundle install
72
+ skills`, [docs/plugins.md](plugins.md#the-skills-bundle)) adds `/skill save`,
73
+ `/skill list`, `/skill show`, `/skill diff`, keeps older versions with a
74
+ one-line diff after each update, and nudges a model that skips a failing
75
+ step instead of fixing the skill.
76
+
37
77
  ## Bundles that need outside commands
38
78
 
39
79
  A bundle's memory can rely on a command chi doesn't ship, such as a GitHub
data/docs/plugins.md CHANGED
@@ -814,6 +814,56 @@ with a line and carry on unchanged.
814
814
  | `/checkin mode ask` / `nudge` / `notify` | the mode |
815
815
  | `/checkin nudge` / `later` / `stop` | the card's actions, also by hand |
816
816
 
817
+ ## The skills bundle
818
+
819
+ `chi bundle install skills` installs the bundle shipped with chi
820
+ (`lib/samagotchi/bundles/skills/plugin.rb`): one anytime command, `chi.on`
821
+ hooks, `ctx.sessions.send`, `ctx.notify` and `event[:steer]`. It has no memory
822
+ file; skills themselves work without it ([docs/memory.md](memory.md#skills)).
823
+
824
+ | | |
825
+ |---|---|
826
+ | `/skill save [name] [--system]` | sends this session a request to save what was just done as `skill_<name>` (chi picks a name when none is given), project scope unless `--system`. The request holds the skill's shape, so the result is the same with a model that never read the memory guide. It runs as a turn; sent while a turn runs, it joins that turn at its next step (the request says to finish the task first). An existing skill is updated. In a `--no-shared` REPL, which takes no messages, the command shows the request to send yourself |
827
+ | `/skill list` | the `skill_*` memories of both scopes, with the date and description from the index |
828
+ | `/skill show <name>` | one skill as saved (project first, as `memory_read` looks) |
829
+ | `/skill diff <name> [N]` | the skill now against its N-th newest older version (default 1: before the last change), unified |
830
+
831
+ **History.** Before `memory_write`, `write` or `edit` changes a
832
+ `skill_<name>.md` in a memories folder, the file as it was is kept under
833
+ `$XDG_STATE_HOME/samagotchi/plugins/skills/history/<scope>/<name>/` (`system`,
834
+ or `project-<project folder>`), the newest `history_keep`. It is state, not a
835
+ memory: `chi bundle build` and a synced `~/.config` never see it. After the
836
+ call a line says what happened:
837
+
838
+ ```
839
+ skills> skill release saved (project, 14 lines)
840
+ skills> skill release updated (+2 −1): 1. Run `scripts/verify.sh`; stop if it fails. · /skill diff release
841
+ ```
842
+
843
+ The line is the file on disk changing, whatever the tool answered; a denied
844
+ write shows nothing.
845
+
846
+ **The nudge** (`nudge: true`). Some models, finding a skill's step broken,
847
+ skip it and go on without fixing the skill. In a turn that read a skill
848
+ (`memory_read` of a `skill_*` name, or `read` of its file), the first failing
849
+ tool call after it (an `execute` that exited non-zero, a tool error) steers
850
+ the model once: *"A step of skill release failed. Find out why before skipping
851
+ it; if the skill is out of date, fix it now: memory_write the whole skill,
852
+ its title and every section as they were, that step fixed, a Changelog line
853
+ added."* If the turn ends with a failed step and the
854
+ skill not rewritten, one line says so: `skill release was followed, a step
855
+ failed, the skill wasn't updated`. A failure unrelated to the skill (a test
856
+ meant to fail) can set it off too: once per turn, and only after a skill was
857
+ read.
858
+
859
+ ```yaml
860
+ # config.yml
861
+ bundles:
862
+ skills:
863
+ history_keep: 20 # older versions kept per skill
864
+ nudge: true # steer once when a followed skill's step fails
865
+ ```
866
+
817
867
  ## Shutdown
818
868
 
819
869
  When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
data/docs/releasing.md CHANGED
@@ -12,16 +12,16 @@ can run the whole release; the user approves the notes before the tag and the
12
12
  and `lib/samagotchi/bundles/system/manifest.yml` are bumped together (a spec
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
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:
15
+ - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
+ loop-guard, mcp, skills, source-links) has its own semver in its
17
+ `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
+ `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`
20
20
  (`rake bundles:check` fails otherwise, once there's a tag to compare with);
21
21
  - a bundle that uses something new in chi raises its `requires_chi` in the
22
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.
23
+ - the release notes say "run `chi update`" and name the bundles whose
24
+ version moved (what changed in each), not per-bundle upgrade steps.
25
25
  - Pre-1.0: config and commands may change in a minor version (0.2 → 0.3); a
26
26
  patch version (0.2.0 → 0.2.1) is fixes only.
27
27
 
@@ -58,9 +58,9 @@ The agent does each step and stops where the user has to say yes.
58
58
  1. **Start from main, up to date and green.** `git checkout main && git pull`;
59
59
  CI on main is green.
60
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`).
61
+ `## [Unreleased]` in CHANGELOG.md into short user-facing lines. End with
62
+ "Update with `chi update`", naming the bundles whose version moved since
63
+ the last tag (`git diff vPREV -- lib/samagotchi/bundles/*/manifest.yml`).
64
64
  Pick the version: fixes only → patch, anything else → minor.
65
65
  3. **The user approves the notes and the version.** Show them the section.
66
66
  4. **Bump.** `bundle exec rake "release:bump[X.Y.Z]"`, review `git diff`.
@@ -130,9 +130,12 @@ next patch version.
130
130
  The suite runs the same on Linux CI as on macOS, with no CI-only skips. A few
131
131
  things differ there, and a new spec that trips on them fails only on CI:
132
132
 
133
- - `CI` set marks every new session a test run, and `--all`, `list_sessions`
134
- and friends leave test runs out. A spec that lists sessions makes them with
135
- `test_run: false`.
133
+ - `CI` set marks every new session a test run, and `list_sessions` and
134
+ friends leave test runs out. A spec that lists sessions makes them with
135
+ `test_run: false`. `chi sessions list --live`/`--cwd`/`--format` and
136
+ `chi note --all` show test runs when they run as one (`CI` set counts): a
137
+ spec checking that they are hidden unsets `CI` or stubs
138
+ `Session.test_session_env?`.
136
139
  - The gems live under `vendor/bundle`: a child `ruby` started with a bare env
137
140
  (`unsetenv_others: true`) needs `GEM_HOME`/`GEM_PATH` to find nokogiri.
138
141
  - Ruby 3.3's zlib raises `Zlib::BufError` when a thread interrupt lands in a
data/docs/sessions.md CHANGED
@@ -56,7 +56,22 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
56
56
 
57
57
  **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.
58
58
 
59
- `--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, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`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`).
59
+ `--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 unless the list is itself run as one (`SAMAGOTCHI_ENV=test`, `RACK_ENV=test` or `CI` set: then they show, marked `[test]`; `chi note --all` likewise). `--live` shows 10 unless `--limit` says otherwise; filters apply before the limit. `--format json` prints `[{id, short_id, desc, cwd, project, updated_at, live, busy, owner, recap, parent_id, archived, scratch, ctx_pct}]` (`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`).
60
+
61
+ **Ordering:**
62
+
63
+ - `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.
64
+ - 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, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first; typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `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 start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
65
+ - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
66
+ - 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), `notify.js` (which of those events needs the user, for the notifications), `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`).
67
+ - 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).
68
+
69
+ **Test-session hygiene:**
70
+
71
+ - 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`.
72
+ - 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.
73
+ - A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
74
+ - 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.
60
75
 
61
76
  ## Context notes
62
77
 
@@ -120,8 +135,9 @@ cancel during its thinking, it said it had never been asked.
120
135
  `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.
121
136
 
122
137
  ```sh
123
- chi send [-m TEXT] (ID|PREFIX)...
138
+ chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
124
139
  chi send -m "is this the same bug?" 3fa2 # a message
140
+ chi send --image shot.png -m "why is this red?" 3fa2 # with an image
125
141
  pbpaste | chi send -m "is this the same bug?" 3fa2 # the clipboard quoted above the message
126
142
  pbpaste | chi send 3fa2 # the clipboard is the message
127
143
  ```
@@ -130,7 +146,8 @@ pbpaste | chi send 3fa2 # the clipboard is the messa
130
146
  - 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.
131
147
  - 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.
132
148
  - 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`).
133
- - 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`.
149
+ - `--image PATH` (repeatable, up to 20) sends images with the message, as the web composer's chips do. Each file is read once before anything is sent (bmp, tiff and heic converted to png, large ones downscaled, as for `@path`); a missing file or one that isn't an image is a usage error (exit 2) and nothing is sent. Each session gets its own copy in `<session>/images/`. Text is still required (`-m` or stdin; context alone counts), also with `--wait`. The session's model must see images: one known not to (the host's model list says text-only, `vision: false` in `models:` or `hosts:`, a native llama.cpp without a vision model) is refused before anything is sent, with the reason (`<id> refused: gemma can't take images (…); send text only or switch the model (/model)`); the other sessions of the same send still get it, and the exit is 1. With `--new` nothing is started. When chi can't tell (a native host whose `/props` doesn't answer, a list that doesn't say), the message is sent as before and a text-only model fails the turn in the session. A running turn doesn't take images mid-turn: the message runs as the next turn, and the line says `(runs after the current turn)`. With `--new` the session starts idle with the message as its preview, then the message goes in as its first turn once its worker is up (`<id> started with 1 image`); if the worker doesn't come up in 5 s the session is kept, with its id on the `failed:` line.
150
+ - One line per session: `sent`, `sent with 2 images`, `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`.
134
151
 
135
152
  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)`.
136
153
 
@@ -164,18 +181,3 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
164
181
  - 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.
165
182
  - 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.
166
183
  - 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.
167
-
168
- **Ordering:**
169
-
170
- - `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.
171
- - 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 start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first command line (`/model …`) still starts the session as its `prompt`. The browser remembers the last choice; a running session's model is in the info bar and changes only with `/model`.
172
- - Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
173
- - 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), `notify.js` (which of those events needs the user, for the notifications), `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`).
174
- - 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).
175
-
176
- **Test-session hygiene:**
177
-
178
- - 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`.
179
- - 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.
180
- - A `chi scratch` session (`"scratch": true`) is deleted when its REPL ends; one a killed process left behind is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it. `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
181
- - 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.
@@ -243,14 +243,13 @@ module Samagotchi
243
243
 
244
244
  section = data["default"]
245
245
  value = section.is_a?(Hash) ? section["model"] : nil
246
- value ||= data[ConfigFile::DEFAULT_MODEL_KEY]
247
246
  value.to_s.strip.empty? ? nil : value.to_s.strip
248
247
  end
249
248
 
250
249
  # Whether bare model names go somewhere today: a default.model or a
251
250
  # server: section. Then a new hosts: block keeps that route as `default`.
252
251
  def routed?
253
- configured_default_model || data.key?("server") || data.keys.any? { |key| key.to_s.start_with?("SAMAGOTCHI_SERVER_") }
252
+ configured_default_model || data.key?("server")
254
253
  end
255
254
 
256
255
  # The `default` hosts entry chi derives from server.* when the file has
@@ -187,9 +187,6 @@ module Samagotchi
187
187
  "Cache-Control: no-cache\r\n" \
188
188
  "Connection: keep-alive\r\n" \
189
189
  "X-Accel-Buffering: no\r\n" \
190
- "Access-Control-Allow-Origin: *\r\n" \
191
- "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" \
192
- "Access-Control-Allow-Headers: Content-Type, Last-Event-ID\r\n" \
193
190
  "\r\n"
194
191
  )
195
192
  io.flush
@@ -146,6 +146,7 @@ module Samagotchi
146
146
  part = { kind: "tool", iteration: event[:iteration], call_index: event[:call_index],
147
147
  tool: event[:tool], params: event[:params], status: "running" }
148
148
  part[:label] = event[:label] if event[:label]
149
+ part[:title] = event[:title] if event[:title]
149
150
  parts << part
150
151
  when :tool_call_completed
151
152
  tool = parts.reverse_each.find do |part|
@@ -159,6 +160,7 @@ module Samagotchi
159
160
  tool[:output] = capped ? output[0, @max_output_chars] : output.dup
160
161
  tool[:output_truncated] = capped || !!event[:output_truncated]
161
162
  tool[:images] = event[:images] if event[:images]
163
+ tool[:diff] = event[:diff] if event[:diff]
162
164
  when :pending_input_merged
163
165
  # A steer-only merge (count 0) has no user part: the origins stay
164
166
  # for the user lines' own merge.
@@ -19,6 +19,7 @@ require_relative "engine"
19
19
  require_relative "session_commands"
20
20
  require_relative "image_store"
21
21
  require_relative "log"
22
+ require_relative "version"
22
23
 
23
24
  module Samagotchi
24
25
  # Bridge is an optional HTTP transport that lets an external web / desktop
@@ -304,8 +305,8 @@ module Samagotchi
304
305
  if request[:too_large]
305
306
  write_json(io, 413, { "Connection" => "close" }, { error: "too_large", detail: "request body over #{MAX_BODY_BYTES} bytes" })
306
307
  break
307
- elsif method == "OPTIONS"
308
- write_json(io, 204, cors, {})
308
+ elsif browser_request?(headers)
309
+ write_json(io, 403, nil, { error: "cross_origin", detail: "the bridge answers chi's own clients only" })
309
310
  elsif (m = stream_match(request[:path])) && method == "GET"
310
311
  cursor = reconnect_cursor(headers, request[:query])
311
312
  Log.debug(:bridge, "stream", method: method, path: request[:path], client_id: stream_client_id(request[:query]))
@@ -343,7 +344,7 @@ module Samagotchi
343
344
  payload, status, body = handle_snapshot(m[1])
344
345
  write_json(io, status, payload, body)
345
346
  else
346
- write_json(io, 404, { "Allow" => "GET, POST, OPTIONS" },
347
+ write_json(io, 404, { "Allow" => "GET, POST" },
347
348
  { error: "not_found", path: request[:path] })
348
349
  end
349
350
 
@@ -413,12 +414,18 @@ module Samagotchi
413
414
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
414
415
  end
415
416
 
416
- def cors
417
- {
418
- "Access-Control-Allow-Origin" => "*",
419
- "Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
420
- "Access-Control-Allow-Headers" => "Content-Type, Last-Event-ID"
421
- }
417
+ LOOPBACK_NAMES = %w[127.0.0.1 [::1] localhost].freeze
418
+
419
+ # Only chi's Ruby clients talk to the Bridge, and they send no Origin and
420
+ # no Sec-Fetch-Site. A browser page always sends one of them (another
421
+ # website's text/plain POST needs no preflight), and a DNS-rebound page
422
+ # also has a foreign Host. Headers are the raw ones, lowercased.
423
+ def browser_request?(headers)
424
+ return true if headers.key?("origin")
425
+ return true if headers.key?("sec-fetch-site") && headers["sec-fetch-site"].downcase != "none"
426
+
427
+ host = headers["host"].to_s
428
+ !host.empty? && !LOOPBACK_NAMES.include?(host.downcase.sub(/:\d*\z/, ""))
422
429
  end
423
430
 
424
431
  def stream_match(path)
@@ -892,8 +899,7 @@ module Samagotchi
892
899
  "Content-Type" => "application/json",
893
900
  "Content-Length" => data.bytesize.to_s,
894
901
  "Connection" => "close",
895
- "Cache-Control" => "no-store",
896
- "Access-Control-Allow-Origin" => "*"
902
+ "Cache-Control" => "no-store"
897
903
  }.merge(extra_headers)
898
904
 
899
905
  io.write("HTTP/1.1 #{status} #{reason}\r\n")
@@ -961,7 +967,9 @@ module Samagotchi
961
967
  "port" => @port,
962
968
  "bind" => @bind,
963
969
  "session_id" => @session_id,
964
- "started_at" => Time.now.iso8601(3)
970
+ "started_at" => Time.now.iso8601(3),
971
+ # The chi this worker runs (chi update reports older ones).
972
+ "version" => Samagotchi::VERSION
965
973
  }
966
974
  record["input_format"] = @input_format if @input_format
967
975
  path = File.join(session_dir, SIDECAR_FILE)
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: skills
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
+ trust_level: reviewed
7
+ plugin:
8
+ file: plugin.rb
9
+ sha256: sha256:76758d41e75db107e0a6f7ce35b9eac4944052746af16aeba30f38210cc436dc
10
+ requires_chi: ">= 0.4.0"