pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / page.py

126,256 bytes · last changed 2026-10-27 in Merge pull request #36 from b-i-t-8/claude/great-heisenberg-o6ocah

1"""The console: one HTML file a service or hub serves at ``/``, and the same
2file hosted anywhere else (``pyrtan console --out``), where it is a hub of
3its own until it joins one.
4
5It asks a sentence and shows the answer, the output and the trace; lists
6what the swarm can answer; shows the hub's peers and their ``link``; lists
7the gaps; and, on a hub started with ``--chat``, pairs this browser with a
8random stranger or a bot (``/chat``, see :mod:`pyrtan.chat`). Everything it does goes through the public endpoints
9(``/api``, ``/health``, ``/capabilities``, ``/peers``, ``/gaps``,
10``/intent``, ``/resume``), so it works the same against a hub and a
11service, and the same whether it was served by that hub or by a static
12host somewhere else.
13
14Served by a hub or service, it talks to the origin it came from. Served
15from elsewhere (it finds out by asking ``api`` on its own origin), the
16browser is the hub: ``pyrtan.js``'s ``LocalHub`` answers the same
17endpoints inside the page, with the catalog's trees (its ``trees.json``),
18reading sentences and running trees here, keeping gaps and unanswered
19sentences for as long as the page is open. Trees that need a box (files,
20programs, devices, brokers, a browser to drive) or a site that will not
21let a page read it say so. Joining a hub is explicit: its URL and token in
22the connect box, kept in the browser's local storage, the token sent as a
23bearer; the hub must allow the console's origin with ``--cors``.
24Forgetting the hub makes the browser its own hub again. No external
25assets, no build step.
26
27Its Identity tab does all of ``pyrtan id`` without a shell
28(``identity.js``, inlined: WebCrypto's Ed25519, PBKDF2 and AES-GCM). It
29makes your identity in this browser, hands you its backup at once
30(encrypted with your passphrase, to download or keep in your own private
31repository; the page then holds the key non-extractable), restores one,
32claims a name (pyrtan.com's, through GitHub's own new-file page, which
33opens the pull request from your account; or any network's names
34repository), and adds and revokes devices. A browser can instead be a
35device of an identity held elsewhere: it makes its own key and takes the
36note its root signs. On a hub of 0.14 or later the key signs what the
37console sends, and a member joined from here follows as it; the Access
38tab keeps the hub's grants. A page served over plain ``http://`` to
39anything but localhost has no WebCrypto, holds no key, and says so.
40
41Its Agents tab starts an agent beside the swarm (``agents.js``, inlined;
42see _meta/agents.md): an open-weights model in this browser (WebLLM,
43imported only when a person presses Start) or on a server of theirs. It
44calls the same endpoints this page does, its suggestions are checked by
45rules before they are shown, and accepting one grows the tree here when
46the browser is its own hub, and keeps it at the hub's ``/suggestions``.
47"""
48
49from __future__ import annotations
50
51import json
52import urllib.parse
53from pathlib import Path
54
55from . import __version__
56
57# The tree runtime in JavaScript, served beside the console so a member can run trees.
58PYRTAN_JS = (Path(__file__).parent / "js" / "pyrtan.js").read_text(encoding="utf-8")
59# The wordmark's colour picker, inlined into the console and copied beside pyrtan.com's pages.
60THEME_JS = (Path(__file__).parent / "js" / "theme.js").read_text(encoding="utf-8")
61# Identifiers, canonical JSON and request signing, inlined into the console (_meta/identity.md).
62IDENTITY_JS = (Path(__file__).parent / "js" / "identity.js").read_text(encoding="utf-8")
63# Agents beside the swarm: open-weights models a person starts in the console (_meta/agents.md).
64AGENTS_JS = (Path(__file__).parent / "js" / "agents.js").read_text(encoding="utf-8")
65
66PAGE = r"""<!doctype html>
67<html lang="en">
68<head>
69<meta charset="utf-8">
70<meta name="viewport" content="width=device-width, initial-scale=1">
71<title>Pyrtan</title>
72<meta name="description" content="Pyrtan console: ask the swarm, read the trace, see what it can answer, find the gaps, and join a hub as a member that keeps its blobs.">
73<link rel="manifest" href="manifest.webmanifest">
74<script src="pyrtan.js"></script>
75<meta name="theme-color" content="#c2410c">
76<script>__THEME__</script>
77<script>__IDENTITY__</script>
78<script>__AGENTS__</script>
79<style>
80 :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }
81 @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }
82 :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 }
83 :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) }
84 * { box-sizing:border-box }
85 body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif }
86 main { max-width:920px; margin:0 auto; padding:24px 16px 64px }
87 header { display:flex; flex-wrap:wrap; align-items:baseline; gap:8px 16px; margin-bottom:12px }
88 h1 { font-size:22px; margin:0 } h1 small { color:var(--mute); font-weight:normal; font-size:14px; margin-left:8px }
89 h2 { font-size:17px; margin:24px 0 8px }
90 nav { display:flex; gap:4px; flex-wrap:wrap; margin:12px 0 16px }
91 nav a { padding:6px 12px; border-radius:999px; color:var(--fg); text-decoration:none; border:1px solid var(--line); background:var(--card); font-size:14px }
92 nav a:hover { border-color:var(--tan) }
93 nav a[aria-current=page] { background:var(--accent); color:var(--accent-fg); border-color:var(--accent) }
94 section[hidden] { display:none }
95 p.lede { color:var(--mute); margin:0 0 16px }
96 form.row { display:flex; gap:8px; margin:0 0 12px; flex-wrap:wrap }
97 div.row { display:flex; gap:8px; flex-wrap:wrap }
98 form.row input[type=text], form.row input[type=url], form.row input[type=password], div.row input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
99 [hidden] { display:none !important } /* even where an inline style sets display */
100 #tab-identity input[type=text], #tab-identity input[type=password], #tab-identity input[type=file], #tab-access input[type=text] { flex:1 1 180px; min-width:0; font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
101 button { font:inherit; padding:10px 16px; border:0; border-radius:8px; background:var(--accent); color:var(--accent-fg); cursor:pointer }
102 button.quiet { background:var(--soft); color:var(--fg) }
103 button:disabled { opacity:.6; cursor:wait }
104 .card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin:0 0 14px }
105 .status { font-weight:600 } .done{color:var(--ok)} .failed,.error{color:var(--bad)} .asked{color:var(--ask)}
106 .say { font-size:18px; margin:6px 0 }
107 pre { margin:8px 0 0; padding:10px 12px; background:var(--bg); border:1px solid var(--line); border-radius:8px; overflow:auto; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap }
108 .suggest { position:relative; flex:1 1 180px; min-width:0 }
109 form.row .suggest input[type=text] { position:relative; width:100%; background:transparent }
110 .suggest .ghost { position:absolute; inset:0; padding:10px 12px; border:1px solid transparent; border-radius:8px; background:var(--card); white-space:pre; overflow:hidden; pointer-events:none }
111 .suggest .ghost .typed { visibility:hidden } .suggest .ghost .rest { color:var(--mute) }
112 .suggestions { position:absolute; z-index:10; left:0; right:0; top:calc(100% + 4px); list-style:none; margin:0; padding:4px 0; background:var(--card); border:1px solid var(--line); border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.12); max-height:320px; overflow-y:auto }
113 .suggestions li { padding:7px 12px; cursor:pointer; display:flex; gap:8px; align-items:baseline }
114 .suggestions li[aria-selected=true], .suggestions li:hover { background:var(--soft) }
115 .suggestions li b { font-weight:600 } .suggestions li .text { flex:1; min-width:0; overflow-wrap:anywhere }
116 .suggestions li .from { color:var(--mute); font-size:13px }
117 details summary { cursor:pointer; color:var(--mute) }
118 ul.tries { list-style:none; padding:0; margin:0; display:flex; flex-wrap:wrap; gap:8px }
119 ul.tries li a { display:inline-block; padding:6px 10px; border:1px solid var(--line); border-radius:999px; background:var(--card); color:var(--fg); text-decoration:none; font-size:14px }
120 table { width:100%; border-collapse:collapse; font-size:14px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 }
121 code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace }
122 .mute { color:var(--mute) } .gap { color:var(--ask) }
123 .link { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; background:var(--mute) }
124 .link.up { background:var(--ok) } .link.stale { background:var(--ask) } .link.down { background:var(--bad) }
125 .notice { border-left:3px solid var(--ask); padding:8px 12px; background:var(--soft); border-radius:6px; margin:0 0 14px; font-size:14px }
126 .notice.bad { border-color:var(--bad) }
127 footer { color:var(--mute); font-size:13px; margin-top:32px }
128 footer a, .card a { color:var(--accent) }
129 .pyr { color:var(--pyr) } .tan { color:var(--tan) }
130 :focus-visible { outline:2px solid var(--tan); outline-offset:2px }
131 ::selection { background:var(--tan); color:var(--tan-fg) }
132 .chat-log { height:min(60vh,480px); overflow-y:auto; padding:2px 2px 8px; overflow-wrap:anywhere }
133 .chat-log .line { margin:6px 0 }
134 .chat-log .sys { color:var(--mute); font-size:14px }
135 .chat-log .who { font-weight:600; margin-right:6px }
136 .chat-log .you .who { color:var(--accent) } .chat-log .stranger .who { color:var(--bad) } .chat-log .bot .who { color:var(--ok) } .chat-log .tree .who { color:var(--tan) }
137 .chat-log details { font-size:13px; margin-top:2px }
138 .typing-line { min-height:20px; color:var(--mute); font-size:14px; font-style:italic }
139 #chat-say { flex-wrap:nowrap } #chat-say input { flex-basis:0 }
140 select { font:inherit; padding:10px 12px; border:1px solid var(--line); border-radius:8px; background:var(--card); color:var(--fg) }
141 @media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } }
142</style>
143</head>
144<body>
145<main>
146 <header>
147 <h1><span class="pyr" data-theme-part="pyr">pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1>
148 <span class="mute" id="where"></span>
149 </header>
150 <p class="lede">A core set of expandable services that act on all forms of I/O through inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved in an answer; an agent you start sits beside the trees, never inside them.</p>
151
152 <section id="connect" hidden>
153 <div class="card">
154 <div style="margin-bottom:8px" id="connect-lede">This console is not being served by a Pyrtan. Point it at one:</div>
155 <div class="mute" style="font-size:14px;margin:-4px 0 8px" id="connect-local" hidden></div>
156 <form class="row" id="connect-form">
157 <input type="url" id="hub-url" placeholder="https://hub.example.net or http://192.168.1.20:8700" autocomplete="off">
158 <input type="password" id="hub-token" placeholder="token, if the hub needs one" autocomplete="off">
159 <button type="submit" id="connect-go">Connect</button>
160 <button type="button" class="quiet" id="forget">Forget</button>
161 </form>
162 <div class="mute" style="font-size:14px" id="connect-help">Kept in this browser only. The hub must allow this page's origin: <code>pyrtan hub --cors <span id="origin"></span></code>. A hub on your LAN over <code>http://</code> may be refused by the browser when this page is <code>https://</code>; open the hub's own <code>/</code> instead, which is this same console.</div>
163 </div>
164 </section>
165
166 <div id="problem"></div>
167
168 <nav id="tabs">
169 <a href="#ask" data-tab="ask">Ask</a>
170 <a href="#trees" data-tab="trees">Trees</a>
171 <a href="#peers" data-tab="peers" id="peers-tab" hidden>Peers</a>
172 <a href="#watches" data-tab="watches" id="watches-tab">Watches</a>
173 <a href="#gaps" data-tab="gaps">Gaps</a>
174 <a href="#unanswered" data-tab="unanswered">Unanswered</a>
175 <a href="#agents" data-tab="agents">Agents</a>
176 <a href="#directory" data-tab="directory" id="directory-tab" hidden>Directory</a>
177 <a href="#member" data-tab="member" id="member-tab" hidden>Member</a>
178 <a href="#identity" data-tab="identity" id="identity-tab" hidden>Identity</a>
179 <a href="#access" data-tab="access" id="access-tab" hidden>Access</a>
180 <a href="#chat" data-tab="chat" id="chat-tab" hidden>Chat</a>
181 <a href="#api" data-tab="api">API</a>
182 </nav>
183
184 <section id="tab-ask">
185 <form class="row" id="ask">
186 <div class="suggest">
187 <div class="ghost" id="ghost" aria-hidden="true"><span class="typed"></span><span class="rest"></span></div>
188 <input type="text" id="text" placeholder="what's the weather in Oslo" autofocus autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="both" aria-expanded="false" aria-controls="suggestions">
189 <ul class="suggestions" id="suggestions" role="listbox" aria-label="Suggestions" hidden></ul>
190 </div>
191 <button type="submit" id="go">Ask</button>
192 </form>
193 <div class="card" style="margin-bottom:24px">
194 <div class="mute" style="margin-bottom:8px">Try one</div>
195 <ul class="tries" id="tries"></ul>
196 </div>
197 <div id="out"></div>
198 </section>
199
200 <section id="tab-trees" hidden>
201 <h2>What this swarm can answer</h2>
202 <div class="card"><table id="caps"><thead><tr><th>Tree</th><th>Answers to</th><th>From</th></tr></thead><tbody></tbody></table></div>
203 <p class="mute" style="font-size:14px" id="trees-more">Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.</p>
204 </section>
205
206 <section id="tab-peers" hidden>
207 <h2>Peers</h2>
208 <div class="card"><table id="peers"><thead><tr><th>Link</th><th>Peer</th><th>Trees</th><th>Last</th></tr></thead><tbody></tbody></table></div>
209 <p class="mute" style="font-size:14px"><span class="link up"></span>up: answered within 30 s · <span class="link stale"></span>stale: within 90 s · <span class="link down"></span>down: longer, or never.</p>
210 <div id="federation" hidden>
211 <h2>Federation</h2>
212 <p class="mute" style="font-size:14px;margin-top:0">This hub is <code id="fed-id"></code>. It routes to the hubs it follows; hubs that follow it receive its announcements and may ask it.</p>
213 <div class="card"><table id="hubs"><thead><tr><th>Link</th><th>Hub</th><th>Relation</th><th>Trees</th></tr></thead><tbody></tbody></table></div>
214 </div>
215 </section>
216
217 <section id="tab-watches" hidden>
218 <h2>Watches</h2>
219 <p class="mute" style="font-size:14px;margin-top:0">Trees that run on their own: a clock, a file, a topic or a serial line starts them (<code>[[watches]]</code> in the tree, <code>--watch</code> on the service). Each row is what the watch last did; a path that changed between two runs of the same watch is how you learn a site changed before anyone asked.</p>
220 <div class="card"><table id="watches"><thead><tr><th>Watch</th><th>On</th><th>Fired</th><th>Last run</th></tr></thead><tbody></tbody></table></div>
221 </section>
222
223 <section id="tab-gaps" hidden>
224 <h2>Gaps</h2>
225 <p class="mute" style="font-size:14px;margin-top:0">A gap is a decision that met a value no branch covers. Each one is the next branch to write, in the tree and node named.</p>
226 <div class="card"><table id="gaps"><thead><tr><th>Tree · node</th><th>Saw</th><th>Times</th><th>Where</th></tr></thead><tbody></tbody></table></div>
227 </section>
228
229 <section id="tab-unanswered" hidden>
230 <h2>Waiting to be written</h2>
231 <p class="mute" style="font-size:14px;margin-top:0" id="unanswered-lede">Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.</p>
232 <div class="card"><table id="unanswered"><thead><tr><th>Nobody answered</th><th>Would be</th><th>To write it</th></tr></thead><tbody></tbody></table></div>
233 </section>
234
235 <section id="tab-agents" hidden>
236 <h2>Agents beside the swarm</h2>
237 <p class="mute" style="font-size:14px;margin-top:0">An agent is an open-weights model you start here: in this browser, on its GPU, or on a server of your own (Ollama, llama.cpp). It tests the trees with sentences of its own, says what a gap's value means, finds the patterns a tree missed and the trees nobody has written, and talks with you. It never answers for a tree and never sits inside a run: what it suggests is checked by rules here, checked again by <code>pyrtan grow --suggestions</code> before it is written, and only you accept it. It remembers what it learns and follows its skills, in this browser only. Nothing is downloaded until you start one.</p>
238 <div class="card" id="agent-setup">
239 <form class="row" id="agent-start" style="margin:0 0 8px">
240 <input type="text" id="agent-name" placeholder="a name, e.g. scout" autocomplete="off" style="flex:0 1 140px">
241 <select id="agent-engine" aria-label="where the model runs"><option value="webllm">in this browser (WebGPU)</option><option value="openai">on a server of mine (OpenAI API)</option></select>
242 <select id="agent-model" aria-label="model"></select>
243 <input type="url" id="agent-url" placeholder="http://localhost:11434/v1" autocomplete="off" hidden>
244 <input type="text" id="agent-server-model" placeholder="model, e.g. qwen2.5:3b" autocomplete="off" hidden>
245 <button type="submit" id="agent-go">Start</button>
246 </form>
247 <div class="mute" style="font-size:14px" id="agent-help">In this browser: the weights download once (from Hugging Face, through WebLLM) and stay in the browser's cache. On a server of yours, it must let this page's origin call it (Ollama: <code>OLLAMA_ORIGINS=<span class="origin-here"></span></code>).</div>
248 <div class="notice" id="agent-gpu" hidden style="margin:8px 0 0">This browser has no WebGPU, so a model cannot run in it. Point it at a server of yours instead, or use a browser that has WebGPU.</div>
249 <div class="mute" style="font-size:14px;margin-top:6px" id="agent-progress"></div>
250 </div>
251 <div id="agent-on" hidden>
252 <div class="card">
253 <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between">
254 <div><span class="status done" id="agent-who"></span> <span class="mute" id="agent-what"></span></div>
255 <div style="display:flex;gap:8px;flex-wrap:wrap">
256 <button type="button" id="agent-patrol" title="probe every tree, then the gaps, then the unanswered">Patrol</button>
257 <button type="button" class="quiet" id="agent-stop" disabled>Stop</button>
258 <button type="button" class="quiet" id="agent-off">Put away</button>
259 </div>
260 </div>
261 <div class="chat-log" id="agent-log" aria-live="polite" style="height:min(50vh,420px);margin-top:8px"></div>
262 <div class="typing-line" id="agent-typing" aria-live="polite"></div>
263 <form class="row" id="agent-say" style="margin:8px 0 0;flex-wrap:nowrap">
264 <input type="text" id="agent-text" placeholder="talk to the agent, or /help" autocomplete="off" maxlength="1000" style="flex-basis:0">
265 <button type="submit">Send</button>
266 </form>
267 </div>
268 <h2>Suggestions</h2>
269 <p class="mute" style="font-size:14px;margin-top:0" id="agent-sug-lede">Each passed the rules in this page. Accept one and it is kept at the hub's <code>/suggestions</code>, for <code>pyrtan grow --suggestions <span class="at">URL</span> --trees DIR --apply</code>. Dismiss one and the agent remembers why.</p>
270 <div id="agent-sugs"><p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p></div>
271 <div style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 14px"><button type="button" class="quiet" id="agent-download">Download accepted</button></div>
272 <details class="card" id="agent-memory"><summary>Memory</summary>
273 <p class="mute" style="font-size:14px">What this agent keeps, for this hub, in this browser: your notes, its own, and what happened (refusals, your verdicts, patrols). The most relevant go into every prompt.</p>
274 <form class="row" id="agent-note-form"><input type="text" id="agent-note" placeholder="a note for the agent, e.g. Oslo's weather API is in Celsius" autocomplete="off"><button type="submit">Remember</button></form>
275 <table id="agent-notes"><tbody></tbody></table>
276 <div style="margin-top:8px"><button type="button" class="quiet" id="agent-forget-all">Forget everything</button></div>
277 </details>
278 <details class="card" id="agent-skills-box"><summary>Skills</summary>
279 <p class="mute" style="font-size:14px">Instructions for a job, the way a skill is for Claude: the built-in ones are the jobs, yours are added to the job you name (or to all of them). A chat skill of yours can be called as <code>/name</code>. Kept in this browser, shared by every agent; a skill file is Markdown with <code>name</code>, <code>description</code> and <code>when</code> in its front matter, or JSON.</p>
280 <table id="agent-skills"><thead><tr><th>Skill</th><th>For</th><th></th></tr></thead><tbody></tbody></table>
281 <form id="agent-skill-form" style="margin-top:10px">
282 <div class="row"><input type="text" id="skill-name" placeholder="name, e.g. tourist" autocomplete="off"><select id="skill-when" aria-label="for which job"><option value="chat">chat</option><option value="probe">probe</option><option value="meaning">meaning</option><option value="trigger">trigger</option><option value="tree">tree</option><option value="always">always</option></select><input type="text" id="skill-desc" placeholder="one line: what it is for" autocomplete="off"></div>
283 <textarea id="skill-body" rows="4" placeholder="the instructions, e.g. Write probe sentences the way a tourist would: short, a little misspelled, with place names in their own language." style="width:100%;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)"></textarea>
284 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"><button type="submit">Add skill</button><label class="quiet" style="padding:10px 16px;border-radius:8px;background:var(--soft);cursor:pointer">Import…<input type="file" id="skill-import" accept=".md,.json,.txt" hidden></label><button type="button" class="quiet" id="skill-export">Export mine</button></div>
285 </form>
286 </details>
287 </div>
288 </section>
289
290 <section id="tab-directory" hidden>
291 <h2>Directory</h2>
292 <p class="mute" style="font-size:14px;margin-top:0">Every hub that asked to be found, as this hub has heard it through the swarm. Entries are hints carried hub to hub; <em>link</em> is how recently each announced. Nothing here is routed to: to use one, follow it from your own hub, <code>pyrtan follow URL --at YOUR-HUB</code>. To be in it: <code>pyrtan hub --listed</code>.</p>
293 <div class="card"><table id="directory"><thead><tr><th>Heard</th><th>Hub</th><th>Holds</th><th>Last announced</th></tr></thead><tbody></tbody></table></div>
294 </section>
295
296 <section id="tab-member" hidden>
297 <h2>This browser as a member</h2>
298 <p class="mute" style="font-size:14px;margin-top:0">A browser or a phone cannot be reached, so it joins a hub the other way round: it follows the hub, polls a mailbox the hub keeps for it, carries the directory, and keeps copies of the hub's blobs (its directory, gaps, unanswered sentences, anything given to <code>pyrtan keep</code>). When the hub's box dies, what it kept is still here. Install this page to the home screen and it is the phone app.</p>
299 <div class="card">
300 <div id="member-off">
301 <form class="row" id="join-form">
302 <input type="text" id="member-name" placeholder="a name for this device, e.g. barry's phone" autocomplete="off">
303 <input type="password" id="member-fedsecret" placeholder="federation secret, if the hub takes follows only with one" autocomplete="off">
304 <button type="submit">Join this hub</button>
305 </form>
306 <div class="mute" style="font-size:14px">Makes an identity for this browser (kept in local storage), follows the hub with it, and starts polling. Nothing leaves this device but signed activities to the hub. Joined with the hub's token (entered above), this device is <em>trusted</em>: the hub hands it its trees and routes sentences to it, and the trees run here.</div>
307 </div>
308 <div id="member-on" hidden>
309 <div><span class="status done">member</span> <code id="member-id"></code> <span class="mute" id="member-state"></span></div>
310 <div class="mute" style="font-size:14px;margin:6px 0">Name <code id="member-name-shown"></code> · <span id="member-trust"></span> · mailbox polled <span id="member-polled">never</span> · <label><input type="checkbox" id="member-listed"> listed in the directory</label></div>
311 <div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0">
312 <button type="button" class="quiet" id="member-poll">Poll now</button>
313 <button type="button" class="quiet" id="member-leave">Leave</button>
314 </div>
315 <h2 style="font-size:15px;margin:14px 0 6px">Kept here for this hub</h2>
316 <table id="kept"><thead><tr><th>Key</th><th>Size</th><th>Updated</th><th></th></tr></thead><tbody></tbody></table>
317 <h2 style="font-size:15px;margin:14px 0 6px">Trees this device runs</h2>
318 <table id="member-trees"><thead><tr><th>Tree</th><th>Answers to</th><th>Reaches</th></tr></thead><tbody></tbody></table>
319 <form class="row" id="member-run" style="margin-top:8px">
320 <select id="member-run-tree" style="font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg)"></select>
321 <input type="text" id="member-run-inputs" placeholder='inputs as JSON, e.g. {"place": "Oslo"}' autocomplete="off">
322 <button type="submit">Run here</button>
323 </form>
324 <div id="member-out"></div>
325 <h2 style="font-size:15px;margin:14px 0 6px">Activity</h2>
326 <pre id="member-log" style="max-height:220px"></pre>
327 </div>
328 </div>
329 </section>
330
331 <section id="tab-identity" hidden>
332 <h2>Your identity</h2>
333 <p class="mute" style="font-size:14px;margin-top:0">An identity is a key, made here and held by this browser, which will not hand it over, not even to this page. No account anywhere: hubs grant your key what they choose, a name like <code>you@pyrtan.com</code> points at it, and your other devices speak for it with a note it signs. Lose the key and its backup and nobody can give it back.</p>
334 <div id="ident-none" class="notice" hidden>This page cannot hold a key: it needs a secure page (https, or localhost) in a browser with Ed25519 in WebCrypto. It still asks, chats and joins, without a key: with the hub's token, or as an anonymous member.</div>
335
336 <div class="card" id="ident-start" hidden>
337 <div style="display:flex;gap:8px;flex-wrap:wrap">
338 <button type="button" id="ident-new">Make my identity</button>
339 <button type="button" class="quiet" id="ident-restore-open">Restore from a backup</button>
340 <button type="button" class="quiet" id="ident-make">Use this browser as a device of an identity I have</button>
341 </div>
342 <form id="ident-new-form" style="margin-top:12px" hidden>
343 <div class="mute" style="font-size:14px;margin-bottom:6px">Your key is made here, then sealed with a passphrase into a backup you keep. Choose a long sentence you will remember: nobody can reset it, and the backup is only as strong as it is.</div>
344 <div class="row"><input type="password" id="ident-pass" placeholder="passphrase (a long sentence)" autocomplete="new-password"><input type="password" id="ident-pass2" placeholder="the same again" autocomplete="new-password"><button type="submit">Make it</button></div>
345 </form>
346 <form id="ident-restore-form" style="margin-top:12px" hidden>
347 <div class="mute" style="font-size:14px;margin-bottom:6px">The backup file this console or <code>pyrtan id backup</code> wrote, and its passphrase.</div>
348 <div class="row"><input type="file" id="ident-file" accept="application/json,.json"><input type="password" id="ident-restore-pass" placeholder="passphrase" autocomplete="current-password"><button type="submit">Restore</button></div>
349 </form>
350 </div>
351
352 <div class="card" id="ident-root" hidden>
353 <div><span class="status done">identity</span> <code id="root-id"></code></div>
354 <div id="root-backup-state" style="margin:8px 0"></div>
355 <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
356 <button type="button" id="root-download">Download my backup</button>
357 <input type="text" id="root-gh" placeholder="your GitHub login" autocomplete="off" style="max-width:220px">
358 <button type="button" class="quiet" id="root-gh-go">Keep it in a private GitHub repository</button>
359 </div>
360 <div id="root-gh-out" class="mute" style="font-size:14px;margin-top:8px"></div>
361 </div>
362
363 <div class="card" id="ident-claim" hidden>
364 <h2 style="font-size:15px;margin:0 0 8px">Claim a name</h2>
365 <form id="claim-form">
366 <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:8px;font-size:14px">
367 <label><input type="radio" name="claim-where" value="pyrtan" checked> at pyrtan.com (your GitHub login)</label>
368 <label><input type="radio" name="claim-where" value="network"> at another network</label>
369 </div>
370 <div class="row" id="claim-pyrtan"><input type="text" id="claim-login" placeholder="your GitHub login" autocomplete="off"><button type="submit">Make my claim</button></div>
371 <div id="claim-network" hidden>
372 <div class="row"><input type="text" id="claim-name" placeholder="name@network-domain, e.g. alice@names.example.org" autocomplete="off"></div>
373 <div class="row" style="margin-top:6px"><input type="text" id="claim-repo" placeholder="its names repository, e.g. https://github.com/example/names" autocomplete="off"><input type="text" id="claim-branch" value="main" style="max-width:120px" autocomplete="off"><button type="submit">Make my claim</button></div>
374 </div>
375 </form>
376 <div id="claim-out"></div>
377 </div>
378
379 <div class="card" id="ident-devices" hidden>
380 <h2 style="font-size:15px;margin:0 0 8px">Devices that speak for you</h2>
381 <table id="dev-table"><thead><tr><th>Device</th><th>May</th><th>Until</th><th></th></tr></thead><tbody></tbody></table>
382 <form id="dev-form" style="margin-top:10px">
383 <div class="mute" style="font-size:14px;margin-bottom:6px">On the other device, open this console's Identity tab, choose <em>Use this browser as a device</em>, and paste its code here.</div>
384 <div class="row"><input type="text" id="dev-code" placeholder="its code, pyr:…" autocomplete="off"><input type="text" id="dev-label" placeholder="what to call it: phone" style="max-width:200px" autocomplete="off"></div>
385 <div style="display:flex;gap:14px;flex-wrap:wrap;margin:8px 0;font-size:14px;align-items:center" id="dev-may"><span class="mute">it may</span>
386 <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat" checked> chat</label><label><input type="checkbox" value="run"> run trees</label><label><input type="checkbox" value="operate"> operate hubs</label>
387 <button type="submit">Sign its note</button></div>
388 </form>
389 <div id="dev-out"></div>
390 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
391 <button type="button" class="quiet" id="doc-push" hidden>Give this hub my newest document</button>
392 <button type="button" class="quiet" id="doc-download">Download my identity document</button>
393 </div>
394 </div>
395
396 <div class="card" id="ident-on" hidden>
397 <div><span class="status done">device</span> <code id="ident-id"></code> <button type="button" class="quiet" id="ident-copy">Copy its code</button></div>
398 <div class="mute" style="font-size:14px;margin:6px 0" id="ident-paired"></div>
399 <form id="ident-note-form" style="margin:10px 0">
400 <div class="mute" style="font-size:14px;margin-bottom:6px">Give this code to the browser that holds your identity (its Identity tab, <em>Devices</em>), or run <code id="ident-howto"></code>; then paste the note it gives back here.</div>
401 <div class="row"><input type="text" id="ident-note" placeholder='{"pyrtan-device": 1, "root": "pyr:…", ...}' autocomplete="off"><button type="submit">Use this note</button></div>
402 </form>
403 </div>
404
405 <div id="ident-common" style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:8px" hidden>
406 <label><input type="checkbox" id="ident-sign"> sign requests to the hub with this key</label>
407 <button type="button" class="quiet" id="ident-forget">Forget it on this browser</button>
408 </div>
409 </section>
410
411 <section id="tab-access" hidden>
412 <h2>Who may do what here</h2>
413 <p class="mute" style="font-size:14px;margin-top:0">The identities this hub grants anything, instead of handing out its token: <em>ask</em> (the trees), <em>chat</em> (the lobby, bots included), <em>run</em> (be a trusted member, running its trees) and <em>operate</em> (everything the token allows). A device gets what its identity is granted, capped by its note. A name is looked up once and pinned.</p>
414 <div id="access-note" class="notice" hidden></div>
415 <div class="card">
416 <table id="grant-table"><thead><tr><th>Who</th><th>May</th><th>Document</th><th></th></tr></thead><tbody></tbody></table>
417 <form id="grant-form" style="margin-top:10px">
418 <div class="row"><input type="text" id="grant-who" placeholder="name@domain or pyr:…" autocomplete="off"><button type="submit">Grant</button></div>
419 <div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:14px" id="grant-may">
420 <label><input type="checkbox" value="ask" checked> ask</label><label><input type="checkbox" value="chat"> chat</label><label><input type="checkbox" value="run"> run</label><label><input type="checkbox" value="operate"> operate</label>
421 </div>
422 </form>
423 </div>
424 </section>
425
426 <section id="tab-chat" hidden>
427 <h2>Talk to a stranger</h2>
428 <p class="mute" style="font-size:14px;margin-top:0">You are paired at random: with someone else on this hub or on a hub it follows, or with a bot. A bot is a set of trees on the network, not a person and not a model; each of its lines carries the path that produced it, and what it cannot answer it says so, with the tree that would. Nobody has a name and nothing is kept: the conversation is gone when either of you leaves. <span id="chat-count"></span></p>
429 <div class="notice" id="chat-guest" hidden>This hub's lobby is open to anyone, and you are here without its token: you meet people, not bots. A bot runs the hub's trees, and the token is what guards them.</div>
430 <div class="card" id="chat-off">
431 <form class="row" id="chat-start" style="margin:0">
432 <input type="text" id="chat-interests" placeholder="interests, optional: weather, news, music" autocomplete="off">
433 <select id="chat-want" aria-label="who to meet"><option value="anyone">a stranger, or a bot</option><option value="people">people only</option><option value="bot">a bot</option></select>
434 <button type="submit">Start</button>
435 </form>
436 </div>
437 <div class="card" id="chat-on" hidden>
438 <div class="chat-log" id="chat-log" aria-live="polite"></div>
439 <div class="typing-line" id="chat-typing-line" aria-live="polite"></div>
440 <form class="row" id="chat-say" style="margin:8px 0 0">
441 <button type="button" class="quiet" id="chat-next" title="Esc">New</button>
442 <input type="text" id="chat-text" placeholder="say something" autocomplete="off" maxlength="1000">
443 <button type="submit">Send</button>
444 </form>
445 <div class="mute" style="font-size:13px">Stop (or Esc), twice: end this and meet someone new · <a href="#" id="chat-stop">leave the lobby</a></div>
446 </div>
447 <label class="mute" style="font-size:14px" id="chat-typing-opt"><input type="checkbox" id="chat-typing" checked> Typing indicator: see when the stranger is typing, and let them see when you are. Off, neither of you sees it.</label>
448 </section>
449
450 <section id="tab-api" hidden>
451 <h2>The same endpoints this page uses</h2>
452 <div class="card">
453 <pre id="api-pre">POST /intent {"text": "…"}
454POST /run {"tree": "…", "inputs": {…}}
455POST /resume {"snapshot": {…}, "answer": …}
456GET /capabilities
457GET /health
458GET /gaps
459GET /unanswered
460GET /peers (hub only)
461POST /chat/start {"want": "anyone"|"people"|"bot", "typing_indicator": true} (hub with --chat)
462POST /chat/typing {"seat": "…", "typing": true}</pre>
463 <div class="mute" style="font-size:14px;margin-top:8px" id="api-help">With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.</div>
464 </div>
465 </section>
466
467 <footer><a href="https://pyrtan.com">pyrtan.com</a> · <a href="https://pyrtan.com/source/">source</a> · AGPL-3.0-or-later · console v__VERSION__<span id="hub-version"></span></footer>
468</main>
469<script>
470const $ = (s) => document.querySelector(s);
471const esc = (s) => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
472const examples = {
473 "weather": "what's the weather in Oslo",
474 "hacker-news": "top 3 stories on hacker news",
475 "site-status": "is example.com up",
476 "github-release": "latest release of astral-sh/uv",
477 "hacker-news-item": "hacker news item 1",
478 "wikipedia": "tell me about Ada Lovelace",
479 "define": "what does serendipity mean",
480 "sun": "when is sunset in Oslo",
481 "air-quality": "air quality in Delhi",
482 "npm-package": "latest version of react on npm",
483 "pypi-package": "latest requests on pypi",
484 "hn-search": "search hacker news for sqlite",
485 "books": "who wrote The Left Hand of Darkness",
486 "holidays": "next holiday in US",
487 "exchange-rate": "convert 100 USD to EUR",
488 "synonyms": "synonyms for happy",
489 "forecast": "weather in Oslo tomorrow",
490 "uv-index": "uv index in Sydney",
491 "local-time": "what time is it in Tokyo",
492 "elevation": "how high is Quito",
493 "postal-code": "where is zip 90210",
494 "wayback": "is example.com archived",
495 "rhymes": "what rhymes with moon",
496 "github-repo": "how many stars does astral-sh/uv have",
497 "go-module": "latest version of github.com/gorilla/mux on go",
498 "homebrew": "brew formula wget",
499 "pokemon": "pokemon pikachu",
500};
501// Served by a Pyrtan, only its token is kept, under its own origin; served elsewhere, the hub URL and token.
502const KEY = "pyrtan.console:" + location.origin;
503const store = {
504 get() { try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch (e) { return {}; } },
505 set(v) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch (e) {} },
506 clear() { try { localStorage.removeItem(KEY); } catch (e) {} },
507};
508// base: "" when this page is served by a Pyrtan (relative URLs); otherwise the hub URL the person gave.
509// local: the hub in this page (pyrtan.js's LocalHub), while no hub is joined.
510let base = "", token = "", remote = false, isHub = false, local = null, catalog = "";
511
512async function api(path, init) {
513 if (local) {
514 const body = init && init.body ? JSON.parse(init.body) : undefined;
515 return local.route((init && init.method) || "GET", path, body).then(([status, payload]) => new Response(JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}}));
516 }
517 const headers = Object.assign({"Accept": "application/json"}, (init && init.headers) || {});
518 if (token) headers["Authorization"] = "Bearer " + token;
519 const url = base ? base.replace(/\/+$/, "") + "/" + path : path;
520 const method = ((init && init.method) || "GET").toUpperCase();
521 const body = init && init.body ? JSON.parse(init.body) : (method === "POST" ? {} : null);
522 Object.assign(headers, await ident.headers(method, "/" + path.split("?")[0].replace(/^\/+/, ""), body));
523 return fetch(url, Object.assign({}, init, {headers}));
524}
525async function json(path, init) {
526 const r = await api(path, init);
527 if (r.status === 401) throw new Error("401: this Pyrtan needs a token");
528 return r.json();
529}
530function problem(msg, bad) { $("#problem").innerHTML = msg ? `<div class="notice${bad ? " bad" : ""}">${esc(msg)}</div>` : ""; }
531
532// ---------------------------------------------------------------- identity
533// This browser's key: made and held by crypto.subtle (non-extractable, in
534// IndexedDB), paired with a root by a device note, used to sign requests
535// for the hub's audience (named at /api). See identity.js.
536// pyrtan.com's names: files in this repository, added by pull request (site/names/README.md).
537const PYRTAN_NAMES = {repo: "b-i-t-8/pyrtan", branch: "main", dir: "site/names", domain: "pyrtan.com"};
538const githubNewFile = (repo, branch, dir, filename, text) => `https://github.com/${repo}/new/${encodeURIComponent(branch)}${dir ? "/" + dir : ""}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(text)}`;
539const githubRepo = (url) => { const m = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(String(url || "").trim()); return m ? m[1] : null; };
540function saveFile(name, data) {
541 const a = document.createElement("a");
542 a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2) + "\n"], {type: "application/json"}));
543 a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
544}
545const pre = (data) => `<pre style="max-height:180px">${esc(JSON.stringify(data, null, 2))}</pre>`;
546
547const ident = {
548 ok: null, key: null, audience: null, sign: true,
549 async init() {
550 this.ok = await PyrtanIdentity.available();
551 if (this.ok) { try { this.key = await PyrtanIdentity.store.get(); } catch (e) { this.key = null; } }
552 try { this.sign = localStorage.getItem("pyrtan.identity.sign") !== "off"; } catch (e) {}
553 this.render();
554 },
555 isRoot() { return !!(this.key && this.key.role === "root"); },
556 active() { return !!(this.ok && this.key && this.sign && this.audience); },
557 async headers(method, path, body) {
558 if (!this.active()) return {};
559 return PyrtanIdentity.signRequest(this.key, this.audience, method, path, body, this.key.note || null);
560 },
561 async keep() { await PyrtanIdentity.store.put(this.key); this.render(); },
562 // ---- a device of an identity held elsewhere
563 async make() {
564 const k = await PyrtanIdentity.generate();
565 this.key = {role: "device", id: k.id, privateKey: k.privateKey, publicKey: k.publicKey, note: null, made: Date.now()};
566 await this.keep();
567 },
568 async useNote(text) {
569 let note; try { note = JSON.parse(text); } catch (e) { problem("The note is JSON: paste what the browser holding your identity, or pyrtan id device add, gave you.", true); return; }
570 const bad = PyrtanIdentity.noteProblem(note, this.key.id);
571 if (bad) { problem(bad, true); return; }
572 this.key.note = note; problem(""); await this.keep();
573 },
574 // ---- your identity, in this browser
575 async makeRoot(pass) {
576 const r = await PyrtanIdentity.createRoot();
577 const sealed = await PyrtanIdentity.makeBackup(r.seed, r.id, pass);
578 const key = await PyrtanIdentity.rootFromSeed(r.seed, r.id); // non-extractable from here on; the seed is wiped
579 const document = await PyrtanIdentity.makeIdentity(key);
580 this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: false, note: null, made: Date.now()};
581 await this.keep();
582 },
583 async restore(file, pass) {
584 let b; try { b = JSON.parse(await file.text()); } catch (e) { throw new Error("that file is not a backup"); }
585 const seed = await PyrtanIdentity.openBackup(b, pass);
586 const key = await PyrtanIdentity.rootFromSeed(seed, b.id.toLowerCase());
587 const sealed = Object.fromEntries(Object.entries(b).filter(([k]) => k !== "document"));
588 const document = b.document || await PyrtanIdentity.makeIdentity(key);
589 this.key = {role: "root", id: key.id, privateKey: key.privateKey, sealed, document, backedUp: true, note: null, made: Date.now()};
590 await this.keep();
591 },
592 backup() { return Object.assign({}, this.key.sealed, {document: this.key.document}); },
593 async backedUp() { if (!this.key.backedUp) { this.key.backedUp = true; await this.keep(); } },
594 async addDevice(code, may, label) {
595 const note = await PyrtanIdentity.makeDevice(this.key, code.trim(), may, {label});
596 this.key.document = await PyrtanIdentity.withDevice(this.key.document, this.key, note);
597 await this.keep();
598 return note;
599 },
600 async revoke(device) {
601 this.key.document = await PyrtanIdentity.withRevoked(this.key.document, this.key, device);
602 await this.keep();
603 },
604 async forget() {
605 const what = this.isRoot() ? "Forget your identity on this browser? Without its backup nobody can give it back." : "Forget this browser's key? A hub that granted it, or a member that joined as it, will not know this browser again.";
606 if (!confirm(what)) return;
607 await PyrtanIdentity.store.put(null); this.key = null; this.render();
608 },
609 render() {
610 if (!$("#tab-identity")) return;
611 const root = this.isRoot(), device = !!this.key && !root;
612 $("#ident-none").hidden = this.ok !== false;
613 $("#ident-start").hidden = !this.ok || !!this.key;
614 for (const id of ["#ident-root", "#ident-claim", "#ident-devices"]) $(id).hidden = !this.ok || !root;
615 $("#ident-on").hidden = !this.ok || !device;
616 $("#ident-common").hidden = !this.ok || !this.key;
617 if (!this.key) return;
618 $("#ident-sign").checked = this.sign;
619 $("#doc-push").hidden = !root || !this.audience;
620 if (device) {
621 $("#ident-id").textContent = this.key.id;
622 const n = this.key.note;
623 $("#ident-paired").innerHTML = n
624 ? `speaks for <code>${esc(n.root)}</code> · may ${esc(n.may.join(", ") || "nothing")} · until ${esc(new Date(n.expires * 1000).toLocaleDateString())}${n.label ? " · " + esc(n.label) : ""}`
625 : "not paired yet: it signs as itself, so a hub grants this key, or nothing";
626 $("#ident-howto").textContent = `pyrtan id device add ${this.key.id} --may ask,chat --label browser`;
627 return;
628 }
629 $("#root-id").textContent = this.key.id;
630 $("#root-backup-state").innerHTML = this.key.backedUp
631 ? `<span class="mute" style="font-size:14px">Backed up. Keep the file somewhere only you can read; never in a public repository.</span>`
632 : `<div class="notice bad" style="margin:0">Save your backup now. It is the only way back to this identity if this browser's data is cleared. It is encrypted with your passphrase; keep it where only you can read it.</div>`;
633 const tb = $("#dev-table tbody"); tb.innerHTML = "";
634 const doc = this.key.document;
635 for (const n of doc.devices) tb.insertAdjacentHTML("beforeend", `<tr><td>${esc(n.label || "-")}<div class="mute"><code>${esc(PyrtanIdentity.short(n.device))}</code></div></td><td>${esc(n.may.join(", "))}</td><td class="mute">${esc(new Date(n.expires * 1000).toLocaleDateString())}</td><td><button type="button" class="quiet" data-revoke="${esc(n.device)}">Revoke</button></td></tr>`);
636 for (const r of doc.revoked) tb.insertAdjacentHTML("beforeend", `<tr><td class="mute"><code>${esc(PyrtanIdentity.short(r))}</code></td><td class="mute" colspan="3">revoked</td></tr>`);
637 if (!doc.devices.length && !doc.revoked.length) tb.innerHTML = `<tr><td class="mute" colspan="4">none yet</td></tr>`;
638 },
639};
640
641// ---- starting
642$("#ident-make").addEventListener("click", () => ident.make().catch((e) => problem("Could not make a key: " + e.message, true)));
643$("#ident-new").addEventListener("click", () => { $("#ident-new-form").hidden = false; $("#ident-restore-form").hidden = true; $("#ident-pass").focus(); });
644$("#ident-restore-open").addEventListener("click", () => { $("#ident-restore-form").hidden = false; $("#ident-new-form").hidden = true; });
645$("#ident-new-form").addEventListener("submit", async (e) => {
646 e.preventDefault();
647 const a = $("#ident-pass").value, b = $("#ident-pass2").value;
648 if (a !== b) { problem("The two passphrases differ.", true); return; }
649 if (a.length < 8) { problem("A passphrase is at least 8 characters; a long sentence is better.", true); return; }
650 const btn = e.submitter || e.target.querySelector("button"); btn.disabled = true; btn.textContent = "Making it…";
651 try { await ident.makeRoot(a); problem(""); } catch (err) { problem("Could not make your identity: " + err.message, true); }
652 btn.disabled = false; btn.textContent = "Make it"; $("#ident-pass").value = $("#ident-pass2").value = "";
653});
654$("#ident-restore-form").addEventListener("submit", async (e) => {
655 e.preventDefault();
656 const f = $("#ident-file").files[0]; if (!f) { problem("Choose the backup file.", true); return; }
657 try { await ident.restore(f, $("#ident-restore-pass").value); problem(""); } catch (err) { problem(err.message, true); }
658 $("#ident-restore-pass").value = "";
659});
660// ---- a device
661$("#ident-note-form").addEventListener("submit", (e) => { e.preventDefault(); ident.useNote($("#ident-note").value.trim()); });
662$("#ident-copy").addEventListener("click", () => { navigator.clipboard && navigator.clipboard.writeText(ident.key.id); });
663// ---- backup
664$("#root-download").addEventListener("click", async () => { saveFile("pyrtan-identity-backup.json", ident.backup()); await ident.backedUp(); });
665$("#root-gh-go").addEventListener("click", async () => {
666 const login = $("#root-gh").value.trim();
667 if (!/^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(login)) { problem("Enter your GitHub login.", true); return; }
668 const create = "https://github.com/new?name=pyrtan-identity&visibility=private&description=" + encodeURIComponent("My pyrtan identity backup (encrypted). Keep this repository private.");
669 const file = githubNewFile(`${login}/pyrtan-identity`, "main", "", "pyrtan-identity-backup.json", JSON.stringify(ident.backup(), null, 2) + "\n");
670 $("#root-gh-out").innerHTML = `1. <a href="${esc(create)}" target="_blank" rel="noopener">Make a <strong>private</strong> repository named pyrtan-identity</a> (check that it says Private). 2. <a href="${esc(file)}" target="_blank" rel="noopener" id="root-gh-file">Commit your backup to it</a>. GitHub keeps every version; the backup is only as strong as your passphrase, so never make that repository public.`;
671 $("#root-gh-file").addEventListener("click", () => ident.backedUp());
672});
673// ---- claiming a name
674for (const r of document.querySelectorAll('input[name="claim-where"]')) r.addEventListener("change", () => { const net = document.querySelector('input[name="claim-where"]:checked').value === "network"; $("#claim-pyrtan").hidden = net; $("#claim-network").hidden = !net; });
675$("#claim-form").addEventListener("submit", async (e) => {
676 e.preventDefault();
677 const out = $("#claim-out"); out.innerHTML = "";
678 try {
679 let claim, repo, branch, dir, gh = null;
680 if (document.querySelector('input[name="claim-where"]:checked').value === "pyrtan") {
681 const login = $("#claim-login").value.trim().toLowerCase();
682 if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(login)) throw new Error("enter your GitHub login");
683 const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {headers: {"Accept": "application/json"}});
684 if (!r.ok) throw new Error(`GitHub does not know ${login} (${r.status})`);
685 gh = (await r.json()).id;
686 claim = await PyrtanIdentity.makeName(ident.key, `${login}@${PYRTAN_NAMES.domain}`, {extra: {github: gh}});
687 ({repo, branch, dir} = PYRTAN_NAMES);
688 } else {
689 claim = await PyrtanIdentity.makeName(ident.key, $("#claim-name").value);
690 repo = githubRepo($("#claim-repo").value); branch = $("#claim-branch").value.trim() || "main"; dir = "names";
691 if (!repo && $("#claim-repo").value.trim()) repo = null;
692 }
693 const file = `${claim.name}.json`, docFile = `${claim.name}.id.json`, text = JSON.stringify(claim, null, 2) + "\n", docText = JSON.stringify(ident.key.document, null, 2) + "\n";
694 let html = `<div style="margin-top:10px">Your claim to <code>${esc(claim.name)}@${esc(claim.domain)}</code>, signed by your key:</div>${pre(claim)}`;
695 if (repo) {
696 html += `<div>1. <a href="${esc(githubNewFile(repo, branch, dir, file, text))}" target="_blank" rel="noopener" id="claim-open">Propose it on GitHub</a>: GitHub opens <code>${esc(dir)}/${esc(file)}</code> filled in; choose <em>Propose changes</em>, then <em>Create pull request</em>${gh !== null ? `, from the GitHub account ${esc(claim.name)}` : ""}.</div>`;
697 html += `<div>2. Optional, once that is merged: <a href="${esc(githubNewFile(repo, branch, dir, docFile, docText))}" target="_blank" rel="noopener">propose your identity document</a> (<code>${esc(docFile)}</code>), so hubs find your devices there.</div>`;
698 } else {
699 html += `<div>Add it to the network's names repository as <code>names/${esc(file)}</code> (and your identity document as <code>names/${esc(docFile)}</code>), and open a merge request there.</div>`;
700 }
701 html += `<div style="margin-top:6px"><button type="button" class="quiet" id="claim-save">Download the claim</button> <button type="button" class="quiet" id="claim-copy">Copy it</button></div>`;
702 out.innerHTML = html;
703 $("#claim-save").addEventListener("click", () => saveFile(file, claim));
704 $("#claim-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(text));
705 problem("");
706 } catch (err) { problem("Could not make the claim: " + err.message, true); }
707});
708// ---- devices
709$("#dev-form").addEventListener("submit", async (e) => {
710 e.preventDefault();
711 const may = Array.from(document.querySelectorAll("#dev-may input:checked")).map((i) => i.value);
712 try {
713 const note = await ident.addDevice($("#dev-code").value, may, $("#dev-label").value.trim());
714 $("#dev-out").innerHTML = `<div style="margin-top:10px">Paste this on <code>${esc(note.label || PyrtanIdentity.short(note.device))}</code>, under <em>Use this note</em>:</div>${pre(note)}<button type="button" class="quiet" id="dev-copy">Copy it</button>`;
715 $("#dev-copy").addEventListener("click", () => navigator.clipboard && navigator.clipboard.writeText(JSON.stringify(note)));
716 $("#dev-code").value = $("#dev-label").value = ""; problem("");
717 } catch (err) { problem("Could not sign the note: " + err.message, true); }
718});
719$("#dev-table").addEventListener("click", async (e) => {
720 const b = e.target.closest("button[data-revoke]"); if (!b) return;
721 if (!confirm("Revoke this device? Hubs refuse it once they have your newer document.")) return;
722 await ident.revoke(b.dataset.revoke);
723 problem("Revoked. Give your newest document to the hubs you use, and to your name's repository, so they know now.");
724});
725$("#doc-push").addEventListener("click", async () => {
726 const r = await api("identity", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({document: ident.key.document})});
727 const b = await r.json().catch(() => ({}));
728 problem(r.ok ? "This hub holds your newest document." : `The hub did not take it: ${b.say || r.status}`, !r.ok);
729});
730$("#doc-download").addEventListener("click", () => saveFile(`${ident.key.id.slice(4, 12)}.id.json`, ident.key.document));
731$("#ident-forget").addEventListener("click", () => ident.forget());
732$("#ident-sign").addEventListener("change", (e) => { ident.sign = e.target.checked; try { localStorage.setItem("pyrtan.identity.sign", ident.sign ? "on" : "off"); } catch (err) {} });
733
734// ---------------------------------------------------------------- access: the hub's grants
735async function loadGrants() {
736 const tb = $("#grant-table tbody"), note = $("#access-note");
737 let r; try { r = await api("allow"); } catch (e) { note.hidden = false; note.textContent = String(e); return; }
738 if (r.status === 401 || r.status === 403) { note.hidden = false; note.textContent = "Only this hub's operator sees and changes its grants: connect with its token, or as an identity it grants operate."; tb.innerHTML = ""; return; }
739 note.hidden = true;
740 const body = await r.json().catch(() => ({grants: []}));
741 tb.innerHTML = (body.grants || []).map((g) => `<tr><td>${g.name ? `<code>${esc(g.name)}</code>` : ""}<div class="mute"><code>${esc(PyrtanIdentity.short(g.id))}</code></div></td><td>${esc(g.may.join(", "))}</td><td class="mute">${g.document ? `${esc(String(g.document.devices))} device(s), ${esc(String(g.document.revoked))} revoked` : "none yet"}</td><td><button type="button" class="quiet" data-disallow="${esc(g.id)}">Remove</button></td></tr>`).join("") || `<tr><td class="mute" colspan="4">nobody yet</td></tr>`;
742}
743async function grantPost(path, payload) {
744 const r = await api(path, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload)});
745 const b = await r.json().catch(() => ({}));
746 if (!r.ok) problem(b.say || `the hub answered ${r.status}`, true); else problem("");
747 loadGrants();
748}
749$("#grant-form").addEventListener("submit", (e) => {
750 e.preventDefault();
751 const may = Array.from(document.querySelectorAll("#grant-may input:checked")).map((i) => i.value);
752 grantPost("allow", {who: $("#grant-who").value.trim(), may}).then(() => { $("#grant-who").value = ""; });
753});
754$("#grant-table").addEventListener("click", (e) => { const b = e.target.closest("button[data-disallow]"); if (b) grantPost("disallow", {who: b.dataset.disallow}); });
755
756function showTab(name) {
757 for (const a of document.querySelectorAll("#tabs a")) { if (a.dataset.tab === name) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }
758 for (const s of document.querySelectorAll("main > section[id^=tab-]")) s.hidden = s.id !== "tab-" + name;
759 if (name === "peers") loadPeers();
760 if (name === "gaps") loadGaps();
761 if (name === "watches") loadWatches();
762 if (name === "unanswered") loadUnanswered();
763 if (name === "directory") loadDirectory();
764 if (name === "member") member.render();
765 if (name === "identity") ident.render();
766 if (name === "access") loadGrants();
767 if (name === "chat") chat.count();
768 if (name === "agents") agentsUI.render();
769}
770
771async function loadDirectory() {
772 let d; try { d = await json("directory"); } catch (e) { problem(e.message, true); return; }
773 const body = $("#directory tbody"); body.innerHTML = "";
774 for (const h of d.hubs || []) {
775 const tr = document.createElement("tr");
776 tr.innerHTML = `<td><span class="link ${esc(h.link)}"></span>${esc(h.link)}</td><td><code>${esc(h.name||"")}</code>${h.via === "self" ? ' <span class="mute">(this hub)</span>' : ""}${h.kind === "member" ? ' <span class="mute">(member)</span>' : ""}<div class="mute">${esc(h.id)}</div></td><td>${(h.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${h.seen ? new Date(h.seen * 1000).toLocaleString() : ""}</td>`;
777 body.appendChild(tr);
778 }
779 if (!(d.hubs || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nobody heard of yet. Join the default network (<code>pyrtan join</code>), or start this hub with <code>--listed</code>.</td></tr>`;
780}
781window.addEventListener("hashchange", () => showTab((location.hash || "#ask").slice(1)));
782
783// A trigger as a sentence: the first of each choice, an input's default or "…"
784// for its blank, and the optional words kept or dropped.
785function phrase(t, pat, optional) {
786 return pat.replace(/\[([^\]|]*)[^\]]*\]/g, optional ? "$1" : "").replace(/\(([^|)]*)\|[^)]*\)/g, "$1").replace(/\{(\w+)(?::\w+)?\}/g, (m, n) => (t.inputs && t.inputs[n] && t.inputs[n].default != null) ? t.inputs[n].default : "…").replace(/\s+/g, " ").trim();
787}
788function sample(t) {
789 if (examples[t.name]) return examples[t.name];
790 const pat = (t.triggers || [])[0]; return pat ? phrase(t, pat, false) : null;
791}
792// Every sentence a tree answers to, for the ask box to suggest; one that starts with a blank suggests nothing.
793function samples(t) {
794 return [examples[t.name], ...(t.triggers || []).map(p => phrase(t, p, true))].filter(s => s && !s.startsWith("…"));
795}
796
797async function detect() {
798 // Same origin first: a Pyrtan answers /api with {"pyrtan": "service"|"hub"}.
799 try {
800 const r = await fetch("api", {headers: {"Accept": "application/json"}});
801 const a = r.ok ? await r.json() : null;
802 if (a && a.pyrtan) {
803 base = ""; remote = false; isHub = a.pyrtan === "hub";
804 if (a.auth === "bearer") {
805 // This Pyrtan wants a token: show the connect box with only the token field.
806 token = store.get().token || "";
807 $("#connect").hidden = false;
808 $("#hub-url").hidden = true;
809 $("#connect-lede").textContent = "This Pyrtan needs a token:";
810 $("#connect-help").innerHTML = "Kept in this browser only. The token is the one the hub or service was started with (<code>--token</code> or <code>$PYRTAN_TOKEN</code>).";
811 $("#hub-token").value = token;
812 if (!token) problem(a.chat_public ? "Enter the token to see what this Pyrtan can answer, or open Chat: its lobby is open to anyone." : "Enter the token to see what this Pyrtan can answer.");
813 }
814 return a;
815 }
816 } catch (e) {}
817 remote = true;
818 const saved = store.get();
819 const asked = new URLSearchParams(location.search).get("hub");
820 base = asked || saved.url || ""; token = saved.token || "";
821 $("#connect").hidden = false;
822 $("#origin").textContent = location.origin;
823 $("#hub-url").value = base; $("#hub-token").value = token;
824 local = null; showLocal(false);
825 if (!base) return startLocal();
826 try {
827 const a = await json("api");
828 if (a && a.pyrtan) { isHub = a.pyrtan === "hub"; return a; }
829 problem(`${base} answered, but not as a Pyrtan.`, true);
830 } catch (e) { problem(`${base}: ${e.message}. Is the hub up, and started with --cors ${location.origin}?`, true); }
831 return null;
832}
833
834// ---------------------------------------------------------------- the hub in this page
835// No hub joined: this browser is one, for as long as the page is open. It
836// holds the catalog's trees and answers the same endpoints a hub does.
837const params = new URLSearchParams(location.search);
838const CATALOGS = [params.get("catalog"), "../catalog/", "https://pyrtan.com/catalog/"].filter(Boolean);
839const TREES_KEY = "pyrtan.console.trees";
840async function catalogTrees() {
841 for (const c of CATALOGS) {
842 try {
843 const at = new URL(c.endsWith("/") ? c : c + "/", location.href);
844 const r = await fetch(new URL("trees.json", at), {headers: {"Accept": "application/json"}});
845 const d = r.ok ? await r.json() : null;
846 if (d && Array.isArray(d.trees)) {
847 catalog = at.href;
848 try { localStorage.setItem(TREES_KEY, JSON.stringify({catalog, trees: d.trees})); } catch (e) {}
849 return d.trees;
850 }
851 } catch (e) {}
852 }
853 // Offline, or no catalog answers: the trees this browser held last time.
854 try { const kept = JSON.parse(localStorage.getItem(TREES_KEY) || "null"); if (kept && Array.isArray(kept.trees)) { catalog = kept.catalog || ""; return kept.trees; } } catch (e) {}
855 return [];
856}
857// A tree runs here when every source it uses is http. Hosts of "*" mean any site, and most sites refuse a page, so "Try one" leaves those out.
858const runsHere = (t) => (t.sources || []).every(s => s === "http");
859const anySite = (t) => (t.hosts || []).includes("*");
860async function startLocal() {
861 isHub = false;
862 local = new Pyrtan.LocalHub({name: "this browser", version: "__VERSION__"});
863 const trees = await catalogTrees();
864 for (const e of trees) local.add(e.tree, e.id);
865 showLocal(true);
866 return (await local.route("GET", "/api"))[1];
867}
868function showLocal(on) {
869 $("#connect-local").hidden = !on;
870 $("#connect-lede").textContent = on ? "This browser is a hub of its own: it holds the catalog's trees, reads your sentences and runs the trees right here." : "This console is not being served by a Pyrtan. Point it at one:";
871 $("#connect-local").innerHTML = on ? "Gaps and unanswered sentences are kept only while this page is open. Trees that need a box (files, programs, devices) or a site that will not let a page read it need a hub: to join one, give its URL and token." : "";
872 $("#connect-go").textContent = on ? "Join" : "Connect";
873 $("#watches-tab").hidden = on;
874 $("#unanswered-lede").innerHTML = on ? "Sentences nobody answered, each with the tree that would. Each command writes that tree's file; serve it from a hub and join the hub here, or publish it to the catalog and this page will hold it too." : 'Sentences nobody answered, each with the tree that would. <code>pyrtan unanswered --at <span class="at">URL</span> --scaffold DIR</code> writes the files.';
875 $("#api-help").innerHTML = on ? "Answered inside this page by <code>pyrtan.js</code>: the same paths and the same answers as a hub, with no server behind them. Join a hub and they go to the hub." : 'With a token: <code>Authorization: Bearer …</code>. From the command line: <code>pyrtan ask "…" --at <span class="at">URL</span></code>, <code>pyrtan gaps --at <span class="at">URL</span></code>.';
876 $("#trees-more").innerHTML = on ? `These are the <a href="${esc(catalog || "https://pyrtan.com/catalog/")}">catalog</a>'s trees. Rows marked <em>needs a hub</em> reach what a page cannot; a service runs them.` : 'Need a tree that is not here? Browse the <a href="https://pyrtan.com/catalog/" id="catalog-link">catalog</a>, then <code>pyrtan pull NAME</code> into a folder a service serves.';
877}
878
879async function load() {
880 problem("");
881 const a = await detect();
882 for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin;
883 if (!a) { $("#who").textContent = remote ? "not connected" : "unreachable"; return; }
884 $("#peers-tab").hidden = !isHub;
885 $("#directory-tab").hidden = !(isHub && a.federation);
886 $("#member-tab").hidden = !(isHub && a.federation);
887 ident.audience = a.audience || null;
888 $("#identity-tab").hidden = false; // an identity needs no hub; signing does, when there is one
889 $("#access-tab").hidden = !(isHub && a.audience);
890 $("#chat-tab").hidden = !(isHub && a.chat);
891 if (a.chat && a.chat_bots === false && token) {
892 try { a.chat_bots = (await json("api")).chat_bots; } catch (e) {} // the first ask went without the token
893 }
894 if (a.chat && a.chat_bots === false) {
895 // A public lobby, and no token here: people only, since a bot runs the hub's trees.
896 $("#chat-want").innerHTML = `<option value="people">people only</option>`;
897 $("#chat-guest").hidden = false;
898 }
899 member.hub = a.federation || null;
900 if (member.hub) member.start();
901 $("#where").textContent = local ? "running in this browser" : (base || location.origin) + (a.auth === "bearer" ? " · token" : "");
902 $("#hub-version").textContent = a.version ? ` · ${a.pyrtan} v${a.version}` : "";
903 try {
904 const h = await json("health");
905 $("#who").textContent = (h.name ? h.name + " · " : "") + (h.peers != null ? `${h.up}/${h.peers} peers up` + (h.trees != null ? `, ${h.trees} trees` : "") : (h.trees ? `${h.trees.length} trees` : ""));
906 } catch (e) { $("#who").textContent = "unreachable"; }
907 for (const el of document.querySelectorAll(".at")) el.textContent = base || location.origin;
908 if (a.auth === "bearer" && !token) {
909 if (a.chat_public && location.hash === "#chat") showTab("chat"); // the one tab open without the token
910 return;
911 }
912 let c;
913 try { c = await json("capabilities"); } catch (e) { problem(e.message + (a.auth === "bearer" ? " (is it the right one?)" : ""), true); return; }
914 problem(local && !(c.trees || []).length ? "No catalog answered and none was kept in this browser, so it holds no trees yet. Join a hub above, or try again online." : "", true);
915 renderCaps(c);
916 showTab((location.hash || "#ask").slice(1));
917}
918function renderCaps(c) {
919 const body = $("#caps tbody"); const tries = $("#tries");
920 body.innerHTML = ""; tries.innerHTML = "";
921 suggest.from((c.trees || []).filter(t => !local || (runsHere(t) && !anySite(t))));
922 for (const t of c.trees || []) {
923 const tr = document.createElement("tr");
924 const here = !local ? "" : !runsHere(t) ? `<div class="gap">needs a hub: ${esc((t.sources||[]).filter(x => x !== "http").join(", "))}</div>` : anySite(t) ? '<div class="gap">any site; most will not let a page read them</div>' : "";
925 tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version||1)}</span><div class="mute">${esc(t.description||"")}</div></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}${t.peer ? `<div><span class="link ${esc(t.link||"")}"></span>${esc(t.peer)}</div>` : ""}${here}</td>`;
926 body.appendChild(tr);
927 const s = sample(t); if (s && !s.includes("…") && (!local || (runsHere(t) && !anySite(t)))) { const li = document.createElement("li"); li.innerHTML = `<a href="#" data-q="${esc(s)}">${esc(s)}</a>`; tries.appendChild(li); }
928 }
929 // The greyed suggestion in the empty box (Tab takes it) is one this swarm answers.
930 const first = tries.querySelector("a[data-q]"), offered = [...tries.querySelectorAll("a[data-q]")].map(a => a.dataset.q);
931 if (first && !offered.includes($("#text").placeholder)) $("#text").placeholder = first.dataset.q;
932}
933
934async function loadPeers() {
935 if (!isHub) return;
936 let p; try { p = await json("peers"); } catch (e) { problem(e.message, true); return; }
937 const body = $("#peers tbody"); body.innerHTML = "";
938 for (const peer of p.peers || []) {
939 const tr = document.createElement("tr");
940 const last = peer.last_ok ? new Date(peer.last_ok * 1000).toLocaleTimeString() : "never";
941 tr.innerHTML = `<td><span class="link ${esc(peer.link)}"></span>${esc(peer.link)}</td><td><code>${esc(peer.name||"")}</code><div class="mute">${esc(peer.url)}</div></td><td>${(peer.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}</td><td class="mute">${esc(last)}${peer.last_error ? `<div class="failed">${esc(peer.last_error)}</div>` : ""}</td>`;
942 body.appendChild(tr);
943 }
944 if (!(p.peers || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">No peers yet. Start a service with <code>--hub ${esc(base || location.origin)}</code>.</td></tr>`;
945 const fed = $("#federation");
946 fed.hidden = !p.id;
947 if (p.id) {
948 $("#fed-id").textContent = p.id;
949 const hb = $("#hubs tbody"); hb.innerHTML = "";
950 for (const [rel, list] of [["follows", p.following || []], ["follower", p.followers || []]]) {
951 for (const r of list) {
952 const tr = document.createElement("tr");
953 tr.innerHTML = `<td><span class="link ${esc(r.link)}"></span>${esc(r.link)}</td><td><code>${esc(r.name||"")}</code><div class="mute">${esc(r.url)}</div></td><td>${rel}${r.state && r.state !== "accepted" ? ` <span class="mute">(${esc(r.state)})</span>` : ""}</td><td>${(r.trees||[]).map(n => `<code>${esc(n)}</code>`).join(" ")}${r.last_error ? `<div class="failed">${esc(r.last_error)}</div>` : ""}</td>`;
954 hb.appendChild(tr);
955 }
956 }
957 if (!hb.children.length) hb.innerHTML = `<tr><td colspan="4" class="mute">Following nobody, followed by nobody. <code>pyrtan follow https://other-hub --at ${esc(base || location.origin)}</code>.</td></tr>`;
958 }
959}
960
961async function loadWatches() {
962 let w; try { w = await json("watches"); } catch (e) { problem(e.message, true); return; }
963 const body = $("#watches tbody"); body.innerHTML = "";
964 for (const x of w.watches || []) {
965 const last = x.last || {};
966 const when = x.last_at ? new Date(x.last_at * 1000).toLocaleTimeString() : "never";
967 const changed = x.path_changed_at ? `<div class="mute">path changed ${esc(new Date(x.path_changed_at * 1000).toLocaleString())}: <code>${esc((x.previous_path||[]).join(" > "))}</code> → <code>${esc((x.path||[]).join(" > "))}</code></div>` : "";
968 const err = x.last_error ? `<div class="mute">error: ${esc(x.last_error)}</div>` : "";
969 const tr = document.createElement("tr");
970 tr.innerHTML = `<td><code>${esc(x.name)}</code>${x.peer && x.peer !== "local" ? `<div class="mute">${esc(x.peer)}</div>` : ""}</td><td>${esc(x.kind)} <code>${esc(x.what)}</code></td><td>${esc(x.fires||0)}× <span class="mute">${esc(when)}</span></td><td>${last.status ? `<span class="status ${esc(last.status)}">${esc(last.status)}</span>${last.reason ? ` <span class="mute">(${esc(last.reason)})</span>` : ""} ${esc(last.say||"")}` : '<span class="mute">not yet</span>'}${changed}${err}</td>`;
971 body.appendChild(tr);
972 }
973 if (!(w.watches || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">Nothing watched. Give a tree <code>[[watches]]</code> and start the service with <code>--watch</code>.</td></tr>`;
974}
975
976async function loadGaps() {
977 let g; try { g = await json("gaps"); } catch (e) { problem(e.message, true); return; }
978 const body = $("#gaps tbody"); body.innerHTML = "";
979 for (const gap of g.gaps || []) {
980 const tr = document.createElement("tr");
981 tr.innerHTML = `<td><code>${esc(gap.tree)}</code> · <code>${esc(gap.node)}</code></td><td><code>${esc(gap.on)}</code> = <code class="gap">${esc(JSON.stringify(gap.observed))}</code></td><td>${esc(gap.count||1)}</td><td class="mute">${esc(gap.peer||"")}</td>`;
982 body.appendChild(tr);
983 }
984 if (!(g.gaps || []).length) body.innerHTML = `<tr><td colspan="4" class="mute">No gaps: every decision so far had a branch.</td></tr>`;
985}
986
987async function loadUnanswered() {
988 let u; try { u = await json("unanswered"); } catch (e) { problem(e.message, true); return; }
989 const body = $("#unanswered tbody"); body.innerHTML = "";
990 for (const e of u.unanswered || []) {
991 const p = e.proposal || {};
992 const tr = document.createElement("tr");
993 tr.innerHTML = `<td>${esc(e.text)} <span class="mute">×${esc(e.count||1)}${e.peer ? " · " + esc(e.peer) : ""}</span></td><td><code>${esc(p.name||"")}</code><div class="mute"><code>${esc(p.trigger||"")}</code></div></td><td><code>${esc(p.scaffold||"")}</code></td>`;
994 body.appendChild(tr);
995 }
996 if (!(u.unanswered || []).length) body.innerHTML = `<tr><td colspan="3" class="mute">Nothing yet: every sentence so far found a tree.</td></tr>`;
997}
998
999function renderTrace(tr) {
1000 if (!tr || !tr.steps) return "";
1001 const lines = tr.steps.map(s => `${" ".repeat((s.depth||0)+1)}${s.node} (${s.kind})${s.chosen ? " [" + s.chosen + "]" : ""}: ${s.summary}${s.next ? " -> " + s.next : ""}`);
1002 for (const g of tr.gaps || []) lines.push(` gap: ${g.node} saw ${g.on} = ${JSON.stringify(g.observed)} and had no branch for it`);
1003 return `trace: ${tr.tree}\n` + lines.join("\n");
1004}
1005
1006async function ask(snapshot, answer) {
1007 const text = $("#text").value.trim(); if (!text && !snapshot) return;
1008 if (!snapshot) { suggest.remember(text); suggest.close(); }
1009 $("#go").disabled = true;
1010 const t0 = performance.now();
1011 let r;
1012 try {
1013 const res = snapshot ? await api("resume", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({snapshot, answer})})
1014 : await api("intent", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({text})});
1015 r = res.status === 401 ? {status:"error", reason:"unauthorised", say:"This Pyrtan needs a token."} : await res.json();
1016 } catch (e) { r = {status:"error", reason:"unreachable", say:String(e)}; }
1017 const ms = Math.round(performance.now() - t0);
1018 const out = $("#out");
1019 const card = document.createElement("div"); card.className = "card";
1020 let html = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${ms} ms${r.peer ? " · " + esc(r.peer) : ""}${local ? " · ran in this browser" : ""}</span></div>`;
1021 if (r.say) html += `<div class="say">${esc(r.say)}</div>`;
1022 if (r.output && r.output.reading && r.output.reading.steps && r.output.reading.steps.length) html += `<div class="mute" style="font-size:14px">Read as <code>${esc(r.output.reading.cleaned)}</code>: ${r.output.reading.steps.map(esc).join("; ")}</div>`;
1023 if (r.reason === "no-match" && r.output && r.output.proposal) {
1024 const p = r.output.proposal;
1025 html += `<div class="gap">No tree answers this yet. The one that would: <code>${esc(p.name)}</code>, answering <code>${esc(p.trigger)}</code>. It is listed under <a href="#unanswered">Unanswered</a>.</div><pre>${esc(p.scaffold)}</pre>`;
1026 }
1027 if (r.status === "asked") html += `<form class="row resume"><input type="text" placeholder="${esc(r.question||"")}" autofocus><button>Answer</button></form>`;
1028 if (r.output != null) html += `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>`;
1029 if (r.trace) html += `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>`;
1030 if (local && (r.reason === "source-unsupported" || (r.reason === "transport" && /may not let a page/.test(r.say || "")))) html += `<div class="mute" style="font-size:14px;margin-top:8px">This one needs a hub: a page cannot reach it. Join a hub above that runs <code>${esc(r.tree||"")}</code>, and the same sentence goes there.</div>`;
1031 if (r.reason === "gap" && r.output && r.output.gap) html += `<div class="mute" style="font-size:14px;margin-top:8px">This run met a gap. It is listed under <a href="#gaps">Gaps</a>; the branch to write is in <code>${esc(r.output.gap.tree)}</code>, node <code>${esc(r.output.gap.node)}</code>.</div>`;
1032 card.innerHTML = html;
1033 out.prepend(card);
1034 const rf = card.querySelector("form.resume");
1035 if (rf) rf.addEventListener("submit", (e) => { e.preventDefault(); ask(r.snapshot, rf.querySelector("input").value); });
1036 if (r.reason === "no-match") loadUnanswered();
1037 $("#go").disabled = false;
1038}
1039
1040$("#ask").addEventListener("submit", (e) => { e.preventDefault(); ask(); });
1041
1042// ---------------------------------------------------------------- suggestions in the ask box
1043// The sentences this swarm's trees answer to, and the ones asked in this
1044// browser before. The best that starts with what is typed shows greyed after
1045// it (Tab or → takes it; Tab in the empty box takes the placeholder), and up to
1046// eight drop down under the box: ↑ ↓ pick, Enter asks, Esc closes. A sentence
1047// with a blank ("latest release of …") fills the box up to the blank instead of asking.
1048const ASKED_KEY = "pyrtan.console.asked";
1049const suggest = {
1050 pool: [], items: [], active: -1, typed: "", open: false,
1051 input: $("#text"), list: $("#suggestions"),
1052 recent() { try { const r = JSON.parse(localStorage.getItem(ASKED_KEY) || "[]"); return Array.isArray(r) ? r.filter(s => typeof s === "string") : []; } catch (e) { return []; } },
1053 remember(text) {
1054 const r = [text, ...this.recent().filter(s => s.toLowerCase() !== text.toLowerCase())].slice(0, 20);
1055 try { localStorage.setItem(ASKED_KEY, JSON.stringify(r)); } catch (e) {}
1056 },
1057 from(trees) {
1058 const seen = new Set(); this.pool = [];
1059 for (const t of trees) for (const text of samples(t)) { const k = text.toLowerCase(); if (!seen.has(k)) { seen.add(k); this.pool.push({text, tree: t.name}); } }
1060 },
1061 // Recent first, then the trees': those that start with what is typed, then those with every typed word starting one of theirs.
1062 match(q) {
1063 const low = q.toLowerCase(), words = low.split(/\s+/).filter(Boolean), seen = new Set(), out = [];
1064 const add = (e) => { const k = e.text.toLowerCase(); if (!seen.has(k) && out.length < 8) { seen.add(k); out.push(e); } };
1065 const all = [...this.recent().map(text => ({text, recent: true})), ...this.pool];
1066 for (const e of all) if (e.text.toLowerCase().startsWith(low)) add(e);
1067 for (const e of all) { const ws = e.text.toLowerCase().split(/\s+/); if (words.every(w => ws.some(x => x.startsWith(w)))) add(e); }
1068 return out;
1069 },
1070 fill(e) { const i = e.text.indexOf("…"); return i < 0 ? e.text : e.text.slice(0, i); },
1071 // The rest of the first suggestion that starts with what is typed, while the caret is at the end.
1072 completion() {
1073 const el = this.input, v = el.value, low = v.toLowerCase();
1074 if (!v || document.activeElement !== el || el.selectionStart !== v.length || el.selectionEnd !== v.length) return "";
1075 const e = this.items.find(e => { const f = this.fill(e); return f.length > v.length && f.toLowerCase().startsWith(low); });
1076 return e ? this.fill(e).slice(v.length) : "";
1077 },
1078 ghost() {
1079 const rest = this.input.scrollWidth <= this.input.clientWidth ? this.completion() : ""; // scrolled text would not line up
1080 $("#ghost .typed").textContent = rest ? this.input.value : "";
1081 $("#ghost .rest").textContent = rest;
1082 },
1083 render() {
1084 const v = this.typed, low = v.toLowerCase();
1085 this.list.innerHTML = "";
1086 this.items.forEach((e, i) => {
1087 const li = document.createElement("li");
1088 li.id = "suggestion-" + i; li.setAttribute("role", "option"); li.setAttribute("aria-selected", String(i === this.active));
1089 const head = v && e.text.toLowerCase().startsWith(low) ? v.length : v ? 0 : e.text.length; // what is typed plain, the rest bold
1090 li.innerHTML = `<span class="text">${esc(e.text.slice(0, head))}<b>${esc(e.text.slice(head))}</b></span><span class="from">${e.recent ? "recent" : esc(e.tree)}</span>`;
1091 li.addEventListener("click", () => this.choose(e));
1092 this.list.appendChild(li);
1093 });
1094 const shown = this.open && this.items.length > 0;
1095 this.list.hidden = !shown; this.input.setAttribute("aria-expanded", String(shown));
1096 if (shown && this.active >= 0) this.input.setAttribute("aria-activedescendant", "suggestion-" + this.active); else this.input.removeAttribute("aria-activedescendant");
1097 },
1098 refresh() {
1099 this.typed = this.input.value; this.active = -1;
1100 this.items = this.open ? this.match(this.typed) : [];
1101 this.render(); this.ghost();
1102 },
1103 close() { this.open = false; this.refresh(); },
1104 choose(e) {
1105 this.input.value = this.fill(e); this.input.focus();
1106 if (e.text.includes("…")) { this.open = true; this.refresh(); } else ask();
1107 },
1108 key(ev) {
1109 if (ev.isComposing || ev.ctrlKey || ev.altKey || ev.metaKey) return;
1110 const shown = !this.list.hidden;
1111 if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
1112 ev.preventDefault();
1113 if (!shown) { this.open = true; this.refresh(); return; }
1114 // -1 is what was typed: the list wraps through it, as a search box's does.
1115 const n = this.items.length + 1;
1116 this.active = ((this.active + 1 + (ev.key === "ArrowDown" ? 1 : -1)) % n + n) % n - 1;
1117 this.input.value = this.active < 0 ? this.typed : this.fill(this.items[this.active]);
1118 this.render(); this.ghost();
1119 } else if ((ev.key === "Tab" && !ev.shiftKey) || ev.key === "ArrowRight") {
1120 const v = this.input.value, rest = this.completion();
1121 const take = rest ? v + rest : ev.key === "Tab" && !v ? this.input.placeholder : "";
1122 if (!take) return; // nothing to complete: Tab moves on, → moves the caret
1123 ev.preventDefault(); this.input.value = take; this.open = true; this.refresh();
1124 } else if (ev.key === "Escape" && shown) {
1125 ev.preventDefault(); this.input.value = this.typed; this.close();
1126 } else if (ev.key === "Enter" && shown && this.active >= 0 && this.items[this.active].text.includes("…")) {
1127 ev.preventDefault(); this.choose(this.items[this.active]);
1128 }
1129 },
1130};
1131suggest.input.addEventListener("input", () => { suggest.open = true; suggest.refresh(); });
1132suggest.input.addEventListener("keydown", (e) => suggest.key(e));
1133suggest.input.addEventListener("keyup", () => suggest.ghost());
1134suggest.input.addEventListener("mousedown", () => { if (!suggest.open) { suggest.open = true; suggest.refresh(); } });
1135suggest.input.addEventListener("click", () => suggest.ghost());
1136suggest.input.addEventListener("focus", () => suggest.ghost());
1137suggest.input.addEventListener("blur", () => suggest.close());
1138suggest.list.addEventListener("mousedown", (e) => e.preventDefault()); // keep the box focused, so the click lands
1139window.addEventListener("resize", () => suggest.ghost());
1140$("#tries").addEventListener("click", (e) => { const a = e.target.closest("a[data-q]"); if (!a) return; e.preventDefault(); $("#text").value = a.dataset.q; location.hash = "#ask"; ask(); });
1141$("#connect-form").addEventListener("submit", (e) => {
1142 e.preventDefault();
1143 agentsUI.putAway();
1144 token = $("#hub-token").value;
1145 store.set(remote ? {url: $("#hub-url").value.trim(), token} : {token});
1146 load();
1147});
1148// ---------------------------------------------------------------- member
1149// A browser as a federation actor without an inbox. Everything it sends is
1150// signed the way hubs sign: HMAC-SHA256 over method, path, date and the
1151// SHA-256 of the canonical JSON body. HMAC is written out here (and SHA-256
1152// in identity.js) because crypto.subtle is not available on a plain http://
1153// LAN address. With a key (the Identity tab), it follows as that key.
1154const {sha256, canonical} = PyrtanIdentity;
1155const enc = new TextEncoder();
1156const hex = (b) => Array.from(b, x => x.toString(16).padStart(2, "0")).join("");
1157function hmac(keyText, msgBytes) {
1158 let key = enc.encode(keyText); if (key.length > 64) key = sha256(key);
1159 const k = new Uint8Array(64); k.set(key);
1160 const ipad = k.map(x => x ^ 0x36), opad = k.map(x => x ^ 0x5c);
1161 const inner = sha256(new Uint8Array([...ipad, ...msgBytes]));
1162 return hex(sha256(new Uint8Array([...opad, ...inner])));
1163}
1164function signHeaders(secret, actor, method, path, body) {
1165 const date = String(Math.floor(Date.now() / 1000));
1166 const digest = hex(sha256(enc.encode(body === null || body === undefined ? "" : canonical(body))));
1167 const mac = hmac(secret, enc.encode([method.toUpperCase(), path, date, digest].join("\n")));
1168 return {"X-Pyrtan-Actor": actor, "X-Pyrtan-Date": date, "X-Pyrtan-Signature": mac};
1169}
1170const randomHex = (n) => hex(crypto.getRandomValues(new Uint8Array(n)));
1171
1172const member = {
1173 hub: null, me: null, timer: null, log: [], directory: new Map(), db: null, runner: null, runs: 0,
1174 key() { return "pyrtan.member:" + (this.hub || ""); },
1175 load() { try { this.me = JSON.parse(localStorage.getItem(this.key()) || "null"); } catch (e) { this.me = null; } },
1176 save() { try { if (this.me) localStorage.setItem(this.key(), JSON.stringify(this.me)); else localStorage.removeItem(this.key()); } catch (e) {} },
1177 note(line) { this.log.unshift(new Date().toLocaleTimeString() + " " + line); this.log = this.log.slice(0, 40); const el = $("#member-log"); if (el) el.textContent = this.log.join("\n"); },
1178 async open() {
1179 if (this.db) return this.db;
1180 this.db = await new Promise((res, rej) => { const r = indexedDB.open("pyrtan-member", 1); r.onupgradeneeded = () => r.result.createObjectStore("kept", {keyPath: "id"}); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
1181 return this.db;
1182 },
1183 async keptAll() { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("kept").objectStore("kept").getAll(); r.onsuccess = () => res(r.result.filter(b => b.hub === this.hub)); r.onerror = () => rej(r.error); }); },
1184 async keptGet(key) { const db = await this.open(); return new Promise((res, rej) => { const r = db.transaction("kept").objectStore("kept").get(this.hub + "|" + key); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); },
1185 async keptPut(blob) { const db = await this.open(); return new Promise((res, rej) => { const t = db.transaction("kept", "readwrite"); t.objectStore("kept").put({...blob, id: this.hub + "|" + blob.key, hub: this.hub}); t.oncomplete = () => res(); t.onerror = () => rej(t.error); }); },
1186 async keptClear() { const all = await this.keptAll(); const db = await this.open(); await new Promise((res) => { const t = db.transaction("kept", "readwrite"); for (const b of all) t.objectStore("kept").delete(b.id); t.oncomplete = () => res(); }); },
1187 async send(method, path, body) {
1188 if (!this.me) throw new Error("not a member");
1189 const headers = Object.assign({"Accept": "application/json"}, signHeaders(this.me.secret, this.me.id, method, path, body === undefined ? null : body));
1190 if (body !== undefined) headers["Content-Type"] = "application/json";
1191 const r = await fetch((base ? base.replace(/\/+$/, "") : "") + path, {method, headers, body: body === undefined ? undefined : canonical(body)});
1192 let data = null; try { data = await r.json(); } catch (e) {}
1193 return {status: r.status, data};
1194 },
1195 activity(type, object, extra) { return Object.assign({type, id: this.me.id + "/activities/" + randomHex(8), actor: this.me.id, published: new Date().toISOString().replace(/\.\d+Z$/, "Z"), object}, extra || {}); },
1196 async join(name, fedSecret) {
1197 const keyed = ident.active();
1198 this.me = {id: keyed ? ident.key.id : "urn:pyrtan:member:" + randomHex(16), secret: randomHex(32), name: name || "browser", state: "pending", listed: true, joined: Date.now(), trusted: false};
1199 this.save();
1200 const act = this.activity("Follow", this.hub, {secret: this.me.secret, inbox: "mailbox", name: this.me.name});
1201 const headers = Object.assign({"Accept": "application/json", "Content-Type": "application/json"}, signHeaders(fedSecret || this.me.secret, this.me.id, "POST", "/inbox", act));
1202 if (keyed) Object.assign(headers, await ident.headers("POST", "/inbox", act)); // a hub that grants this key, or its root, run makes it trusted
1203 if (token) headers["Authorization"] = "Bearer " + token; // the operator's token makes this device trusted: it runs the hub's trees
1204 const r = await fetch((base ? base.replace(/\/+$/, "") : "") + "/inbox", {method: "POST", headers, body: canonical(act)});
1205 const data = await r.json().catch(() => ({}));
1206 if (r.status !== 202) { this.me = null; this.save(); this.note(`follow refused: ${data.say || r.status}`); problem(`The hub did not take the follow: ${data.say || r.status}`, true); this.render(); return; }
1207 this.me.trusted = !!data.trusted; this.me.identity = data.identity || null; this.save();
1208 this.note(`followed ${this.hub}` + (this.me.trusted ? " as a trusted device" : ""));
1209 await this.poll();
1210 this.start();
1211 this.render();
1212 },
1213 async leave() {
1214 try { await this.send("POST", "/inbox", this.activity("Undo", {type: "Follow", actor: this.me.id, object: this.hub})); } catch (e) {}
1215 await this.keptClear();
1216 this.me = null; this.save(); this.stop(); this.note("left"); this.render();
1217 },
1218 async poll() {
1219 if (!this.me) return;
1220 let r; try { r = await this.send("GET", "/mailbox"); } catch (e) { this.note("poll failed: " + e.message); return; }
1221 if (r.status === 401) { this.note("the hub no longer knows this member"); this.me.state = "refused"; this.save(); this.render(); return; }
1222 this.me.polled = Date.now();
1223 for (const a of (r.data && r.data.activities) || []) {
1224 if (a.type === "Accept") { this.me.state = "accepted"; this.note("accepted"); }
1225 else if (a.type === "Announce") { for (const e of a.object.directory || []) this.directory.set(e.id, e); this.note(`announce: ${(a.object.trees||[]).length} trees, ${(a.object.directory||[]).length} directory entries`); }
1226 else if (a.type === "Keep") { await this.keptPut(a.object); if (a.object.key.startsWith("trees/")) this.hold(a.object.data); await this.send("POST", "/inbox", this.activity("Kept", {key: a.object.key, sha256: a.object.sha256})); this.note(`keeping ${a.object.key} (${a.object.size} B)`); }
1227 else if (a.type === "Ask") { await this.answer(a.object); }
1228 else if (a.type === "Fetch") { const b = await this.keptGet(a.object.key); if (b) { await this.send("POST", "/inbox", this.activity("Kept", {key: b.key, sha256: b.sha256, data: b.data})); this.note(`handed back ${b.key}`); } else this.note(`asked for ${a.object.key}, not kept here`); }
1229 else if (a.type === "Reject") { this.me.state = "rejected"; this.note("rejected: " + (a.summary || "")); }
1230 }
1231 this.save(); this.render();
1232 },
1233 // ---- running trees: the hub hands them over as trees/<name> blobs; Ask runs one here, Answer goes back.
1234 ensureRunner() {
1235 if (this.runner) return this.runner;
1236 const self = this;
1237 const resolver = { // a member may ask the hub it follows: delegate goes there, signed
1238 async runTree(name, inputs, depth) { const r = await self.send("POST", "/run", {tree: name, inputs, depth}); return r.data; },
1239 async runIntent(text, depth) { const r = await self.send("POST", "/intent", {text, depth}); return r.data; },
1240 };
1241 this.runner = new Pyrtan.Runner({transport: new Pyrtan.FetchTransport(), resolver});
1242 return this.runner;
1243 },
1244 hold(raw) { try { this.ensureRunner().add(raw); } catch (e) { this.note(`tree refused: ${e.message}`); } },
1245 async restoreTrees() { for (const b of await this.keptAll()) if (b.key.startsWith("trees/")) this.hold(b.data); },
1246 capabilities() { return this.runner ? Object.values(this.runner.trees).map(t => t.capability()) : []; },
1247 async answer(ask) {
1248 const runner = this.ensureRunner();
1249 let outcome;
1250 try { outcome = Pyrtan.outcomeDict(await runner.runTree(ask.tree, ask.inputs || {}, ask.depth || 0)); }
1251 catch (e) { outcome = {status: "error", tree: ask.tree, reason: "member-error", say: String(e && e.message || e)}; }
1252 this.runs++;
1253 await this.send("POST", "/inbox", this.activity("Answer", {run: ask.run, outcome}));
1254 this.note(`ran ${ask.tree} for the hub: ${outcome.status}` + (outcome.say ? ` · ${String(outcome.say).slice(0, 80)}` : ""));
1255 this.render();
1256 },
1257 async announce() {
1258 if (!this.me || this.me.state !== "accepted") return;
1259 const kept = (await this.keptAll()).map(b => b.key).sort();
1260 const trees = this.me.trusted ? this.capabilities() : [];
1261 const own = this.me.listed ? [{id: this.me.id, name: this.me.name, kind: "member", trees: trees.map(t => t.name), seen: Date.now() / 1000}] : [];
1262 const heard = [...this.directory.values()].filter(e => e.id !== this.me.id);
1263 try {
1264 const r = await this.send("POST", "/inbox", this.activity("Announce", {type: "Capabilities", name: this.me.name, trees, directory: own.concat(heard), kept}));
1265 for (const e of (r.data && r.data.directory) || []) this.directory.set(e.id, e);
1266 } catch (e) { this.note("announce failed: " + e.message); }
1267 },
1268 start() {
1269 this.load();
1270 if (!this.me || this.timer) { this.render(); return; }
1271 // A trusted device answers Asks, so it polls faster: a sentence waits one poll.
1272 this.timer = setInterval(() => this.poll(), this.me.trusted ? 4000 : 20000);
1273 this.announceTimer = setInterval(() => this.announce(), 60000);
1274 this.restoreTrees().then(() => this.poll()).then(() => this.announce());
1275 },
1276 stop() { clearInterval(this.timer); clearInterval(this.announceTimer); this.timer = null; },
1277 async render() {
1278 if (!$("#member-on")) return;
1279 $("#member-off").hidden = !!this.me; $("#member-on").hidden = !this.me;
1280 if (!this.me) return;
1281 $("#member-id").textContent = this.me.id; $("#member-state").textContent = this.me.state;
1282 $("#member-name-shown").textContent = this.me.name; $("#member-polled").textContent = this.me.polled ? new Date(this.me.polled).toLocaleTimeString() : "never";
1283 $("#member-listed").checked = !!this.me.listed;
1284 $("#member-trust").textContent = this.me.trusted ? "trusted: runs the hub's trees" + (this.me.identity ? ` (as ${PyrtanIdentity.short(this.me.identity)})` : "") : "keeper only (join with the hub's token, or as a key it grants run, to run trees)";
1285 const tb = $("#member-trees tbody"); tb.innerHTML = "";
1286 const sel = $("#member-run-tree"); const chosen = sel.value; sel.innerHTML = "";
1287 for (const t of this.capabilities()) {
1288 const tr = document.createElement("tr"); tr.innerHTML = `<td><code>${esc(t.name)}</code> <span class="mute">v${esc(t.version)}</span></td><td>${(t.triggers||[]).map(p => `<div><code>${esc(p)}</code></div>`).join("")}</td><td class="mute">${esc((t.hosts||[]).join(", "))}</td>`; tb.appendChild(tr);
1289 const o = document.createElement("option"); o.value = t.name; o.textContent = t.name; sel.appendChild(o);
1290 }
1291 if (chosen) sel.value = chosen;
1292 if (!tb.children.length) tb.innerHTML = `<tr><td colspan="3" class="mute">${this.me.trusted ? "None handed over yet; the hub sends what this device should run, and this device picks it up on its next poll." : "A keeper runs nothing."}</td></tr>`;
1293 const blobs = await this.keptAll();
1294 const body = $("#kept tbody"); body.innerHTML = ""; // cleared after the await, so two renders at once do not double the rows
1295 for (const b of blobs) { const tr = document.createElement("tr"); tr.innerHTML = `<td><code>${esc(b.key)}</code></td><td>${esc(b.size)} B</td><td class="mute">${b.updated ? new Date(b.updated * 1000).toLocaleString() : ""}</td><td><a href="#" data-show="${esc(b.key)}">show</a></td>`; body.appendChild(tr); }
1296 if (!body.children.length) body.innerHTML = `<tr><td colspan="4" class="mute">Nothing yet. The hub sends its blobs as it needs keepers; "Poll now" asks sooner.</td></tr>`;
1297 },
1298};
1299$("#join-form").addEventListener("submit", (e) => { e.preventDefault(); member.join($("#member-name").value.trim(), $("#member-fedsecret").value); });
1300$("#member-poll").addEventListener("click", () => member.poll().then(() => member.announce()));
1301$("#member-leave").addEventListener("click", () => member.leave());
1302$("#member-run").addEventListener("submit", async (e) => {
1303 e.preventDefault();
1304 const name = $("#member-run-tree").value; if (!name) return;
1305 let inputs = {}; try { inputs = JSON.parse($("#member-run-inputs").value || "{}"); } catch (err) { problem("inputs must be JSON", true); return; }
1306 const t0 = performance.now();
1307 const r = Pyrtan.outcomeDict(await member.ensureRunner().runTree(name, inputs));
1308 const card = document.createElement("div"); card.className = "card";
1309 card.innerHTML = `<div><span class="status ${esc(r.status)}">${esc(r.status)}</span>${r.reason ? ` <span class="mute">(${esc(r.reason)})</span>` : ""} <span class="mute">· ${esc(r.tree||"")} · ${Math.round(performance.now() - t0)} ms · ran in this browser</span></div>${r.say ? `<div class="say">${esc(r.say)}</div>` : ""}${r.output != null ? `<details open><summary>output</summary><pre>${esc(JSON.stringify(r.output, null, 2))}</pre></details>` : ""}${r.trace ? `<details open><summary>trace</summary><pre>${esc(renderTrace(r.trace))}</pre></details>` : ""}`;
1310 $("#member-out").prepend(card);
1311});
1312$("#member-listed").addEventListener("change", (e) => { member.me.listed = e.target.checked; member.save(); member.announce(); });
1313$("#kept").addEventListener("click", async (e) => { const a = e.target.closest("a[data-show]"); if (!a) return; e.preventDefault(); const b = await member.keptGet(a.dataset.show); alert(b ? JSON.stringify(b.data, null, 2).slice(0, 4000) : "not kept"); });
1314if ("serviceWorker" in navigator && (location.protocol === "https:" || ["localhost", "127.0.0.1"].includes(location.hostname))) { navigator.serviceWorker.register("sw.js").catch(() => {}); }
1315
1316// ------------------------------------------------------------ chat
1317// A seat in the hub's lobby: start, then long-poll for events (the hub
1318// echoes our own lines too, so the log is the hub's word, in its order).
1319const chat = {
1320 seat: null, after: 0, gen: 0, state: null, partner: null, sure: 0,
1321 indicator: false, partnerTyping: false, sentTyping: false, sentAt: 0,
1322 pref() { try { return localStorage.getItem("pyrtan.chat.typing") !== "0"; } catch (e) { return true; } },
1323 async post(path, body, extra) {
1324 const r = await api(path, Object.assign({method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body)}, extra || {}));
1325 if (r.status === 401) throw new Error("401: this Pyrtan needs a token");
1326 return r.json();
1327 },
1328 async count() {
1329 try { const s = await json("chat"); $("#chat-count").textContent = `Now: ${s.waiting} waiting, ${s.talking} talking, ${s.bots} bot${s.bots === 1 ? "" : "s"}` + (s.hubs ? `, and ${s.hubs} other hub${s.hubs === 1 ? "" : "s"} to meet strangers on.` : "."); $("#chat-typing-opt").hidden = s.typing !== true; } catch (e) {}
1330 },
1331 async start() {
1332 const interests = $("#chat-interests").value.split(/[,\s]+/).filter(Boolean);
1333 let r; try { r = await this.post("chat/start", {want: $("#chat-want").value, interests, typing_indicator: $("#chat-typing").checked}); } catch (e) { problem(e.message, true); return; }
1334 if (!r.seat) { problem(r.say || "The lobby did not take a seat.", true); return; }
1335 problem(""); this.seat = r.seat; this.after = 0; $("#chat-log").innerHTML = "";
1336 $("#chat-off").hidden = true; $("#chat-on").hidden = false;
1337 this.take(r); this.loop(++this.gen); $("#chat-text").focus();
1338 },
1339 take(r) {
1340 for (const e of r.events || []) if (e.n > this.after) { this.render(e); this.after = e.n; }
1341 this.state = r.state; this.partner = r.partner;
1342 this.indicator = !!r.typing_indicator; this.partnerTyping = !!r.partner_typing;
1343 $("#chat-typing-line").textContent = this.partnerTyping ? (r.partner && r.partner.kind === "bot" ? "Bot is thinking…" : "Stranger is typing…") : "";
1344 $("#chat-text").disabled = r.state !== "talking";
1345 $("#chat-text").placeholder = r.state === "talking" ? (r.partner && r.partner.kind === "bot" ? "ask the bot something" : "say something") : r.state === "waiting" ? "waiting for someone…" : "press New for someone else";
1346 this.resetNext();
1347 },
1348 async loop(gen) {
1349 while (this.seat && gen === this.gen) {
1350 // Saying what is shown lets the hub answer as soon as the stranger starts or stops typing.
1351 const ask = {seat: this.seat, after: this.after, wait: 20}; if (this.indicator) ask.partner_typing = this.partnerTyping;
1352 let r; try { r = await this.post("chat/poll", ask); } catch (e) { await new Promise(res => setTimeout(res, 2000)); continue; }
1353 if (gen !== this.gen) return;
1354 if (r.state === "left") return;
1355 if (r.reason === "no-seat") { this.line("sys", "This seat is gone (nobody polled it for a minute). Start again."); this.seat = null; this.take({state: "ended"}); return; }
1356 this.take(r);
1357 }
1358 },
1359 render(e) {
1360 if (e.kind === "system") return this.line("sys", e.text);
1361 const el = this.line(e.kind, e.text, {you: "You", stranger: "Stranger", bot: "Bot"}[e.kind] || e.kind);
1362 if (e.kind === "bot" && (e.trace || e.tree)) {
1363 const d = document.createElement("details");
1364 d.innerHTML = `<summary>${esc([e.status, e.tree, e.peer && e.peer !== "local" ? "from " + e.peer : ""].filter(Boolean).join(" · "))}</summary>${e.trace ? `<pre>${esc(renderTrace(e.trace))}</pre>` : ""}`;
1365 el.appendChild(d);
1366 }
1367 },
1368 line(kind, text, who) {
1369 const log = $("#chat-log"), stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40;
1370 const el = document.createElement("div"); el.className = "line " + kind;
1371 el.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + esc(text);
1372 log.appendChild(el); if (stick) log.scrollTop = log.scrollHeight;
1373 return el;
1374 },
1375 async say() {
1376 const text = $("#chat-text").value.trim(); if (!text || !this.seat) return;
1377 $("#chat-text").value = ""; this.sentTyping = false; // the line ends the typing, on the hub too
1378 try { const r = await this.post("chat/say", {seat: this.seat, text}); if (r.status !== "ok") this.line("sys", r.say || r.reason); } catch (e) { this.line("sys", e.message); }
1379 },
1380 typed(on) {
1381 // Typing goes out when it starts or stops, and again every two seconds while it goes on.
1382 if (!this.seat || !this.indicator || this.state !== "talking" || (this.partner && this.partner.kind === "bot")) return;
1383 const now = Date.now();
1384 if (on === this.sentTyping && (!on || now - this.sentAt < 2000)) return;
1385 this.sentTyping = on; this.sentAt = now;
1386 this.post("chat/typing", {seat: this.seat, typing: on}).catch(() => {});
1387 },
1388 async setIndicator(on) {
1389 try { localStorage.setItem("pyrtan.chat.typing", on ? "1" : "0"); } catch (e) {}
1390 if (!this.seat) return;
1391 this.sentTyping = false;
1392 try { this.take(await this.post("chat/settings", {seat: this.seat, typing_indicator: on, after: this.after})); } catch (e) { this.line("sys", e.message); }
1393 },
1394 resetNext() { this.sure = 0; $("#chat-next").textContent = this.state === "talking" ? "Stop" : "New"; },
1395 async next() {
1396 // Like Omegle: while talking, the first press asks, the second ends it and finds someone new.
1397 if (this.state === "talking" && !this.sure) { this.sure = 1; $("#chat-next").textContent = "Really?"; setTimeout(() => this.resetNext(), 3000); return; }
1398 if (!this.seat) return this.start();
1399 this.sentTyping = false;
1400 try { this.take(await this.post("chat/next", {seat: this.seat, after: this.after, typing_indicator: $("#chat-typing").checked})); } catch (e) { this.line("sys", e.message); }
1401 },
1402 leave(keepalive) {
1403 if (!this.seat) return;
1404 const seat = this.seat; this.seat = null; this.gen++;
1405 this.post("chat/leave", {seat}, keepalive ? {keepalive: true} : {}).catch(() => {});
1406 },
1407};
1408$("#chat-start").addEventListener("submit", (e) => { e.preventDefault(); chat.start(); });
1409$("#chat-say").addEventListener("submit", (e) => { e.preventDefault(); chat.say(); });
1410$("#chat-text").addEventListener("input", () => chat.typed(!!$("#chat-text").value.trim()));
1411$("#chat-text").addEventListener("blur", () => chat.typed(false));
1412$("#chat-typing").checked = chat.pref();
1413$("#chat-typing").addEventListener("change", (e) => chat.setIndicator(e.target.checked));
1414$("#chat-next").addEventListener("click", () => chat.next());
1415$("#chat-stop").addEventListener("click", (e) => { e.preventDefault(); chat.leave(); $("#chat-on").hidden = true; $("#chat-off").hidden = false; chat.count(); });
1416document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !$("#tab-chat").hidden && chat.seat) { e.preventDefault(); chat.next(); } });
1417window.addEventListener("pagehide", () => chat.leave(true));
1418
1419// ---------------------------------------------------------------- agents
1420// A model beside the swarm, never inside it (agents.js, _meta/agents.md).
1421// It reaches the swarm only through this page's own calls, and what it
1422// suggests is applied here only when you accept it, and only to the trees
1423// this browser holds; a hub keeps it at /suggestions for pyrtan grow.
1424const agentsUI = {
1425 agent: null, config: {}, skills: null, busy: false,
1426 init() {
1427 try { this.config = JSON.parse(localStorage.getItem("pyrtan.agents.config") || "{}"); } catch (e) { this.config = {}; }
1428 $("#agent-model").innerHTML = PyrtanAgents.MODELS.map(m => `<option value="${esc(m.id)}">${esc(m.label)} · ${esc(m.licence)} · ${esc(m.size)}</option>`).join("");
1429 $("#agent-name").value = this.config.name || "scout";
1430 $("#agent-engine").value = this.config.engine || (PyrtanAgents.WebLLMEngine.available() ? "webllm" : "openai");
1431 $("#agent-model").value = this.config.model || PyrtanAgents.DEFAULT_MODEL;
1432 $("#agent-url").value = this.config.url || "http://localhost:11434/v1";
1433 $("#agent-server-model").value = this.config.serverModel || "qwen2.5:3b";
1434 for (const el of document.querySelectorAll(".origin-here")) el.textContent = location.origin;
1435 this.skills = new PyrtanAgents.Skills(PyrtanAgents.browserStorage("pyrtan.agents:"));
1436 this.engineChanged();
1437 },
1438 engineChanged() {
1439 const web = $("#agent-engine").value === "webllm";
1440 $("#agent-model").hidden = !web; $("#agent-url").hidden = web; $("#agent-server-model").hidden = web;
1441 $("#agent-gpu").hidden = !web || PyrtanAgents.WebLLMEngine.available();
1442 },
1443 where() { return local ? "this browser" : (base || location.origin); },
1444 hub(method, path, body) { return json(path, method === "GET" ? undefined : {method, headers: {"Content-Type": "application/json"}, body: JSON.stringify(body || {})}); },
1445 raw(name) { const t = local && local.runner.get(name); return t ? t.raw : null; },
1446 async start() {
1447 const engineKind = $("#agent-engine").value;
1448 this.config = {name: $("#agent-name").value.trim() || "scout", engine: engineKind, model: $("#agent-model").value, url: $("#agent-url").value.trim(), serverModel: $("#agent-server-model").value.trim()};
1449 try { localStorage.setItem("pyrtan.agents.config", JSON.stringify(this.config)); } catch (e) {}
1450 const progress = (text) => { $("#agent-progress").textContent = text; };
1451 const engine = engineKind === "webllm"
1452 ? new PyrtanAgents.WebLLMEngine({model: this.config.model, onProgress: (text) => progress(text)})
1453 : new PyrtanAgents.OpenAIEngine({url: this.config.url, model: this.config.serverModel});
1454 $("#agent-go").disabled = true;
1455 progress(engineKind === "webllm" ? "Loading WebLLM and the model; the first time, this is a download…" : `Asking ${this.config.url}…`);
1456 try {
1457 await engine.load();
1458 if (engineKind === "openai") await engine.complete([{role: "user", content: "Say ok."}], {maxTokens: 4});
1459 } catch (e) { progress(""); problem(`The agent could not start: ${e.message}`, true); $("#agent-go").disabled = false; return; }
1460 progress("");
1461 const hubKey = local ? "browser" : (base || location.origin);
1462 this.agent = new PyrtanAgents.Agent({name: this.config.name, engine, hub: (m, p, b) => this.hub(m, p, b), raw: (n) => this.raw(n), storage: PyrtanAgents.browserStorage(`pyrtan.agents:${hubKey}:`), skills: this.skills, onEvent: (e) => this.event(e)});
1463 $("#agent-go").disabled = false;
1464 $("#agent-setup").hidden = true; $("#agent-on").hidden = false;
1465 $("#agent-who").textContent = this.agent.name;
1466 const m = PyrtanAgents.MODELS.find(x => x.id === engine.model);
1467 $("#agent-what").textContent = `${m ? m.label : engine.model} · ${engineKind === "webllm" ? "in this browser" : "on " + this.config.url} · beside ${this.where()}`;
1468 $("#agent-log").innerHTML = "";
1469 for (const msg of this.agent.memory.chat.slice(-10)) this.line(msg.role === "you" ? "you" : "bot", msg.role === "you" ? "You" : this.agent.name, esc(msg.content));
1470 this.sys(`${this.agent.name} is a model, not a tree: what it says is its own, and what a tree says comes with the tree's name and path. /help lists what it can do.`);
1471 this.render();
1472 },
1473 putAway() {
1474 if (!this.agent) return;
1475 this.agent.stop();
1476 if (this.agent.engine.unload) this.agent.engine.unload().catch(() => {});
1477 this.agent = null; $("#agent-setup").hidden = false; $("#agent-on").hidden = true;
1478 },
1479 line(cls, who, html, extra) {
1480 const log = $("#agent-log"), div = document.createElement("div");
1481 div.className = "line " + cls;
1482 div.innerHTML = (who ? `<span class="who">${esc(who)}:</span>` : "") + html + (extra || "");
1483 log.appendChild(div); log.scrollTop = log.scrollHeight;
1484 },
1485 sys(text) { this.line("sys", "", esc(text)); },
1486 event(e) {
1487 if (e.type === "thinking") $("#agent-typing").textContent = `${e.agent} is thinking (${e.job})…`;
1488 else if (e.type === "probe") { const r = e.result; this.sys(`probe ${e.tree}: “${r.text}” → ${r.read === null ? "no tree reads it" : r.read === "tie" ? "a tie" : r.read === e.tree ? (r.status ? `${r.status}${r.reason ? " (" + r.reason + ")" : ""}` : "reads, not run") : "reads as " + r.read}`); }
1489 else if (e.type === "refused") this.sys(`refused by the rules: ${e.what}: ${e.why}`);
1490 else if (e.type === "suggestion") { this.sys(`suggests a ${e.item.suggestion.kind} for ${e.item.suggestion.tree || "“" + e.item.suggestion.text + "”"}`); this.renderSuggestions(); }
1491 else if (e.type === "patrol" && e.step !== "done") this.sys(e.step === "probe" ? `patrol: probing ${e.tree}` : `patrol: the ${e.step}`);
1492 else if (e.type === "action") { const a = e.action; if (a.kind === "ask" && a.result) this.line("tree", a.result.tree || "no tree", esc(a.result.say || a.result.status), a.result.path && a.result.path.length ? `<details><summary>path</summary><code>${esc(a.result.path.join(" > "))}</code></details>` : ""); else if (a.kind === "remember") this.sys(`remembered: ${a.input}`); }
1493 },
1494 async run(fn) {
1495 if (this.busy) return; this.busy = true; $("#agent-stop").disabled = false;
1496 try { await fn(); } catch (e) { this.sys(`error: ${e.message}`); }
1497 finally { this.busy = false; $("#agent-stop").disabled = true; $("#agent-typing").textContent = ""; this.renderMemory(); }
1498 },
1499 say(text) {
1500 if (!this.agent || !text) return;
1501 this.line("you", "You", esc(text));
1502 return this.run(async () => {
1503 const r = await this.agent.chat(text);
1504 if (r.outcome) this.line("tree", r.outcome.tree || "no tree", esc(r.outcome.say || r.outcome.status), (r.outcome.trace ? `<details><summary>trace</summary><pre>${esc(renderTrace(r.outcome.trace))}</pre></details>` : ""));
1505 else if (r.say) this.line("bot", this.agent.name, esc(r.say).replace(/\n/g, "<br>"));
1506 });
1507 },
1508 patrol() { return this.run(async () => { const r = await this.agent.patrol(); this.line("bot", this.agent.name, esc(`Patrolled ${r.trees.length} trees: ${r.tally.sentences} sentences, ${r.tally.read} read, ${r.tally.unread} read to nothing, ${r.tally.misread} to another tree, ${r.tally.gaps} met a gap. ${r.suggestions.length} suggestions, ${r.refused.length} refused by the rules.${r.stopped ? " (stopped)" : ""}`)); }); },
1509 // Accepting: in a browser that is its own hub, the tree grows here and now; any hub keeps the suggestion for pyrtan grow.
1510 async accept(id) {
1511 const item = this.agent.suggestions.find(x => x.id === id); if (!item) return;
1512 const s = item.suggestion; let applied = "";
1513 if (local && (s.kind === "branch" || s.kind === "trigger")) {
1514 const raw = this.raw(s.tree);
1515 try {
1516 let grown = null;
1517 if (raw && s.kind === "branch") { const d = Pyrtan.grow.draftBranch(raw, s); if (d) grown = Pyrtan.grow.growTree(raw, d, s.meaning); }
1518 if (raw && s.kind === "trigger") { grown = JSON.parse(JSON.stringify(raw)); grown.triggers = (grown.triggers || []).concat([{pattern: s.pattern}]); }
1519 if (grown) { new Pyrtan.Tree(grown); local.add(grown, local.held[s.tree]); applied = "grown in this page"; }
1520 } catch (e) { this.sys(`could not grow ${s.tree} here: ${e.message}`); return; }
1521 }
1522 let kept = "";
1523 try { const out = await this.agent.send([item]); kept = (out.refused || []).length ? `refused by the hub: ${out.refused[0].why}` : `kept at ${this.where()}'s /suggestions`; }
1524 catch (e) { kept = `not kept: ${e.message}${/404/.test(e.message) ? " (a hub older than 0.18 has no /suggestions; download it instead)" : ""}`; }
1525 this.agent.decide(id, "accepted");
1526 item.note = [applied, kept].filter(Boolean).join(" · ");
1527 if (applied) { try { renderCaps(await json("capabilities")); await this.agent.trees(true); } catch (e) {} }
1528 this.renderSuggestions(); this.renderMemory();
1529 },
1530 dismiss(id) {
1531 const why = prompt("Why not? The agent remembers. (optional)") || "";
1532 this.agent.decide(id, "dismissed", why.trim()); this.renderSuggestions(); this.renderMemory();
1533 },
1534 download() {
1535 const items = (this.agent ? this.agent.suggestions : []).filter(x => x.status === "accepted").map(x => x.suggestion);
1536 if (!items.length) { this.sys("Nothing accepted yet."); return; }
1537 const a = document.createElement("a");
1538 a.href = URL.createObjectURL(new Blob([JSON.stringify({"pyrtan-suggestions": 1, suggestions: items}, null, 2)], {type: "application/json"}));
1539 a.download = "pyrtan-suggestions.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
1540 this.sys("Saved. Then: pyrtan grow --suggestions pyrtan-suggestions.json --trees DIR --apply");
1541 },
1542 render() { if (!this.skills) this.init(); this.renderSuggestions(); this.renderMemory(); this.renderSkills(); },
1543 renderSuggestions() {
1544 const box = $("#agent-sugs"); if (!box) return;
1545 const items = this.agent ? this.agent.suggestions.slice().reverse() : [];
1546 if (!items.length) { box.innerHTML = '<p class="mute">Nothing yet. Press Patrol, or ask it to <code>/probe</code> a tree.</p>'; return; }
1547 box.innerHTML = items.map(x => {
1548 const s = x.suggestion, by = s.by || {};
1549 const what = s.kind === "branch" ? `<code>${esc(s.tree)}</code> · <code>${esc(s.node)}</code>: <code>${esc(s.on || "")}</code> = <code class="gap">${esc(JSON.stringify(s.observed))}</code> means a <strong>${esc(s.meaning.kind)}</strong>${s.meaning.reason ? ` <span class="mute">(${esc(s.meaning.reason)})</span>` : ""}<div class="say">${esc(s.meaning.say)}</div>`
1550 : s.kind === "trigger" ? `<code>${esc(s.tree)}</code> should answer to <code>${esc(s.pattern)}</code><div class="mute" style="font-size:14px">reads ${(s.covers || []).map(c => "“" + esc(c) + "”").join(", ")}</div>`
1551 : `a new tree for “${esc(s.text)}”<div class="say">${esc(s.description || "")}</div>${(s.hosts_hint || []).length ? `<div class="mute" style="font-size:14px">hosts it guessed (a note, never a host): ${esc(s.hosts_hint.join(", "))}</div>` : ""}`;
1552 const state = x.status === "new" ? `<div style="display:flex;gap:8px;margin-top:8px"><button type="button" data-accept="${x.id}">Accept</button><button type="button" class="quiet" data-dismiss="${x.id}">Dismiss</button></div>` : `<div class="mute sug-state" style="font-size:14px;margin-top:6px">${esc(x.status)}${x.note ? " · " + esc(x.note) : ""}</div>`;
1553 return `<div class="card"><div><span class="status asked">${esc(s.kind)}</span> <span class="mute">by ${esc(by.agent || "")}${by.model ? " · " + esc(by.model) : ""}</span></div><div style="margin-top:4px">${what}</div>${s.why ? `<div class="mute" style="font-size:14px">${esc(s.why)}</div>` : ""}${x.preview && x.preview.text ? `<details><summary>what it would write</summary><pre>${esc(x.preview.text)}</pre></details>` : ""}${state}</div>`;
1554 }).join("");
1555 },
1556 renderMemory() {
1557 const body = $("#agent-notes tbody"); if (!body) return;
1558 const notes = this.agent ? this.agent.memory.notes.slice().reverse() : [];
1559 body.innerHTML = notes.length ? notes.map(n => `<tr><td class="mute">${esc(n.source)}</td><td>${esc(n.text)}</td><td><button type="button" class="quiet" data-forget="${n.id}">Forget</button></td></tr>`).join("") : `<tr><td class="mute">${this.agent ? "Nothing kept yet." : "Start an agent to see what it remembers."}</td></tr>`;
1560 },
1561 renderSkills() {
1562 const body = $("#agent-skills tbody"); if (!body || !this.skills) return;
1563 body.innerHTML = this.skills.list().map(s => `<tr><td><code>${esc(s.name)}</code><div class="mute">${esc(s.description || "")}</div></td><td>${esc(s.when)}</td><td>${s.builtin ? '<span class="mute">built in</span>' : `<label><input type="checkbox" data-skill-on="${esc(s.name)}"${s.on ? " checked" : ""}> on</label> <button type="button" class="quiet" data-skill-remove="${esc(s.name)}">Remove</button>`}</td></tr>`).join("");
1564 },
1565};
1566$("#agent-engine").addEventListener("change", () => agentsUI.engineChanged());
1567$("#agent-start").addEventListener("submit", (e) => { e.preventDefault(); agentsUI.start(); });
1568$("#agent-say").addEventListener("submit", (e) => { e.preventDefault(); const t = $("#agent-text").value.trim(); $("#agent-text").value = ""; agentsUI.say(t); });
1569$("#agent-patrol").addEventListener("click", () => agentsUI.patrol());
1570$("#agent-stop").addEventListener("click", () => { if (agentsUI.agent) { agentsUI.agent.stop(); agentsUI.sys("stopping after this step…"); } });
1571$("#agent-off").addEventListener("click", () => agentsUI.putAway());
1572$("#agent-download").addEventListener("click", () => agentsUI.download());
1573$("#agent-sugs").addEventListener("click", (e) => { const a = e.target.closest("[data-accept]"), d = e.target.closest("[data-dismiss]"); if (a) agentsUI.accept(Number(a.dataset.accept)); if (d) agentsUI.dismiss(Number(d.dataset.dismiss)); });
1574$("#agent-note-form").addEventListener("submit", (e) => { e.preventDefault(); if (agentsUI.agent && $("#agent-note").value.trim()) { agentsUI.agent.memory.remember($("#agent-note").value, "you"); $("#agent-note").value = ""; agentsUI.renderMemory(); } });
1575$("#agent-notes").addEventListener("click", (e) => { const f = e.target.closest("[data-forget]"); if (f && agentsUI.agent) { agentsUI.agent.memory.forget(f.dataset.forget); agentsUI.renderMemory(); } });
1576$("#agent-forget-all").addEventListener("click", () => { if (agentsUI.agent && confirm("Forget everything this agent keeps for this hub: notes, probes, verdicts and the conversation?")) { agentsUI.agent.memory.clear(); agentsUI.renderMemory(); } });
1577$("#agent-skill-form").addEventListener("submit", (e) => {
1578 e.preventDefault();
1579 try { agentsUI.skills.add({name: $("#skill-name").value, when: $("#skill-when").value, description: $("#skill-desc").value, instructions: $("#skill-body").value}); for (const id of ["#skill-name", "#skill-desc", "#skill-body"]) $(id).value = ""; problem(""); }
1580 catch (err) { problem(err.message, true); }
1581 agentsUI.renderSkills();
1582});
1583$("#agent-skills").addEventListener("change", (e) => { const t = e.target.closest("[data-skill-on]"); if (t) agentsUI.skills.toggle(t.dataset.skillOn, t.checked); });
1584$("#agent-skills").addEventListener("click", (e) => { const r = e.target.closest("[data-skill-remove]"); if (r) { agentsUI.skills.remove(r.dataset.skillRemove); agentsUI.renderSkills(); } });
1585$("#skill-import").addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; try { const got = agentsUI.skills.import(await f.text()); problem(""); if (agentsUI.agent) agentsUI.sys(`imported ${got.map(s => s.name).join(", ")}`); } catch (err) { problem(`${f.name}: ${err.message}`, true); } e.target.value = ""; agentsUI.renderSkills(); });
1586$("#skill-export").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([agentsUI.skills.export()], {type: "application/json"})); a.download = "pyrtan-skills.json"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); });
1587agentsUI.init();
1588
1589// Forgetting the hub makes this browser its own hub again.
1590$("#forget").addEventListener("click", () => { chat.leave(true); agentsUI.putAway(); store.clear(); base = ""; token = ""; member.stop(); $("#hub-url").value = ""; $("#hub-token").value = ""; $("#caps tbody").innerHTML = ""; $("#tries").innerHTML = ""; problem(""); if (remote) { const q = new URLSearchParams(location.search); if (q.has("hub")) { q.delete("hub"); history.replaceState(null, "", location.pathname + (q.toString() ? "?" + q : "") + location.hash); } load(); } });
1591ident.init().finally(load);
1592</script>
1593</body>
1594</html>
1595""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS).replace("__IDENTITY__", IDENTITY_JS).replace("__AGENTS__", AGENTS_JS)
1596
1597MANIFEST = json.dumps({
1598 "name": "Pyrtan",
1599 "short_name": "Pyrtan",
1600 "description": "The console of a Pyrtan swarm, and a member of it: install it and this device keeps the hub's blobs.",
1601 "start_url": "./",
1602 "scope": "./",
1603 "display": "standalone",
1604 "background_color": "#fbfaf7",
1605 "theme_color": "#c2410c",
1606 "icons": [{"src": "data:image/svg+xml," + urllib.parse.quote('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#c2410c"/><path d="M18 46V18h14a9 9 0 0 1 0 18h-8" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>'), "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],
1607}, indent=2)
1608
1609# The service worker keeps the console shell so the page opens offline (a
1610# phone in a tunnel, a hub that is down): the shell comes from the cache,
1611# everything the page asks the hub for goes to the network and nowhere else.
1612SERVICE_WORKER = r"""const SHELL = "pyrtan-shell-__VERSION__";
1613const FILES = ["./", "manifest.webmanifest", "pyrtan.js"];
1614self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then(c => c.addAll(FILES)).then(() => self.skipWaiting())); });
1615self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== SHELL).map(k => caches.delete(k)))).then(() => self.clients.claim())); });
1616self.addEventListener("fetch", (e) => {
1617 const url = new URL(e.request.url);
1618 const shell = e.request.mode === "navigate" || url.pathname.endsWith("/manifest.webmanifest");
1619 if (!shell || e.request.method !== "GET") return; // API calls: the network, always
1620 e.respondWith(fetch(e.request).then(r => { const copy = r.clone(); caches.open(SHELL).then(c => c.put(e.request, copy)); return r; }).catch(() => caches.match(e.request).then(r => r || caches.match("./"))));
1621});
1622""".replace("__VERSION__", __version__)