tinyrelaycommit

Commit 474033214334

browse files at this commit | history

1diff --git a/internal/webui/style.css b/internal/webui/style.css
2index 9f5ee4a..40e8524 100644
3--- a/internal/webui/style.css
4+++ b/internal/webui/style.css
5@@ -226,8 +226,9 @@ diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px
6 wiki-article{display:block;max-width:720px;overflow-wrap:anywhere}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key,wiki-article>header nostr-name{color:var(--ink)}
7 wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}
8 wiki-article pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px}
9-merge-request{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button{margin-left:6px;font-size:12px}
10-merge-request nostr-react,merge-request nostr-react form{display:inline}merge-request nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error]{color:var(--alert)}
11+merge-request,wiki-proposal{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}merge-request nostr-key,merge-request nostr-name,wiki-proposal nostr-key,wiki-proposal nostr-name{color:var(--ink);font-weight:500}merge-request>a,merge-request button,wiki-proposal button{margin-left:6px;font-size:12px}
12+merge-request nostr-react,merge-request nostr-react form,wiki-proposal nostr-react,wiki-proposal nostr-react form{display:inline}merge-request nostr-react output,wiki-proposal nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error],wiki-proposal nostr-react output[data-error]{color:var(--alert)}
13+wiki-proposal[data-state=pending]{border-color:var(--bark)}wiki-proposal[data-state=rejected]{border-color:var(--alert)}#wiki-list #rows td:first-child small,#panel li[data-state]{font:12px var(--mono)}#wiki-list #rows td:first-child small{margin-left:6px}#wiki-list #rows tr[data-state=pending] small,#panel li[data-state=pending]{color:var(--bark)}#wiki-list #rows tr[data-state=approved] small,#panel li[data-state=approved]{color:var(--accent)}#wiki-list #rows tr[data-state=rejected] small,#panel li[data-state=rejected]{color:var(--alert)}
14 #merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}
15 wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}
16 wiki-compose label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}wiki-compose label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
17diff --git a/internal/webui/templates.go b/internal/webui/templates.go
18index c984b56..5f9f499 100644
19--- a/internal/webui/templates.go
20+++ b/internal/webui/templates.go
21@@ -279,6 +279,7 @@ func parseTemplates() (*template.Template, error) {
22 "approvalDevices": approvalDevices,
23 "wikiHTML": wikiHTML,
24 "wikiView": wikiPageView,
25+ "wikiProposal": wikiProposal,
26 "wikiURL": wikiURL,
27 "roomItems": roomItems,
28 "roomRoot": roomRoot,
29diff --git a/internal/webui/wiki.go b/internal/webui/wiki.go
30index 27f45ef..eab4f49 100644
31--- a/internal/webui/wiki.go
32+++ b/internal/webui/wiki.go
33@@ -26,6 +26,34 @@ type wikiView struct {
34 OpenCount int
35 RedirectsTo []map[string]any
36 RedirectsFrom []map[string]any
37+ // CanApprove is set when the viewer may accept or reject proposals.
38+ CanApprove bool
39+}
40+
41+// wikiProposalView is how a version's proposal state reads for one viewer.
42+// Shown is set for the people who may see the state: the owner and
43+// moderators, and the version's author. Decide is set when the viewer may
44+// accept or reject it, which is only while it is pending.
45+type wikiProposalView struct {
46+ Shown, Decide bool
47+ State string
48+ At any
49+ By string
50+}
51+
52+// wikiProposal reads a version's proposal state for the viewer. canApprove
53+// is the browse result's can_approve flag.
54+func wikiProposal(version map[string]any, actor string, canApprove any) wikiProposalView {
55+ if version == nil || version["proposal"] != true {
56+ return wikiProposalView{}
57+ }
58+ approver, _ := canApprove.(bool)
59+ if !approver && (actor == "" || actor != plainString(version["author"])) {
60+ return wikiProposalView{}
61+ }
62+ view := wikiProposalView{Shown: true, State: plainString(version["approval"]), At: version["approval_at"], By: plainString(version["approval_by"])}
63+ view.Decide = approver && view.State == "pending"
64+ return view
65 }
66
67 func wikiMaps(value any) []map[string]any {
68@@ -49,6 +77,7 @@ func wikiPageView(result any) wikiView {
69 view.Merges = wikiMaps(page["merges"])
70 view.RedirectsTo = wikiMaps(page["redirects_to"])
71 view.RedirectsFrom = wikiMaps(page["redirects_from"])
72+ view.CanApprove, _ = page["can_approve"].(bool)
73 view.Count = len(view.Versions)
74 if current := valueMap(page["version"]); current != nil {
75 view.Version = current
76diff --git a/internal/webui/wiki.html b/internal/webui/wiki.html
77index 12c79c4..ac97c2e 100644
78--- a/internal/webui/wiki.html
79+++ b/internal/webui/wiki.html
80@@ -3,10 +3,10 @@
81 <form method="get" action="/wiki" id="wiki-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="title or summary" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/wiki">clear</a>{{end}}</form>
82 {{if and .Actor (.Query.Get "new")}}<h2>New page</h2>{{template "wikiEditor" .}}
83 {{end}}{{if .Error}}{{template "alert" .Error}}
84-{{else if .Feed}}<table id="rows">
85+{{else if .Feed}}{{$can := index (asMap .Event) "can_approve"}}<table id="rows">
86 <thead><tr><th>page</th><th>author</th><th>versions</th><th>merge requests</th><th>updated</th></tr></thead>
87 <tbody>
88-{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}<tr><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a></td><td>{{template "nostrName" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>
89+{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}{{$s := wikiProposal $v $.Actor $can}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a>{{if $s.Shown}} <small>proposed | {{$s.State}}</small>{{end}}</td><td>{{template "nostrName" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>
90 {{end}}</tbody>
91 </table>
92 {{else}}<p>No pages yet.</p>
93@@ -23,10 +23,11 @@
94 <h1>{{$w.Title}}</h1>
95 {{template "alert" .Notice}}{{if not $w.Version}}<p>This name has no article of its own.</p>
96 {{template "wikiRedirects" $w}}{{if .Actor}}{{template "wikiEditor" .}}{{end}}
97-{{else}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrName" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>
98+{{else}}{{$s := wikiProposal $w.Version $.Actor $w.CanApprove}}{{if $s.Shown}}<wiki-proposal data-state="{{$s.State}}">Proposed by {{template "nostrName" $w.Author}} | {{$s.State}}{{if $s.By}} {{when $s.At}} by {{template "nostrName" $s.By}}{{end}}{{if $s.Decide}}<nostr-react event="{{str (index $w.Version "id")}}" pubkey="{{$w.Author}}" kind="30818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}</wiki-proposal>
99+{{end}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrName" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>
100 {{end}}{{if .Query.Get "merge"}}{{template "wikiCompare" .}}
101 {{else if .Query.Get "edit"}}{{template "wikiEditor" .}}
102-{{else if .Query.Get "history"}}{{template "wikiHistory" $w}}
103+{{else if .Query.Get "history"}}{{template "wikiHistory" .}}
104 {{else}}<wiki-article><header>wiki | {{$w.D}} | by {{template "nostrName" $w.Author}} | version {{$w.Number}} of {{$w.Count}} | {{$w.Forks}} fork{{if ne $w.Forks 1}}s{{end}}</header>
105 {{with asMap (index $w.Version "fork")}}<p><small>forked from <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>
106 {{end}}{{with asMap (index $w.Version "defer")}}<p><small>the author defers to <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>
107@@ -62,17 +63,17 @@
108 {{end}}<p><a href="{{wikiURL $w.D}}">back to the page</a></p></div>{{end}}{{end}}
109
110 {{/* wikiHistory lists every version of a page, newest first. */}}
111-{{define "wikiHistory"}}<table id="rows">
112+{{define "wikiHistory"}}{{$w := wikiView .Event}}<table id="rows">
113 <thead><tr><th>author</th><th>version</th><th>notes</th><th>published</th></tr></thead>
114 <tbody>
115-{{range .Versions}}<tr><td>{{template "nostrName" (str (index . "author"))}}</td><td><a href="{{wikiURL $.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}</td><td>{{when (index . "created_at")}}</td></tr>
116+{{range $w.Versions}}{{$s := wikiProposal . $.Actor $w.CanApprove}}<tr{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><td>{{template "nostrName" (str (index . "author"))}}</td><td><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}{{if $s.Shown}} | proposed {{$s.State}}{{end}}</td><td>{{when (index . "created_at")}}</td></tr>
117 {{end}}</tbody>
118 </table>
119-<p><a href="{{wikiURL .D}}">back to the page</a></p>{{end}}
120+<p><a href="{{wikiURL $w.D}}">back to the page</a></p>{{end}}
121
122 {{define "panel-wiki"}}{{if eq .Tab "wiki"}}<section><h4>Wiki</h4><table><tr><th>protocol</th><td>NIP-54</td></tr><tr><th>markup</th><td>Djot</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr></table></section>
123 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>
124 {{else}}{{$w := wikiView .Event}}<section><h4>Page</h4><table><tr><th>name</th><td><code>{{or $w.D (.Query.Get "d")}}</code></td></tr><tr><th>versions</th><td>{{$w.Count}}</td></tr><tr><th>merge requests</th><td>{{$w.OpenCount}} open</td></tr><tr><th>links</th><td>{{$w.Links}}</td></tr></table></section>
125-{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}<li><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>
126+{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}{{$s := wikiProposal . $.Actor $w.CanApprove}}<li{{if $s.Shown}} data-state="{{$s.State}}"{{end}}><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}}{{if $s.Shown}} | proposed {{$s.State}}{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>
127 {{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>
128 {{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}
129diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go
130index 8278f53..fa3eb3b 100644
131--- a/internal/webui/wiki_test.go
132+++ b/internal/webui/wiki_test.go
133@@ -27,6 +27,13 @@ type wikiBackend struct {
134 calls []string
135 params map[string]map[string]any
136 private bool
137+ // proposal makes the second author's version a proposal in this state
138+ // (pending, approved or rejected), decided by the owner when it is not
139+ // pending. approver marks the caller as allowed to decide, and shown
140+ // makes the proposal the page's shown version.
141+ proposal string
142+ approver bool
143+ shown bool
144 }
145
146 func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
147@@ -46,14 +53,24 @@ func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawM
148 "content": "# Highlights\n\nPrivate tenants and _encrypted_ uploads. See [[Files and Private Repositories]].\n\n<script>alert(1)</script>", "links": []any{"files-and-private-repositories"}}
149 other := map[string]any{"id": wikiOtherV, "author": wikiOther, "created_at": float64(1788880000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": "30818:" + wikiOther + ":release-notes-1-4", "likes": float64(0), "fork": map[string]any{"a": owner["coordinate"], "e": wikiOwnerV}, "content": "# Highlights\n\nA proposed rewrite."}
150 merge := map[string]any{"id": wikiMerge, "author": wikiOther, "created_at": float64(1788881000), "content": "Please take my rewrite", "target": owner["coordinate"], "target_d": "release-notes-1-4", "destination": wikiOwner, "source": wikiOtherV, "status": "open"}
151+ shown := owner
152+ if b.proposal != "" {
153+ other["proposal"], other["approval"] = true, b.proposal
154+ if b.proposal != "pending" {
155+ other["approval_event"], other["approval_at"], other["approval_by"] = wikiMerge, float64(1788882000), wikiOwner
156+ }
157+ if b.shown {
158+ shown = other
159+ }
160+ }
161 switch method {
162 case "browsewiki":
163- return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4"}, nil
164+ return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4", "can_approve": b.approver}, nil
165 case "browsewikipage":
166 if object["d"] != "release-notes-1-4" {
167 return nil, errors.New("not found: wiki page")
168 }
169- return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}}, nil
170+ return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": shown, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}, "can_approve": b.approver}, nil
171 case "browsewikimerge":
172 return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil
173 }
174@@ -196,3 +213,58 @@ func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {
175 wantNone(t, path, body, `role="alert"`)
176 }
177 }
178+
179+const wikiMember = "6666666666666666666666666666666666666666666666666666666666666666"
180+
181+func TestWikiProposalBarOffersTheDecisionToApprovers(t *testing.T) {
182+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", approver: true, shown: true}
183+ path := "/wiki/release-notes-1-4"
184+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
185+ wantAll(t, path, body,
186+ `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending<nostr-react event="`+wikiOtherV+`" pubkey="`+wikiOther+`" kind="30818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react></wiki-proposal>`,
187+ `<wiki-article><header>wiki | release-notes-1-4 | by <nostr-name pubkey="`+wikiOther+`"`, `A proposed rewrite.`)
188+ // A decided proposal names the decision, its time and the deciding key,
189+ // and offers no buttons.
190+ for _, state := range []string{"approved", "rejected"} {
191+ b.proposal = state
192+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
193+ wantAll(t, path, body, `<wiki-proposal data-state="`+state+`">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| `+state+` 2026-09-08 15:40 UTC by <nostr-name pubkey="`+wikiOwner+`"`)
194+ wantNone(t, path, body, "<nostr-react", ">Accept<")
195+ }
196+ // A pending proposal that is not the shown version is marked in the
197+ // panel and the history, with no bar above the article.
198+ b.proposal, b.shown = "pending", false
199+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
200+ wantAll(t, path, body, `<h4>Other versions</h4><ul><li data-state="pending"><a href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork | proposed pending <small>`)
201+ wantNone(t, path, body, "<wiki-proposal")
202+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path+"?history=1")
203+ wantAll(t, path, body, `<tr data-state="pending"><td><nostr-name pubkey="`+wikiOther+`"`, `fork of 444444444444 | proposed pending</td>`)
204+}
205+
206+func TestWikiProposalAuthorSeesTheStateWithoutButtons(t *testing.T) {
207+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "pending", shown: true}
208+ path := "/wiki/release-notes-1-4"
209+ body := wikiGet(t, wikiApp(t, b, wikiOther), path)
210+ wantAll(t, path, body, `<wiki-proposal data-state="pending">Proposed by <nostr-name pubkey="`+wikiOther+`"`, `| pending</wiki-proposal>`)
211+ wantNone(t, path, body, "<nostr-react", ">Accept<", ">Reject<")
212+ body = wikiGet(t, wikiApp(t, b, wikiOther), "/wiki")
213+ wantAll(t, "/wiki", body, `<tr data-state="pending"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | pending</small></td>`)
214+}
215+
216+func TestWikiProposalStateIsHiddenFromMembers(t *testing.T) {
217+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "approved", shown: true}
218+ for _, path := range []string{"/wiki/release-notes-1-4", "/wiki/release-notes-1-4?history=1", "/wiki"} {
219+ body := wikiGet(t, wikiApp(t, b, wikiMember), path)
220+ wantAll(t, path, body, "Release notes 1.4")
221+ wantNone(t, path, body, "<wiki-proposal", "<tr data-state=", "<li data-state=", "proposed | ", "| proposed ", "<nostr-react")
222+ }
223+}
224+
225+func TestWikiListMarksProposalRowsForApprovers(t *testing.T) {
226+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}, proposal: "rejected", approver: true, shown: true}
227+ body := wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")
228+ wantAll(t, "/wiki", body, `<tr data-state="rejected"><td><a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a> <small>proposed | rejected</small></td><td><nostr-name pubkey="`+wikiOther+`"`)
229+ b.proposal = ""
230+ body = wikiGet(t, wikiApp(t, b, wikiOwner), "/wiki")
231+ wantNone(t, "/wiki", body, "<tr data-state=", "proposed | ")
232+}

About

ownerc01503e8035f
visibilitypublic
head474033214334

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs