agent-yes 1.231.0 → 1.232.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/{SUPPORTED_CLIS-Cj00WOEz.js → SUPPORTED_CLIS-BP1BBtWO.js} +3 -3
  2. package/dist/{SUPPORTED_CLIS-BS2hYjrW.js → SUPPORTED_CLIS-D6J7QvLL.js} +2 -2
  3. package/dist/{agentShare-BBLXfmbU.js → agentShare-CDMFg0Rj.js} +2 -2
  4. package/dist/{callback-B8Bx8c-9.js → callback-B1kcLI1k.js} +3 -3
  5. package/dist/{callback-beueK8jU.js → callback-sBpq-n_6.js} +2 -2
  6. package/dist/{channels-mAf4ztTJ.js → channels-Boi4aNiY.js} +24 -6
  7. package/dist/{channels-CUOl2wrG.js → channels-Bu4DZzHk.js} +15 -2
  8. package/dist/channels.js +19 -3
  9. package/dist/cli.js +4 -4
  10. package/dist/index.js +2 -2
  11. package/dist/{notifyDaemon-CJfxTj46.js → notifyDaemon-BNCj9i2N.js} +2 -2
  12. package/dist/{rustBinary-d4ZUStzi.js → rustBinary-D8_DgkiI.js} +2 -2
  13. package/dist/{schedule-B4v3unjv.js → schedule-BVVQtiaS.js} +4 -4
  14. package/dist/{serve-Bf8JJcJG.js → serve-Bv_twI1k.js} +13 -13
  15. package/dist/{setup-Cth_2Kor.js → setup-BmoiDPZn.js} +2 -2
  16. package/dist/{subcommands-CFg6By2s.js → subcommands-CFC5HMmx.js} +1 -1
  17. package/dist/{subcommands-t140aQXd.js → subcommands-CXCoOuN6.js} +9 -9
  18. package/dist/{ts-BDMKe8RP.js → ts-Bb3CeS6-.js} +2 -2
  19. package/dist/{versionChecker-Bhv8OZAY.js → versionChecker-SHk3Fuvm.js} +2 -2
  20. package/dist/{ws-6kyIFJHL.js → ws-DDYVVQwl.js} +2 -2
  21. package/package.json +3 -1
  22. package/ts/channels/browser.ts +287 -0
  23. package/ts/channels/hlc.ts +67 -0
  24. package/ts/channels/index.ts +10 -0
  25. package/ts/channels/link.ts +103 -0
  26. package/ts/channels/op.ts +89 -0
  27. package/ts/channels/peer.ts +468 -0
  28. package/ts/channels/store.browser.ts +42 -0
  29. package/ts/channels/store.node.ts +72 -0
  30. package/ts/channels/store.ts +170 -0
  31. package/ts/channels.spec.ts +23 -0
  32. package/ts/channels.ts +50 -2
@@ -0,0 +1,287 @@
1
+ // Browser channel client: `import AyChannel from "agent-yes/channels"`.
2
+ //
3
+ // The frontend counterpart to the CLI. It joins the SAME WebRTC mesh as any
4
+ // `ay ch sync` peer using the isomorphic ChannelPeer (peer.ts) wired to the
5
+ // browser's native RTCPeerConnection + WebSocket, persists to LocalStorage
6
+ // (store.browser.ts), and renders a self-contained floating chat window (Shadow
7
+ // DOM) so an agent and a human can talk on the same page — with no server ever
8
+ // storing a message.
9
+ //
10
+ // const ch = new AyChannel("ay://ch/s.agent-yes.com/<room>#e1.<S>");
11
+ // await ch.start();
12
+ // ch.on("message", render);
13
+ // ch.mount(); // floating widget, or ch.mount(el) to embed
14
+ // await ch.send("hello");
15
+
16
+ import { deriveChannelId, deriveRoom, parseChannelLink, secretFromTopic } from "./link.ts";
17
+ import { hlcSend } from "./hlc.ts";
18
+ import { makeOp, type Role } from "./op.ts";
19
+ import { maxHlc, renderThread, type Message } from "./store.ts";
20
+ import { randomHex } from "../../lab/ui/e2e.js";
21
+ import { ChannelPeer } from "./peer.ts";
22
+ import { LocalStorageStore } from "./store.browser.ts";
23
+
24
+ export interface AyChannelInfo {
25
+ /** A channel invite link (ay://ch/… or https://…/w/#ch=…). If given, room/sighost/s are parsed from it. */
26
+ link?: string;
27
+ room?: string;
28
+ sighost?: string;
29
+ /** Secret S (64-hex). */
30
+ s?: string;
31
+ name?: string;
32
+ role?: Role;
33
+ /** Stable author id; auto-generated + persisted per channel if omitted. */
34
+ author?: string;
35
+ }
36
+
37
+ type Events = "message" | "peers" | "ready";
38
+
39
+ export class AyChannel {
40
+ readonly room: string;
41
+ readonly sighost: string;
42
+ readonly s: string;
43
+ channelId = "";
44
+ name: string;
45
+ role: Role;
46
+ author = "";
47
+ private store?: LocalStorageStore;
48
+ private peer?: ChannelPeer;
49
+ private listeners = new Map<Events, Set<(arg: any) => void>>();
50
+ private started = false;
51
+ private peers = 0;
52
+
53
+ /**
54
+ * Build a channel whose identity is DERIVED from a topic string (e.g. the page
55
+ * URL) — same topic ⇒ same room, no invite needed. Public to anyone with the
56
+ * topic (see secretFromTopic). This is what the page bookmarklet uses.
57
+ */
58
+ static async fromTopic(
59
+ topic: string,
60
+ opts?: { sighost?: string; name?: string; role?: Role },
61
+ ): Promise<AyChannel> {
62
+ const s = await secretFromTopic(topic);
63
+ const sighost = opts?.sighost ?? "s.agent-yes.com";
64
+ const room = await deriveRoom(s);
65
+ return new AyChannel({ room, sighost, s, name: opts?.name, role: opts?.role });
66
+ }
67
+
68
+ constructor(info: string | AyChannelInfo) {
69
+ const o = typeof info === "string" ? { link: info } : info;
70
+ const link = o.link ? parseChannelLink(o.link) : null;
71
+ this.room = o.room ?? link?.room ?? "";
72
+ this.sighost = o.sighost ?? link?.sighost ?? "s.agent-yes.com";
73
+ this.s = o.s ?? link?.s ?? "";
74
+ if (!this.room || !this.s) throw new Error("AyChannel: need a link or {room, s}");
75
+ this.name = o.name ?? "guest";
76
+ this.role = o.role ?? "human";
77
+ if (o.author) this.author = o.author;
78
+ }
79
+
80
+ /** Derive identity, open the LocalStorage replica, and join the mesh. */
81
+ async start(): Promise<void> {
82
+ if (this.started) return;
83
+ this.started = true;
84
+ this.channelId = await deriveChannelId(this.s);
85
+ this.store = new LocalStorageStore(this.channelId);
86
+ this.loadIdentity();
87
+ this.peer = new ChannelPeer({
88
+ room: this.room,
89
+ sighost: this.sighost,
90
+ s: this.s,
91
+ rtc: (globalThis as any).RTCPeerConnection,
92
+ WebSocketImpl: (globalThis as any).WebSocket,
93
+ store: this.store,
94
+ onOp: () => this.emit("message", undefined),
95
+ onPeers: (n) => {
96
+ this.peers = n;
97
+ this.emit("peers", n);
98
+ },
99
+ });
100
+ await this.peer.start();
101
+ this.emit("ready", undefined);
102
+ }
103
+
104
+ /** Identity is stable per channel across reloads (a returning tab keeps its author id). */
105
+ private loadIdentity(): void {
106
+ const key = `ay29ch-id:${this.channelId}`;
107
+ let saved: { author: string; name: string; role: Role } | null = null;
108
+ try {
109
+ saved = JSON.parse(localStorage.getItem(key) || "null");
110
+ } catch {
111
+ /* ignore */
112
+ }
113
+ // Author is stable across reloads; name/role fall back to the saved ones only
114
+ // when the caller left them at their defaults.
115
+ this.author ||= saved?.author || randomHex(8);
116
+ if (saved?.name && this.name === "guest") this.name = saved.name;
117
+ if (saved?.role && this.role === "human") this.role = saved.role;
118
+ try {
119
+ localStorage.setItem(
120
+ key,
121
+ JSON.stringify({ author: this.author, name: this.name, role: this.role }),
122
+ );
123
+ } catch {
124
+ /* ignore */
125
+ }
126
+ }
127
+
128
+ /** The rendered thread (folded messages, ordered). */
129
+ async messages(): Promise<Message[]> {
130
+ return renderThread(await (this.store?.all() ?? Promise.resolve([])));
131
+ }
132
+
133
+ /** The current confirmed-peer count (presence). */
134
+ peerCount(): number {
135
+ return this.peers;
136
+ }
137
+
138
+ /** Post a message: persist locally + broadcast to the mesh. */
139
+ async send(text: string): Promise<void> {
140
+ if (!this.store || !this.peer) throw new Error("AyChannel: call start() first");
141
+ const t = text.trim();
142
+ if (!t) return;
143
+ const ops = await this.store.all();
144
+ const hlc = hlcSend(maxHlc(ops), Date.now(), this.author);
145
+ const op = makeOp({
146
+ author: this.author,
147
+ name: this.name,
148
+ role: this.role,
149
+ hlc,
150
+ kind: "msg",
151
+ body: t,
152
+ });
153
+ await this.peer.publish(op); // append + broadcast
154
+ this.emit("message", undefined);
155
+ }
156
+
157
+ on(evt: Events, cb: (arg: any) => void): this {
158
+ (this.listeners.get(evt) ?? this.listeners.set(evt, new Set()).get(evt)!).add(cb);
159
+ return this;
160
+ }
161
+ off(evt: Events, cb: (arg: any) => void): this {
162
+ this.listeners.get(evt)?.delete(cb);
163
+ return this;
164
+ }
165
+ private emit(evt: Events, arg: any): void {
166
+ for (const cb of this.listeners.get(evt) ?? []) {
167
+ try {
168
+ cb(arg);
169
+ } catch {
170
+ /* a listener throwing must not break delivery */
171
+ }
172
+ }
173
+ }
174
+
175
+ close(): void {
176
+ this.peer?.close();
177
+ this.widget?.remove();
178
+ }
179
+
180
+ // --- floating chat widget -------------------------------------------------
181
+ // DOM globals (document/HTMLElement) aren't in the Node/Bun type lib this
182
+ // package compiles against, so DOM access here is intentionally loosely typed;
183
+ // this method only ever runs in a browser.
184
+
185
+ private widget?: any;
186
+
187
+ /**
188
+ * Render the floating chat window. With no target it mounts a fixed-position
189
+ * bubble in the corner; pass an element to embed it there. Auto-calls start().
190
+ */
191
+ mount(target?: any, opts?: { open?: boolean }): any {
192
+ if (this.widget) return this.widget;
193
+ const doc: any = (globalThis as any).document;
194
+ const host = doc.createElement("div");
195
+ host.setAttribute("data-aychannel", this.channelId || this.room);
196
+ const root = host.attachShadow({ mode: "open" });
197
+ root.innerHTML = WIDGET_HTML;
198
+ this.widget = host;
199
+ (target ?? doc.body).appendChild(host);
200
+
201
+ const list = root.getElementById("list")!;
202
+ const input = root.getElementById("input") as any;
203
+ const form = root.getElementById("form") as any;
204
+ const badge = root.getElementById("peers")!;
205
+ const panel = root.getElementById("panel")!;
206
+ const toggle = root.getElementById("toggle")!;
207
+
208
+ const esc = (s: string) =>
209
+ s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
210
+ const render = async () => {
211
+ const msgs = await this.messages();
212
+ list.innerHTML = msgs
213
+ .map((m) => {
214
+ const mine = m.author === this.author;
215
+ const time = new Date(m.ms).toLocaleTimeString([], {
216
+ hour: "2-digit",
217
+ minute: "2-digit",
218
+ });
219
+ const body = m.deleted ? "<i>message deleted</i>" : esc(m.text);
220
+ return `<div class="msg ${mine ? "mine" : ""} ${m.role}"><div class="meta">${esc(m.name)} · ${time}</div><div class="body">${body}</div></div>`;
221
+ })
222
+ .join("");
223
+ list.scrollTop = list.scrollHeight;
224
+ };
225
+
226
+ form.addEventListener("submit", (e: any) => {
227
+ e.preventDefault();
228
+ const text = input.value;
229
+ input.value = "";
230
+ void this.send(text);
231
+ });
232
+ toggle.addEventListener("click", () => panel.classList.toggle("open"));
233
+ if (opts?.open) panel.classList.add("open");
234
+ this.on("message", () => void render());
235
+ this.on("peers", () => (badge.textContent = String(this.peers)));
236
+
237
+ void (async () => {
238
+ if (!this.started) await this.start();
239
+ await render();
240
+ badge.textContent = String(this.peers);
241
+ })();
242
+
243
+ return host;
244
+ }
245
+ }
246
+
247
+ // Self-contained styles + markup for the Shadow DOM widget (no external assets).
248
+ const WIDGET_HTML = `
249
+ <style>
250
+ :host { all: initial; }
251
+ * { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
252
+ #toggle {
253
+ position: fixed; right: 20px; bottom: 20px; z-index: 2147483000;
254
+ width: 52px; height: 52px; border-radius: 50%; border: none; cursor: pointer;
255
+ background: #4f46e5; color: #fff; font-size: 22px; box-shadow: 0 4px 16px rgba(0,0,0,.3);
256
+ }
257
+ #panel {
258
+ position: fixed; right: 20px; bottom: 84px; z-index: 2147483000;
259
+ width: min(360px, 92vw); height: min(520px, 70vh); display: none;
260
+ flex-direction: column; background: #1b1d24; color: #e6e6ea; border-radius: 14px;
261
+ box-shadow: 0 12px 40px rgba(0,0,0,.45); overflow: hidden; border: 1px solid #2c2f3a;
262
+ }
263
+ #panel.open { display: flex; }
264
+ header { padding: 12px 14px; background: #23262f; display: flex; align-items: center; gap: 8px; }
265
+ header .title { font-weight: 600; font-size: 14px; }
266
+ header .peers { margin-left: auto; font-size: 12px; opacity: .8; background: #4f46e5; border-radius: 10px; padding: 1px 8px; }
267
+ #list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
268
+ .msg { max-width: 82%; }
269
+ .msg .meta { font-size: 11px; opacity: .6; margin-bottom: 2px; }
270
+ .msg .body { background: #2c2f3a; padding: 7px 10px; border-radius: 10px; font-size: 13px; line-height: 1.35; white-space: pre-wrap; word-break: break-word; }
271
+ .msg.agent .body { border-left: 3px solid #10b981; }
272
+ .msg.mine { align-self: flex-end; text-align: right; }
273
+ .msg.mine .body { background: #4f46e5; color: #fff; }
274
+ #form { display: flex; gap: 6px; padding: 10px; border-top: 1px solid #2c2f3a; }
275
+ #input { flex: 1; background: #23262f; border: 1px solid #363a46; color: #e6e6ea; border-radius: 8px; padding: 8px 10px; font-size: 13px; }
276
+ #form button { background: #4f46e5; color: #fff; border: none; border-radius: 8px; padding: 0 14px; cursor: pointer; font-size: 13px; }
277
+ </style>
278
+ <button id="toggle" title="Chat">💬</button>
279
+ <section id="panel">
280
+ <header><span class="title">Channel</span><span class="peers" id="peers">0</span></header>
281
+ <div id="list"></div>
282
+ <form id="form"><input id="input" placeholder="Message…" autocomplete="off" /><button type="submit">Send</button></form>
283
+ </section>
284
+ `;
285
+
286
+ export default AyChannel;
287
+ export * from "./index.ts";
@@ -0,0 +1,67 @@
1
+ // Hybrid Logical Clock (HLC) for ay channels.
2
+ //
3
+ // A channel is a grow-only set of immutable ops replicated across peers with no
4
+ // coordinator (see store.ts). To display those ops in a stable, causally-sensible
5
+ // order every replica must agree on, each op carries an HLC timestamp: a wall
6
+ // clock reading fused with a per-node counter so that (a) order roughly tracks
7
+ // real time, (b) concurrent ops from different nodes get a deterministic
8
+ // tie-break, and (c) an op always sorts AFTER every op its author had already
9
+ // seen when it was created (causality).
10
+ //
11
+ // The timestamp is encoded as a FIXED-WIDTH, lexicographically-sortable string
12
+ // "<ms:15><SEP><ctr:6><SEP><node>"
13
+ // so a plain string compare reproduces the numeric HLC order — which lets the
14
+ // jsonl store and the wire protocol sort without parsing. This module is
15
+ // dependency-free and isomorphic (Node + browser).
16
+
17
+ const MS_WIDTH = 15; // ms since epoch; 15 digits lasts past year 33000
18
+ const CTR_WIDTH = 6; // per-ms counter; 10^6 ops sharing one ms is astronomically safe
19
+ const SEP = "."; // 0x2e < '0' (0x30) so, with fixed widths, it never reorders fields
20
+
21
+ export interface Hlc {
22
+ ms: number;
23
+ ctr: number;
24
+ node: string;
25
+ }
26
+
27
+ /** Encode an HLC as its sortable string form. */
28
+ export function formatHlc(ms: number, ctr: number, node: string): string {
29
+ if (ms < 0 || ctr < 0) throw new Error("hlc: negative component");
30
+ if (ms >= 10 ** MS_WIDTH || ctr >= 10 ** CTR_WIDTH) throw new Error("hlc: component overflow");
31
+ return `${String(ms).padStart(MS_WIDTH, "0")}${SEP}${String(ctr).padStart(CTR_WIDTH, "0")}${SEP}${node}`;
32
+ }
33
+
34
+ /** Parse a sortable HLC string back into its components. Throws on malformed input. */
35
+ export function parseHlc(s: string): Hlc {
36
+ const parts = s.split(SEP);
37
+ if (parts.length < 3) throw new Error("hlc: malformed");
38
+ const ms = Number(parts[0]);
39
+ const ctr = Number(parts[1]);
40
+ // node ids never contain SEP, but be defensive if one ever does.
41
+ const node = parts.slice(2).join(SEP);
42
+ if (!Number.isInteger(ms) || !Number.isInteger(ctr) || !node) throw new Error("hlc: malformed");
43
+ return { ms, ctr, node };
44
+ }
45
+
46
+ /**
47
+ * Numeric HLC comparison. Equivalent to a plain string compare of the sortable
48
+ * form (that's the point of the fixed-width encoding), but exposed explicitly so
49
+ * callers reading structured HLCs don't have to reconstruct the string.
50
+ */
51
+ export function compareHlc(a: string, b: string): number {
52
+ return a < b ? -1 : a > b ? 1 : 0;
53
+ }
54
+
55
+ /**
56
+ * Produce the HLC for a new local op. `prevMax` is the greatest HLC this replica
57
+ * has already stored (from ANY author — see store.maxHlc); passing it makes the
58
+ * result monotonic across process restarts and causally after everything seen,
59
+ * with no separate clock-state file to persist. `physNow` is the wall clock
60
+ * (Date.now()); `node` is this participant's stable id.
61
+ */
62
+ export function hlcSend(prevMax: string | null, physNow: number, node: string): string {
63
+ const prev = prevMax ? parseHlc(prevMax) : { ms: 0, ctr: 0 };
64
+ if (physNow > prev.ms) return formatHlc(physNow, 0, node);
65
+ // wall clock hasn't advanced past what we've seen — keep the ms, bump the counter
66
+ return formatHlc(prev.ms, prev.ctr + 1, node);
67
+ }
@@ -0,0 +1,10 @@
1
+ // Public surface of the channels core — imported by the CLI (ts/channels.ts),
2
+ // the serve daemon (Phase 2), and re-exported as the npm `agent-yes/channels`
3
+ // subpath + browser lib (Phase 3). Only isomorphic, dependency-free modules are
4
+ // re-exported here; the Node-only jsonl backend (store.node.ts) is imported
5
+ // directly by Node callers so a browser bundle never pulls in `fs`.
6
+
7
+ export * from "./hlc.ts";
8
+ export * from "./op.ts";
9
+ export * from "./store.ts";
10
+ export * from "./link.ts";
@@ -0,0 +1,103 @@
1
+ // Channel identity + invite links — pure, isomorphic, native-free (kept off
2
+ // node-datachannel like webrtcLink.ts, so parsing/derivation never loads the
3
+ // WebRTC addon and stays unit-testable).
4
+ //
5
+ // A channel's shared secret S is the same `e1.<64hex>` value the WebRTC share
6
+ // links use (e2e.js). Everything else is derived from S so peers who hold it
7
+ // agree without exchanging anything the server can read:
8
+ // - channelId : names the LOCAL replica file/key; topic-blind, cwd-portable.
9
+ // - room : the signaling rendezvous name (server sees only this + authToken).
10
+ // The topic string is a purely local label — it never appears in a link and
11
+ // never leaves the machine.
12
+
13
+ import { parseSecret, validateS } from "../../lab/ui/e2e.js";
14
+
15
+ export const CH_DEFAULT_SIGHOST = "s.agent-yes.com";
16
+
17
+ const HEX64 = /^[0-9a-f]{64}$/;
18
+ const subtle = globalThis.crypto.subtle;
19
+ const enc = new TextEncoder();
20
+
21
+ async function sha256Hex(input: string): Promise<string> {
22
+ const digest = new Uint8Array(await subtle.digest("SHA-256", enc.encode(input)));
23
+ let s = "";
24
+ for (const b of digest) s += b.toString(16).padStart(2, "0");
25
+ return s;
26
+ }
27
+
28
+ /** Local replica id: `sha256("ay/ch/id\n" + S)[:16]`. Topic-blind. */
29
+ export async function deriveChannelId(s: string): Promise<string> {
30
+ return (await sha256Hex(`ay/ch/id\n${validateS(s)}`)).slice(0, 16);
31
+ }
32
+
33
+ /** Signaling rendezvous name: `"c" + sha256("ay/ch/room\n" + S)[:12]`. */
34
+ export async function deriveRoom(s: string): Promise<string> {
35
+ return "c" + (await sha256Hex(`ay/ch/room\n${validateS(s)}`)).slice(0, 12);
36
+ }
37
+
38
+ /**
39
+ * Deterministic channel secret from a public topic string (e.g. a page URL) —
40
+ * `sha256("ay/ch/topic/v1\n" + topic)`, a full 64-hex S. Everyone who derives
41
+ * from the SAME topic gets the SAME channel, so a page can "join by its URL" with
42
+ * no invite exchange (the bookmarklet + `ay ch mk --topic`).
43
+ *
44
+ * SECURITY: this makes the channel PUBLIC to anyone who knows the topic (topic =
45
+ * membership). Message contents still stay E2E from the signaling server, but it
46
+ * is NOT a private channel — use a random secret (the default) for those.
47
+ */
48
+ export async function secretFromTopic(topic: string): Promise<string> {
49
+ return sha256Hex(`ay/ch/topic/v1\n${topic}`);
50
+ }
51
+
52
+ export interface ChannelLink {
53
+ sighost: string;
54
+ room: string;
55
+ /** The raw 64-hex secret S (marker stripped). */
56
+ s: string;
57
+ }
58
+
59
+ /** True if `str` looks like a channel invite link. */
60
+ export function isChannelLink(str: string): boolean {
61
+ return str.startsWith("ay://ch/") || (/^https?:\/\//.test(str) && str.includes("#ch="));
62
+ }
63
+
64
+ /**
65
+ * Format a channel invite:
66
+ * ay://ch/<sighost>/<room>#e1.<64hex>
67
+ * The secret rides the fragment; on the https form it is never sent to a server.
68
+ */
69
+ export function formatChannelLink(link: ChannelLink): string {
70
+ return `ay://ch/${link.sighost}/${link.room}#e1.${validateS(link.s)}`;
71
+ }
72
+
73
+ /** Browser-console form: https://<host>/w/#ch=<room>:e1.<64hex>[@<sighost>]. */
74
+ export function formatChannelWebLink(link: ChannelLink, webHost = "agent-yes.com"): string {
75
+ const at = link.sighost === CH_DEFAULT_SIGHOST ? "" : `@${link.sighost}`;
76
+ return `https://${webHost}/w/#ch=${link.room}:e1.${validateS(link.s)}${at}`;
77
+ }
78
+
79
+ /**
80
+ * Parse either invite form back into { sighost, room, s }. Returns null if the
81
+ * string isn't a recognizable channel link; throws (via parseSecret) if the
82
+ * secret slot is present but malformed — never silently downgrades.
83
+ */
84
+ export function parseChannelLink(link: string): ChannelLink | null {
85
+ const ay = /^ay:\/\/ch\/([^/]+)\/([^#]+)#(.+)$/.exec(link);
86
+ if (ay) {
87
+ const { s } = parseSecret(ay[3]!);
88
+ if (!HEX64.test(s)) throw new Error("malformed channel link");
89
+ return { sighost: ay[1]!, room: ay[2]!, s };
90
+ }
91
+ if (/^https?:\/\//.test(link) && link.includes("#ch=")) {
92
+ const frag = link.split("#ch=")[1] ?? "";
93
+ const at = frag.split("@");
94
+ const sighost = at[1] || CH_DEFAULT_SIGHOST;
95
+ const seg = at[0]!;
96
+ const i = seg.indexOf(":");
97
+ if (i < 0) return null;
98
+ const { s } = parseSecret(seg.slice(i + 1));
99
+ if (!HEX64.test(s)) throw new Error("malformed channel link");
100
+ return { sighost, room: seg.slice(0, i), s };
101
+ }
102
+ return null;
103
+ }
@@ -0,0 +1,89 @@
1
+ // The immutable op — the single record type replicated through a channel.
2
+ //
3
+ // Every message, edit, delete, reaction, and presence beat is one Op, appended
4
+ // to every replica's log and merged as a grow-only set (store.ts). Ops are
5
+ // content-independent of transport: the same shape lands in the CLI's jsonl and
6
+ // the browser's LocalStorage, and travels verbatim inside the E2E sealed frame.
7
+ //
8
+ // Identity: `id = "<author>@<hlc>"`. An author's HLCs are strictly monotonic and
9
+ // never reused, so (author, hlc) is globally unique WITHOUT a content hash — a
10
+ // retransmit of the same op yields the same id and dedups for free. (Phase 3
11
+ // ed25519 `sig` will bind the body to this id so a peer can't forge a different
12
+ // body under an author's id; until then, channel-secret possession = trust.)
13
+ //
14
+ // Dependency-free and isomorphic (Node + browser).
15
+
16
+ export type OpKind = "msg" | "edit" | "delete" | "reaction" | "presence";
17
+ export type Role = "agent" | "human";
18
+
19
+ export interface Op {
20
+ /** `<author>@<hlc>` — globally unique dedupe key. */
21
+ id: string;
22
+ /** Stable per-participant id (registry `author`); the HLC node + identity. */
23
+ author: string;
24
+ /** Display name at send time. */
25
+ name: string;
26
+ role: Role;
27
+ /** Sortable Hybrid Logical Clock (hlc.ts). */
28
+ hlc: string;
29
+ kind: OpKind;
30
+ /** msg/edit: text. reaction: the emoji/label. presence: status. Absent for delete. */
31
+ body?: string;
32
+ /** edit/delete/reaction: the target op id being amended. */
33
+ ref?: string;
34
+ /** Phase 3: ed25519 signature over `id` (author authenticity). */
35
+ sig?: string;
36
+ }
37
+
38
+ const KINDS: ReadonlySet<string> = new Set(["msg", "edit", "delete", "reaction", "presence"]);
39
+ const ROLES: ReadonlySet<string> = new Set(["agent", "human"]);
40
+
41
+ /** Deterministic op id from author + hlc (no content hash needed — see file header). */
42
+ export function opId(author: string, hlc: string): string {
43
+ return `${author}@${hlc}`;
44
+ }
45
+
46
+ /** Construct a well-formed op, filling `id` and dropping empty optional fields. */
47
+ export function makeOp(fields: {
48
+ author: string;
49
+ name: string;
50
+ role: Role;
51
+ hlc: string;
52
+ kind: OpKind;
53
+ body?: string;
54
+ ref?: string;
55
+ }): Op {
56
+ const op: Op = {
57
+ id: opId(fields.author, fields.hlc),
58
+ author: fields.author,
59
+ name: fields.name,
60
+ role: fields.role,
61
+ hlc: fields.hlc,
62
+ kind: fields.kind,
63
+ };
64
+ if (fields.body !== undefined) op.body = fields.body;
65
+ if (fields.ref) op.ref = fields.ref;
66
+ return op;
67
+ }
68
+
69
+ /**
70
+ * Validate an op arriving from an untrusted source (peer wire, disk, storage).
71
+ * Fail-closed: a malformed op is dropped, never coerced. Also enforces that `id`
72
+ * matches `author@hlc` so a peer can't smuggle a colliding id.
73
+ */
74
+ export function isValidOp(x: unknown): x is Op {
75
+ if (!x || typeof x !== "object") return false;
76
+ const o = x as Record<string, unknown>;
77
+ if (typeof o.author !== "string" || !o.author) return false;
78
+ if (typeof o.name !== "string") return false;
79
+ if (typeof o.hlc !== "string" || !o.hlc) return false;
80
+ if (typeof o.kind !== "string" || !KINDS.has(o.kind)) return false;
81
+ if (typeof o.role !== "string" || !ROLES.has(o.role)) return false;
82
+ if (o.body !== undefined && typeof o.body !== "string") return false;
83
+ if (o.ref !== undefined && typeof o.ref !== "string") return false;
84
+ if (o.sig !== undefined && typeof o.sig !== "string") return false;
85
+ if (o.id !== opId(o.author, o.hlc)) return false;
86
+ // amendments must target something
87
+ if ((o.kind === "edit" || o.kind === "delete" || o.kind === "reaction") && !o.ref) return false;
88
+ return true;
89
+ }