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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +201 -104
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +26 -79
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- 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
|
|
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
|
|
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.
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
421
|
-
|
|
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 `{`, `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
|
|
24
|
-
bundle
|
|
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.
|
|
62
|
-
`chi
|
|
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
|
-
-
|
|
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.
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -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
|
-
#
|
|
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[:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
8
|
+
source_links.md: sha256:aebd37f1e25491780994ecc4afc20ed6e8db32b724dc038302bb050177efada8
|
|
9
9
|
hooks:
|
|
10
10
|
source_links.rb:
|
|
11
|
-
sha256: sha256:
|
|
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.
|