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.
- package/dist/{SUPPORTED_CLIS-Cj00WOEz.js → SUPPORTED_CLIS-BP1BBtWO.js} +3 -3
- package/dist/{SUPPORTED_CLIS-BS2hYjrW.js → SUPPORTED_CLIS-D6J7QvLL.js} +2 -2
- package/dist/{agentShare-BBLXfmbU.js → agentShare-CDMFg0Rj.js} +2 -2
- package/dist/{callback-B8Bx8c-9.js → callback-B1kcLI1k.js} +3 -3
- package/dist/{callback-beueK8jU.js → callback-sBpq-n_6.js} +2 -2
- package/dist/{channels-mAf4ztTJ.js → channels-Boi4aNiY.js} +24 -6
- package/dist/{channels-CUOl2wrG.js → channels-Bu4DZzHk.js} +15 -2
- package/dist/channels.js +19 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-CJfxTj46.js → notifyDaemon-BNCj9i2N.js} +2 -2
- package/dist/{rustBinary-d4ZUStzi.js → rustBinary-D8_DgkiI.js} +2 -2
- package/dist/{schedule-B4v3unjv.js → schedule-BVVQtiaS.js} +4 -4
- package/dist/{serve-Bf8JJcJG.js → serve-Bv_twI1k.js} +13 -13
- package/dist/{setup-Cth_2Kor.js → setup-BmoiDPZn.js} +2 -2
- package/dist/{subcommands-CFg6By2s.js → subcommands-CFC5HMmx.js} +1 -1
- package/dist/{subcommands-t140aQXd.js → subcommands-CXCoOuN6.js} +9 -9
- package/dist/{ts-BDMKe8RP.js → ts-Bb3CeS6-.js} +2 -2
- package/dist/{versionChecker-Bhv8OZAY.js → versionChecker-SHk3Fuvm.js} +2 -2
- package/dist/{ws-6kyIFJHL.js → ws-DDYVVQwl.js} +2 -2
- package/package.json +3 -1
- package/ts/channels/browser.ts +287 -0
- package/ts/channels/hlc.ts +67 -0
- package/ts/channels/index.ts +10 -0
- package/ts/channels/link.ts +103 -0
- package/ts/channels/op.ts +89 -0
- package/ts/channels/peer.ts +468 -0
- package/ts/channels/store.browser.ts +42 -0
- package/ts/channels/store.node.ts +72 -0
- package/ts/channels/store.ts +170 -0
- package/ts/channels.spec.ts +23 -0
- 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
|
+
}
|
package/ts/channels.spec.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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":
|