samagotchi 0.3.0 → 0.4.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. metadata +12 -1
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
@@ -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/releasing.md CHANGED
@@ -15,13 +15,13 @@ can run the whole release; the user approves the notes before the tag and the
15
15
  - **Every other shipped bundle** (btw, guardrails, known-names, loop-guard,
16
16
  mcp) has its own semver in its `manifest.yml` and, when it needs a newer chi,
17
17
  a `requires_chi:` line. They never upgrade by themselves: users run
18
- `chi bundle upgrade NAME`. So:
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`.
data/docs/sessions.md CHANGED
@@ -58,6 +58,21 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
58
58
 
59
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`).
60
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, `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`.
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.
75
+
61
76
  ## Context notes
62
77
 
63
78
  A context note is text pushed into a session as background: not a prompt, and it starts no turn.
@@ -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: a text-only one fails the turn in the session (the line here still says `sent`). 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.
@@ -159,6 +159,7 @@ module Samagotchi
159
159
  tool[:output] = capped ? output[0, @max_output_chars] : output.dup
160
160
  tool[:output_truncated] = capped || !!event[:output_truncated]
161
161
  tool[:images] = event[:images] if event[:images]
162
+ tool[:diff] = event[:diff] if event[:diff]
162
163
  when :pending_input_merged
163
164
  # A steer-only merge (count 0) has no user part: the origins stay
164
165
  # 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
@@ -961,7 +962,9 @@ module Samagotchi
961
962
  "port" => @port,
962
963
  "bind" => @bind,
963
964
  "session_id" => @session_id,
964
- "started_at" => Time.now.iso8601(3)
965
+ "started_at" => Time.now.iso8601(3),
966
+ # The chi this worker runs (chi update reports older ones).
967
+ "version" => Samagotchi::VERSION
965
968
  }
966
969
  record["input_format"] = @input_format if @input_format
967
970
  path = File.join(session_dir, SIDECAR_FILE)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Loaded through module_eval: it requires what it uses.
4
+ require "open3"
5
+
3
6
  # An after_turn hook that links the source refs the model's answer
4
7
  # mentions — a JIRA ticket, a GitHub issue, an internal wiki page. In the
5
8
  # web, each ref in the answer becomes a link (`[JIRA-123](https://…)`,
@@ -22,10 +25,24 @@
22
25
  # pattern: '\bGH-(\d+)\b' # full form: a regex
23
26
  # url: 'https://github.com/org/repo/issues/{match}'
24
27
  # case_insensitive: false # optional, default false
28
+ # - name: Issues # `#12` → this project's repo,
29
+ # # `owner/repo#12` → that repo
30
+ # pattern: '(?<![\w/&])(?:(?<repo>[A-Za-z0-9][\w-]*/[\w.-]*\w))?#(?<num>\d+)\b'
31
+ # url: 'https://github.com/{repo}/issues/{num}'
32
+ # remote: origin # optional: the git remote {repo}/{host}
33
+ # # come from, default origin
34
+ # remote_host: github.com # optional: a host or a list; else a
35
+ # # remote on another host links nothing
25
36
  # max: 10 # optional: refs per line, default 10
26
37
  # note: false # optional: no sources line (the web
27
38
  # # links stay), default true
28
39
  #
40
+ # A `url:` template takes {match} (group 1, else the whole ref), {1}…{9}
41
+ # (numbered groups), {name} (named groups), and {repo}/{host}: the named
42
+ # group when it took part, else the project's (Dir.pwd's) git remote, asked
43
+ # of git once per worker. A ref with a placeholder that can't be filled is
44
+ # not linked; a `{word}` that is none of these stays text (warned at load).
45
+ #
29
46
  # The note is not stored in the conversation: it is an event, replayed by a
30
47
  # UI only while the session's worker lives (a reload keeps it; a stopped
31
48
  # worker loses it). The links are stored as the answer's display. A ref in
@@ -47,6 +64,32 @@ class SourceLinks
47
64
  MARKDOWN_LINK = /\[([^\]]*)\]\(([^)]*)\)/
48
65
  # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
49
66
  FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
67
+ # A `{word}` or `{N}` placeholder in a `url:` template.
68
+ PLACEHOLDER = /\{(\w+)\}/
69
+ # Placeholders every pattern has: {match} (group 1, else the whole ref),
70
+ # and {repo}/{host} (a named group, else the project's git remote).
71
+ BUILTIN_PLACEHOLDERS = %w[match repo host].freeze
72
+ # `git remote get-url` output: a URL with a scheme, `[user[:pw]@]host[:port]/path`.
73
+ REMOTE_URL = %r{\A(?:https?|ssh|git)://(?:[^/]*@)?(\[[^\]]*\]|[^/:@]+)(?::[^/]*)?(/.*)?\z}i
74
+ # scp-like `[user@]host:path`: a colon before any slash; a leading `/` or
75
+ # `.` is a local path.
76
+ REMOTE_SCP = %r{\A(?:[^@/:]+@)?([^/:.@][^/:@]*):(.*)\z}
77
+
78
+ # The {host:, repo:} a git remote URL names, or nil (a local path,
79
+ # `file://`, an empty path, or a path with an empty or dot segment).
80
+ def self.parse_remote_url(url)
81
+ url = url.to_s.strip
82
+ # Any other scheme (file://, a transport helper's) is not a web host.
83
+ match = url.include?("://") ? url.match(REMOTE_URL) : url.match(REMOTE_SCP)
84
+ return nil unless match
85
+
86
+ host = match[1]
87
+ repo = match[2].to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "").sub(/\.git\z/, "").sub(%r{/+\z}, "")
88
+ segments = repo.split("/", -1)
89
+ return nil if host.empty? || segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
90
+
91
+ { host: host, repo: repo }
92
+ end
50
93
 
51
94
  def initialize(settings = {})
52
95
  settings = {} unless settings.is_a?(Hash)
@@ -54,6 +97,9 @@ class SourceLinks
54
97
  max = settings["max"].to_i
55
98
  @max = max.positive? ? max : DEFAULT_MAX
56
99
  @note = settings["note"] != false
100
+ # [Dir.pwd, remote name] => {host:, repo:} or nil, for the worker's life.
101
+ @remotes = {}
102
+ @host_mismatch_logged = {}
57
103
  end
58
104
 
59
105
  def call(event)
@@ -115,9 +161,12 @@ class SourceLinks
115
161
  next if inside_markdown_link?(links, text, match)
116
162
  next if url_adjacent?(text, match)
117
163
 
164
+ # An unresolved placeholder: no link, from this source.
165
+ url = source[:url].call(match[0], match)
166
+ next if url.nil?
167
+
118
168
  quiet = inside_any?(code, start, finish) || inside_any?(labels, start, finish)
119
- hits << { start: start, finish: finish, name: source[:name], ref: match[0],
120
- url: source[:url].call(match[0], match), quiet: quiet }
169
+ hits << { start: start, finish: finish, name: source[:name], ref: match[0], url: url, quiet: quiet }
121
170
  end
122
171
  rescue Regexp::TimeoutError
123
172
  Samagotchi::Log.warn(:hooks, "source_links_timeout",
@@ -131,11 +180,12 @@ class SourceLinks
131
180
  end
132
181
 
133
182
  # [name, ref, url] for the note: first-occurrence order, deduped by the
134
- # ref text case-insensitively.
183
+ # URL case-insensitively (`#12` and `o/r#12` may name one issue; with
184
+ # case_insensitive, `JIRA-1` and `jira-1` are one ticket).
135
185
  def collect(hits)
136
186
  seen = {}
137
187
  hits.filter_map do |hit|
138
- key = hit[:ref].downcase
188
+ key = hit[:url].downcase
139
189
  next if seen.key?(key)
140
190
 
141
191
  seen[key] = true
@@ -336,7 +386,12 @@ class SourceLinks
336
386
  name = "source" if name.empty?
337
387
  regex = Regexp.new(pattern, flags, timeout: REGEX_TIMEOUT)
338
388
  template = entry["url"].to_s
339
- { name: name, regex: regex, url: ->(ref, match) { template.gsub("{match}", escape_url(match[1] || ref)) } }
389
+ known = known_placeholders(name, template, regex)
390
+ remote = entry["remote"].to_s.strip
391
+ remote = "origin" if remote.empty?
392
+ hosts = Array(entry["remote_host"]).map { |host| host.to_s.strip.downcase }.reject(&:empty?)
393
+ source = { name: name, remote: remote, remote_hosts: hosts }
394
+ source.merge(regex: regex, url: ->(ref, match) { render_url(template, known, match, ref, source) })
340
395
  else
341
396
  warn_invalid("a source needs a prefix: or a pattern:")
342
397
  nil
@@ -346,6 +401,124 @@ class SourceLinks
346
401
  nil
347
402
  end
348
403
 
404
+ # The placeholders of +template+ this pattern can fill: {match}, {repo},
405
+ # {host}, its named groups and {1}…{N} for its N groups. Any other
406
+ # `{word}` stays as text, with one warning here, at compile time.
407
+ def known_placeholders(name, template, regex)
408
+ used = template.scan(PLACEHOLDER).flatten.uniq
409
+ groups = group_count(regex)
410
+ known, unknown = used.partition do |word|
411
+ if word.match?(/\A\d+\z/)
412
+ groups.nil? || (word.to_i.between?(1, groups))
413
+ else
414
+ BUILTIN_PLACEHOLDERS.include?(word) || regex.names.include?(word)
415
+ end
416
+ end
417
+ unless unknown.empty?
418
+ list = unknown.map { |word| "{#{word}}" }.join(", ")
419
+ Samagotchi::Log.warn(:hooks, "source_links_unknown_placeholder",
420
+ echo: "[samagotchi:hooks] source-links: #{name}: #{list}: no such group in its pattern; " \
421
+ "left as text")
422
+ end
423
+ known
424
+ end
425
+
426
+ # How many groups +regex+ captures (with named groups, only those), found
427
+ # by matching an always-empty alternative; nil when that fails.
428
+ def group_count(regex)
429
+ probe = Regexp.new("(?:#{regex.source}\n)|", regex.options, timeout: REGEX_TIMEOUT)
430
+ probe.match("").size - 1
431
+ rescue RegexpError, Regexp::TimeoutError
432
+ nil
433
+ end
434
+
435
+ # The URL for one hit: +template+ with its +known+ placeholders filled, or
436
+ # nil when one can't be (a group that didn't take part, a {repo} with an
437
+ # empty or dot segment, no remote): we never build a URL with a hole.
438
+ def render_url(template, known, match, ref, source)
439
+ unresolved = false
440
+ url = template.gsub(PLACEHOLDER) do
441
+ word = Regexp.last_match(1)
442
+ next Regexp.last_match(0) unless known.include?(word)
443
+
444
+ value = placeholder_value(word, match, ref, source)
445
+ unresolved = true if value.nil?
446
+ value.to_s
447
+ end
448
+ unresolved ? nil : url
449
+ end
450
+
451
+ # One placeholder's escaped value, or nil when it is unresolved.
452
+ def placeholder_value(word, match, ref, source)
453
+ case word
454
+ when "match" then escape_url(match[1] || ref)
455
+ when /\A\d+\z/ then match[word.to_i]&.then { |value| escape_url(value) }
456
+ when "repo", "host"
457
+ value = match.names.include?(word) ? match[word] : nil
458
+ value ||= remote_value(word, source)
459
+ word == "repo" ? escape_repo(value) : value&.then { |host| escape_url(host) }
460
+ else match[word]&.then { |value| escape_url(value) }
461
+ end
462
+ end
463
+
464
+ # {repo} / {host} from the project's git remote (the source's `remote:`,
465
+ # default origin); nil when there is none, or when its host is not one of
466
+ # the source's `remote_host:` list.
467
+ def remote_value(word, source)
468
+ remote = project_remote(source[:remote])
469
+ return nil unless remote
470
+
471
+ hosts = source[:remote_hosts]
472
+ unless hosts.empty? || hosts.include?(remote[:host].downcase)
473
+ key = [source[:name], remote[:host]]
474
+ unless @host_mismatch_logged[key]
475
+ @host_mismatch_logged[key] = true
476
+ Samagotchi::Log.debug(:hooks, "source_links_remote_host_mismatch", source: source[:name],
477
+ host: remote[:host], remote_host: hosts.join(","))
478
+ end
479
+ return nil
480
+ end
481
+ remote[word.to_sym]
482
+ end
483
+
484
+ # The project's (Dir.pwd's) remote +name+ as {host:, repo:}, or nil.
485
+ # Asked of git lazily (only a hit that needs it) and remembered, nil too,
486
+ # per [Dir.pwd, name]: git applies insteadOf rewrites and includes, and a
487
+ # worktree reports its main repo's remote.
488
+ def project_remote(name)
489
+ key = [Dir.pwd, name]
490
+ return @remotes[key] if @remotes.key?(key)
491
+
492
+ @remotes[key] = read_remote(key[0], name)
493
+ end
494
+
495
+ def read_remote(dir, name)
496
+ out, status = Open3.capture2({ "GIT_DIR" => nil, "GIT_WORK_TREE" => nil },
497
+ "git", "-C", dir, "remote", "get-url", name, err: File::NULL)
498
+ unless status.success?
499
+ Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, exit: status.exitstatus)
500
+ return nil
501
+ end
502
+ remote = self.class.parse_remote_url(out)
503
+ Samagotchi::Log.debug(:hooks, "source_links_remote", remote: name, host: remote&.dig(:host), repo: remote&.dig(:repo))
504
+ remote
505
+ rescue SystemCallError => e
506
+ Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, error: e.class.name)
507
+ nil
508
+ end
509
+
510
+ # A repo path with each `/`-separated segment escaped and the `/` kept
511
+ # (GitLab's `group/sub/proj`); nil for an empty, `.` or `..` segment, which
512
+ # a browser would resolve out of the path.
513
+ def escape_repo(value)
514
+ return nil if value.nil?
515
+
516
+ segments = value.split("/", -1)
517
+ return nil if segments.empty? || segments.any? { |segment| ["", ".", ".."].include?(segment) }
518
+
519
+ segments.map { |segment| escape_url(segment) }.join("/")
520
+ end
521
+
349
522
  def warn_invalid(reason)
350
523
  Samagotchi::Log.warn(:hooks, "source_links_invalid_source",
351
524
  echo: "[samagotchi:hooks] source-links: skipping a source: #{reason}")
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.2.0
3
+ version: 0.3.0
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
7
  files:
8
- source_links.md: sha256:0fb30311603c60f062740917ad0670904e022e8975b1d0eafe3df9e2904f2fe0
8
+ source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
9
9
  hooks:
10
10
  source_links.rb:
11
- sha256: sha256:5f2b8d5248efc7e0a98185d9e53a47e6a3edfe7c43ee5bf0ebe44d07f18d61aa
11
+ sha256: sha256:0ee86a932efc90d81777855c81dea16510710d7371fe8b6b146a5a16b29794c2
12
12
  event: after_turn
13
13
  on_error: log
14
14
  priority: 90
@@ -2,4 +2,4 @@
2
2
 
3
3
  The `source-links` bundle's `after_turn` hook links the source refs (a JIRA ticket, a GitHub issue, …) in your answers. In the web, each ref in the answer becomes a link; that is how the answer is **shown**, not what you wrote: your own message keeps the plain ref. A `sources: NAME ref → url, …` line right after an answer is the same hook's note, which every UI shows (the terminals only see this line). The line is **not part of the conversation**: it is an event, so it is not in the session file and you cannot refer back to it. A UI replays it while the session's worker lives (a page reload keeps it; a stopped worker loses it).
4
4
 
5
- The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.
5
+ The refs come from the `bundles: source-links:` section of config.yml: each source is a `prefix:` + `base_url:` pair (simple) or a `pattern:` regex + `url:` template (full form), plus an optional `max:` for refs per line and `note: false` to drop the line (the web links stay). A `url:` template can take the project's own repo from its git remote, so a bare `#12` links to this project's issue and `owner/repo#12` to that repo's. With no sources configured the hook does nothing. A ref already inside a URL is not linked again, and a ref in code or in a markdown link is not linked in the answer.