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,170 @@
1
+ // The channel CRDT — pure, isomorphic (Node + browser), storage-agnostic.
2
+ //
3
+ // A channel is a grow-only set of immutable ops (op.ts) keyed by `id`. Because
4
+ // ids are content-independent-but-unique and the set only grows, MERGE is a
5
+ // union and always converges: any two replicas that have exchanged the same ops
6
+ // hold the same set regardless of arrival order (commutative, associative,
7
+ // idempotent). Display order is the total order over `hlc`.
8
+ //
9
+ // Amendments (edit/delete/reaction) are folded per target message as
10
+ // last-writer-wins by HLC, so the rendered thread is likewise a pure function of
11
+ // the op set — every replica renders identically.
12
+ //
13
+ // This module never touches disk or network; backends (store.node.ts,
14
+ // store.browser.ts) provide the ops, and peer.ts moves them between replicas.
15
+
16
+ import { compareHlc, parseHlc } from "./hlc.ts";
17
+ import type { Op, Role } from "./op.ts";
18
+
19
+ /** Order ops by HLC (then id, for a fully deterministic tie-break). */
20
+ export function sortOps(ops: Op[]): Op[] {
21
+ return [...ops].sort(
22
+ (a, b) => compareHlc(a.hlc, b.hlc) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Union `incoming` into `existing`, deduping by id. Returns the merged set
28
+ * (sorted) and the ops that were actually new — the backend appends `added`, and
29
+ * peer.ts rebroadcasts them. Idempotent: merging the same ops twice adds nothing.
30
+ */
31
+ export function mergeOps(existing: Op[], incoming: Op[]): { merged: Op[]; added: Op[] } {
32
+ const byId = new Map<string, Op>();
33
+ for (const op of existing) byId.set(op.id, op);
34
+ const added: Op[] = [];
35
+ for (const op of incoming) {
36
+ if (byId.has(op.id)) continue;
37
+ byId.set(op.id, op);
38
+ added.push(op);
39
+ }
40
+ return { merged: sortOps([...byId.values()]), added };
41
+ }
42
+
43
+ /** The greatest HLC in the set (used to seed the next local send), or null if empty. */
44
+ export function maxHlc(ops: Op[]): string | null {
45
+ let max: string | null = null;
46
+ for (const op of ops) if (max === null || compareHlc(op.hlc, max) > 0) max = op.hlc;
47
+ return max;
48
+ }
49
+
50
+ // --- rendered thread --------------------------------------------------------
51
+
52
+ export interface Reaction {
53
+ /** The emoji/label. */
54
+ emoji: string;
55
+ /** Distinct author ids that reacted with it. */
56
+ by: string[];
57
+ }
58
+
59
+ /** A message as shown in the UI: a base `msg` op with amendments folded in. */
60
+ export interface Message {
61
+ id: string;
62
+ author: string;
63
+ name: string;
64
+ role: Role;
65
+ /** Base op HLC — the thread sort key. */
66
+ hlc: string;
67
+ /** Current text (after the latest edit); empty when deleted. */
68
+ text: string;
69
+ /** True if a delete op is the latest amendment. */
70
+ deleted: boolean;
71
+ /** HLC of the latest edit/delete applied, if any. */
72
+ amendedHlc?: string;
73
+ reactions: Reaction[];
74
+ ms: number;
75
+ }
76
+
77
+ /**
78
+ * Fold an op set into the ordered list of messages. Pure function of the ops:
79
+ * for each `msg` op, its `edit`/`delete` amendments are applied in HLC order
80
+ * (last wins — an edit after a delete revives it, a delete after an edit hides
81
+ * it), and `reaction` ops are grouped by emoji into distinct authors.
82
+ */
83
+ export function renderThread(ops: Op[]): Message[] {
84
+ const sorted = sortOps(ops);
85
+ const messages = new Map<string, Message>();
86
+
87
+ for (const op of sorted) {
88
+ if (op.kind !== "msg") continue;
89
+ messages.set(op.id, {
90
+ id: op.id,
91
+ author: op.author,
92
+ name: op.name,
93
+ role: op.role,
94
+ hlc: op.hlc,
95
+ text: op.body ?? "",
96
+ deleted: false,
97
+ reactions: [],
98
+ ms: parseHlc(op.hlc).ms,
99
+ });
100
+ }
101
+
102
+ // reactions grouped as emoji -> ordered distinct authors, per target message
103
+ const reactions = new Map<string, Map<string, string[]>>();
104
+
105
+ for (const op of sorted) {
106
+ if (!op.ref) continue;
107
+ const target = messages.get(op.ref);
108
+ if (!target) continue; // amendment for an op we don't (yet) have — ignore
109
+ if (op.kind === "edit") {
110
+ // amendments always sort after the base (author is causal); LWW by HLC
111
+ if (compareHlc(op.hlc, target.amendedHlc ?? target.hlc) > 0) {
112
+ target.text = op.body ?? "";
113
+ target.deleted = false;
114
+ target.amendedHlc = op.hlc;
115
+ }
116
+ } else if (op.kind === "delete") {
117
+ if (compareHlc(op.hlc, target.amendedHlc ?? target.hlc) > 0) {
118
+ target.text = "";
119
+ target.deleted = true;
120
+ target.amendedHlc = op.hlc;
121
+ }
122
+ } else if (op.kind === "reaction" && op.body) {
123
+ let group = reactions.get(op.ref);
124
+ if (!group) reactions.set(op.ref, (group = new Map()));
125
+ const authors = group.get(op.body) ?? [];
126
+ if (!authors.includes(op.author)) authors.push(op.author);
127
+ group.set(op.body, authors);
128
+ }
129
+ }
130
+
131
+ for (const [msgId, group] of reactions) {
132
+ const target = messages.get(msgId);
133
+ if (!target) continue;
134
+ target.reactions = [...group.entries()].map(([emoji, by]) => ({ emoji, by }));
135
+ }
136
+
137
+ return [...messages.values()].sort((a, b) => compareHlc(a.hlc, b.hlc));
138
+ }
139
+
140
+ // --- anti-entropy sync ------------------------------------------------------
141
+ //
142
+ // On each new peer connection the two sides exchange a compact per-author
143
+ // summary of what they hold, then each sends the ops the other lacks. This is
144
+ // why an intermittent / partial mesh still converges: a peer that was offline
145
+ // receives the suffix it missed on reconnect.
146
+
147
+ /** Per-author greatest HLC held — the compact "have" summary sent to a peer. */
148
+ export function haveVector(ops: Op[]): Record<string, string> {
149
+ const have: Record<string, string> = {};
150
+ for (const op of ops) {
151
+ const cur = have[op.author];
152
+ if (cur === undefined || compareHlc(op.hlc, cur) > 0) have[op.author] = op.hlc;
153
+ }
154
+ return have;
155
+ }
156
+
157
+ /**
158
+ * The ops in `local` that a peer with summary `remoteHave` is missing: any op
159
+ * from an author the peer hasn't heard from, or newer than the peer's max for
160
+ * that author. Relies on an author's ops forming a contiguous HLC suffix on each
161
+ * replica (sync always transfers whole suffixes), which holds under this protocol.
162
+ */
163
+ export function opsMissing(local: Op[], remoteHave: Record<string, string>): Op[] {
164
+ return sortOps(
165
+ local.filter((op) => {
166
+ const peerMax = remoteHave[op.author];
167
+ return peerMax === undefined || compareHlc(op.hlc, peerMax) > 0;
168
+ }),
169
+ );
170
+ }
@@ -3,6 +3,7 @@ import os from "os";
3
3
  import path from "path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import {
6
+ buildBookmarklet,
6
7
  cmdCh,
7
8
  defaultName,
8
9
  defaultRole,
@@ -145,6 +146,28 @@ describe("ay ch CLI (local-only)", () => {
145
146
  expect(linkResolved.entry).toBeNull();
146
147
  });
147
148
 
149
+ it("mk --topic derives the same channel for the same topic string", async () => {
150
+ await capture(() => cmdCh(["mk", "a", "--topic", "https://example.com/p"]));
151
+ await capture(() => cmdCh(["mk", "b", "--topic", "https://example.com/p"]));
152
+ await capture(() => cmdCh(["mk", "c", "--topic", "https://example.com/other"]));
153
+ const reg = await readRegistry(cwd);
154
+ // same topic → same underlying channel; different topic → different channel
155
+ expect(reg.channels.a!.channelId).toBe(reg.channels.b!.channelId);
156
+ expect(reg.channels.c!.channelId).not.toBe(reg.channels.a!.channelId);
157
+ });
158
+
159
+ it("bookmarklet prints a javascript: URL that loads the widget by page topic", async () => {
160
+ const out = (await capture(() => cmdCh(["bookmarklet"]))).out;
161
+ expect(out).toMatch(/^javascript:/);
162
+ expect(out).toContain("AyChannel.fromTopic");
163
+ expect(out).toContain("agent-yes.com/w/channels.js");
164
+ // custom host/sighost flow through
165
+ const bm = buildBookmarklet("beta.example.dev", "sig.example.dev");
166
+ expect(bm).toContain("beta.example.dev/w/channels.js");
167
+ expect(bm).toContain("sig.example.dev");
168
+ expect(bm).toContain("location.href"); // default topic = full page URL
169
+ });
170
+
148
171
  it("prints help for no subcommand and rejects unknown ones", async () => {
149
172
  expect((await capture(() => cmdCh([]))).out).toContain("ay ch -");
150
173
  const bad = await capture(() => cmdCh(["frobnicate"]));
package/ts/channels.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  maxHlc,
42
42
  parseChannelLink,
43
43
  renderThread,
44
+ secretFromTopic,
44
45
  type Message,
45
46
  type Role,
46
47
  } from "./channels/index.ts";
@@ -196,15 +197,25 @@ async function cmdChMk(cwd: string, args: string[]): Promise<number> {
196
197
  name: "value",
197
198
  role: "value",
198
199
  salt: "value",
200
+ topic: "value",
199
201
  });
200
202
  const topic = positional[0];
201
203
  if (!topic || positional.length > 1)
202
- throw new Error("usage: ay ch mk <topic> [--sighost H] [--name N] [--role R] [--salt HEX]");
204
+ throw new Error(
205
+ "usage: ay ch mk <name> [--topic <string>] [--sighost H] [--name N] [--role R] [--salt HEX]",
206
+ );
203
207
  const reg = await readRegistry(cwd);
204
208
  if (reg.channels[topic])
205
209
  throw new Error(`channel "${topic}" already exists (ay ch rm ${topic} to replace)`);
206
210
 
207
- const s = typeof flags.salt === "string" ? flags.salt : randomBytes(32).toString("hex");
211
+ // --topic derives a deterministic secret from a public string (e.g. a page URL)
212
+ // so this peer lands in the SAME channel a bookmarklet on that URL joins.
213
+ const s =
214
+ typeof flags.topic === "string"
215
+ ? await secretFromTopic(flags.topic)
216
+ : typeof flags.salt === "string"
217
+ ? flags.salt
218
+ : randomBytes(32).toString("hex");
208
219
  const [channelId, room] = await Promise.all([deriveChannelId(s), deriveRoom(s)]);
209
220
  const sighost = typeof flags.sighost === "string" ? flags.sighost : undefined;
210
221
  const entry: ChannelRegEntry = {
@@ -554,6 +565,38 @@ async function cmdChEmbed(cwd: string, args: string[]): Promise<number> {
554
565
  return 0;
555
566
  }
556
567
 
568
+ /** The page bookmarklet: derive a channel from the page URL and mount the widget. */
569
+ export function buildBookmarklet(host: string, sighost: string): string {
570
+ // One line, minimal, defensive: prompt for the topic (default = full URL incl.
571
+ // hash), remember the name, load the widget, and alert clearly if the page CSP
572
+ // blocks the cross-origin import (the expected failure on hardened sites).
573
+ return (
574
+ `javascript:(async()=>{try{` +
575
+ `const{AyChannel}=await import('https://${host}/w/channels.js');` +
576
+ `const t=prompt('ay channel — topic (same topic = same room):',location.href);if(!t)return;` +
577
+ `let n=localStorage.getItem('ay29ch-name');if(!n){n=prompt('Your name:','guest')||'guest';localStorage.setItem('ay29ch-name',n);}` +
578
+ `const ch=await AyChannel.fromTopic(t,{sighost:'${sighost}',name:n});` +
579
+ `ch.mount(document.body,{open:true});` +
580
+ `}catch(e){alert('ay channel could not load — this page\\'s CSP may block it (works on your own apps, localhost, blogs, docs).\\n'+(e&&e.message||e));}})()`
581
+ );
582
+ }
583
+
584
+ async function cmdChBookmarklet(_cwd: string, args: string[]): Promise<number> {
585
+ const { flags, positional } = parseFlags(args, { host: "value", sighost: "value" });
586
+ if (positional.length) throw new Error("usage: ay ch bookmarklet [--host H] [--sighost H]");
587
+ const host = typeof flags.host === "string" ? flags.host : "agent-yes.com";
588
+ const sighost = typeof flags.sighost === "string" ? flags.sighost : "s.agent-yes.com";
589
+ process.stdout.write(buildBookmarklet(host, sighost) + "\n");
590
+ process.stderr.write(
591
+ `\n Make a new bookmark and paste the line above as its URL. Click it on any\n` +
592
+ ` page to drop an ay-channel chat window joined to that page's URL (edit the\n` +
593
+ ` prompt to change the topic). Same topic = same room, no invite needed.\n` +
594
+ ` Heads-up: strict site CSP (GitHub/Google/…) blocks it; it works on your own\n` +
595
+ ` apps, localhost, blogs, and docs.\n`,
596
+ );
597
+ return 0;
598
+ }
599
+
557
600
  function chHelp(): number {
558
601
  process.stdout.write(
559
602
  `ay ch - local-first E2E channels for AI ↔ humans (per-cwd, no server storage)\n` +
@@ -569,7 +612,10 @@ function chHelp(): number {
569
612
  ` ay ch sync <topic> [--quiet] hold the WebRTC mesh: live send/receive (Ctrl-C to stop)\n` +
570
613
  ` ay ch pipe <topic> sync + bridge stdin→send and inbound→stdout\n` +
571
614
  ` ay ch embed <topic> [--host H] print an HTML snippet embedding a floating chat widget\n` +
615
+ ` ay ch bookmarklet [--host H] print a bookmarklet: any page joins a channel by its URL\n` +
572
616
  `\n` +
617
+ ` URL-topic: 'ay ch mk <name> --topic <string>' derives the SAME channel a\n` +
618
+ ` bookmarklet on that URL joins (topic = public membership)\n` +
573
619
  ` identity: --name defaults to $AY_CH_NAME/OS user; --role to agent (in an agent) or human\n` +
574
620
  ` storage: <cwd>/.agent-yes/ch-<id>.jsonl (a full CRDT replica; cwd-scoped)\n` +
575
621
  ` live: run 'ay ch sync <topic>' (foreground/backgrounded) to join the mesh; then\n` +
@@ -610,6 +656,8 @@ export async function cmdCh(args: string[]): Promise<number> {
610
656
  return cmdChPipe(cwd, rest);
611
657
  case "embed":
612
658
  return cmdChEmbed(cwd, rest);
659
+ case "bookmarklet":
660
+ return cmdChBookmarklet(cwd, rest);
613
661
  case undefined:
614
662
  case "help":
615
663
  case "--help":