pipeup 0.6.0 → 0.7.0-beta.2

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 (45) hide show
  1. package/README.md +3 -3
  2. package/cli/check.mjs +267 -0
  3. package/cli/link.d.mts +17 -0
  4. package/cli/link.mjs +78 -4
  5. package/cli/pipeup.mjs +37 -7
  6. package/dist/pipeup.agents.js +2 -0
  7. package/dist/pipeup.core.js +1 -1
  8. package/dist/pipeup.esm.js +1 -1
  9. package/dist/pipeup.min.js +1 -1
  10. package/dist/types/core.d.ts +3 -1
  11. package/dist/types/crypto/portable.d.ts +15 -0
  12. package/dist/types/document.d.ts +20 -4
  13. package/dist/types/export/agent.d.ts +5 -0
  14. package/dist/types/export/format.d.ts +3 -1
  15. package/dist/types/index.d.ts +3 -1
  16. package/dist/types/model/animals.d.ts +7 -1
  17. package/dist/types/model/fold.d.ts +2 -0
  18. package/dist/types/model/ops.d.ts +5 -0
  19. package/dist/types/model/types.d.ts +28 -2
  20. package/dist/types/ui/addons.d.ts +22 -1
  21. package/dist/types/ui/animals.d.ts +1 -1
  22. package/dist/types/ui/app.d.ts +6 -0
  23. package/dist/types/ui/composer.d.ts +6 -1
  24. package/dist/types/ui/context.d.ts +16 -10
  25. package/dist/types/ui/draft-view.d.ts +5 -1
  26. package/dist/types/ui/highlights.d.ts +2 -0
  27. package/dist/types/ui/icons.d.ts +2 -0
  28. package/dist/types/ui/identity.d.ts +27 -0
  29. package/dist/types/ui/launcher.d.ts +0 -5
  30. package/dist/types/ui/legible.d.ts +27 -0
  31. package/dist/types/ui/mentions.d.ts +46 -0
  32. package/dist/types/ui/mount.d.ts +3 -0
  33. package/dist/types/ui/reactions.d.ts +31 -0
  34. package/dist/types/ui/styles.d.ts +1 -1
  35. package/dist/types/ui/theme.d.ts +31 -2
  36. package/dist/types/ui/thread-view.d.ts +12 -2
  37. package/package.json +1 -1
  38. package/skills/pipeup-add/SKILL.md +10 -5
  39. package/skills/pipeup-apply/SKILL.md +7 -5
  40. package/skills/pipeup-integrate/SKILL.md +20 -11
  41. package/skills/pipeup-send-to-git-setup/SKILL.md +1 -1
  42. package/skills/pipeup-summarise/SKILL.md +1 -1
  43. package/dist/types/export/prompt.d.ts +0 -12
  44. package/dist/types/ui/link.d.ts +0 -22
  45. package/dist/types/ui/replies.d.ts +0 -33
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Comments and feedback right on any HTML page: documents, slide decks and whole sites.
4
4
 
5
- > **Alpha (0.6.0).** The API and the stored comment format may change before 1.0; pin an exact version.
5
+ > **Alpha (0.7.0-beta.2).** The API and the stored comment format may change before 1.0; pin an exact version.
6
6
  > Website and Try pages: https://pipeup-ai.github.io/pipeup/ · Source: https://github.com/pipeup-ai/pipeup
7
7
 
8
8
  Pipeup: signed comment operations, threads with one-level replies, anchors that survive page changes,
@@ -21,14 +21,14 @@ As a classic script (also works from `file://`), it mounts itself on pages that
21
21
  ```html
22
22
  <html data-pipeup-doc="<id>:<key>">
23
23
  ...
24
- <script src="https://cdn.jsdelivr.net/npm/pipeup@0.6.0/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
24
+ <script src="https://cdn.jsdelivr.net/npm/pipeup@0.7.0-beta.2/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
25
25
  ```
26
26
 
27
27
  Each release's notes on GitHub give the `integrity` value
28
28
  (`node scripts/sri.mjs` prints it for a local build). Make a document key with
29
29
  `await Pipeup.newDocumentAttribute()` and keep it once people have commented.
30
30
 
31
- **Add-ons.** Optional extra scripts add features (`@pipeup/share`, `voice`, `live`, `assist`), and anyone can write their own
31
+ **Add-ons.** Optional extra scripts add features (`@pipeup/share`, `voice`, `live`, `assist`, `agents`, `emoji`, `github`, `updates`, `inbox`), and anyone can write their own
32
32
  (a company's, for example) without the Pipeup project's permission: see the
33
33
  [guide](https://pipeup-ai.github.io/pipeup/addons-guide.html). Add-ons run with the page's full power, so keep them
34
34
  intentional (best effort): pin each script with its `integrity` hash, name the add-ons that may run with
package/cli/check.mjs ADDED
@@ -0,0 +1,267 @@
1
+ // pipeup check <page.html>: loads a page with and without Pipeup at phone, tablet and desktop widths and reports what
2
+ // Pipeup changes or covers, and what the page could do better for it. It uses Playwright (yours; Pipeup depends on none).
3
+ import { pathToFileURL } from "node:url";
4
+ import { resolve } from "node:path";
5
+
6
+ const WIDTHS = [375, 768, 1440];
7
+ const SHIFT_PX = 0.5;
8
+ /** Requests that load Pipeup or one of its add-ons: blocked for the page as it is without Pipeup. */
9
+ const PIPEUP_SCRIPT = /(?:^|\/)pipeup[^/]*\.js(?:\?|$)|\/@pipeup\/|\/pipeup@/i;
10
+
11
+ async function playwright() {
12
+ for (const name of ["playwright", "@playwright/test"]) {
13
+ try {
14
+ return await import(name);
15
+ } catch {
16
+ // try the next
17
+ }
18
+ }
19
+ throw new Error(
20
+ "pipeup check needs Playwright and a browser: npm i -D playwright && npx playwright install chromium",
21
+ );
22
+ }
23
+
24
+ /** Every visible element's box (without Pipeup's own), in document order, so two loads can be compared. */
25
+ const boxes = () =>
26
+ [...document.body.querySelectorAll("*")]
27
+ .filter((e) => e.tagName !== "PIPEUP-ROOT" && !e.closest("pipeup-root"))
28
+ .slice(0, 4000)
29
+ .map((e) => {
30
+ const r = e.getBoundingClientRect();
31
+ const label = e.tagName.toLowerCase() + (e.id ? `#${e.id}` : "");
32
+ return {
33
+ label,
34
+ x: r.x + scrollX,
35
+ y: r.y + scrollY,
36
+ w: r.width,
37
+ h: r.height,
38
+ shown: r.width > 0 && r.height > 0,
39
+ };
40
+ });
41
+
42
+ /** What is checked once, with Pipeup running at the widest width. */
43
+ const inspect = async () => {
44
+ const w = window;
45
+ const out = {
46
+ scripts: 0,
47
+ roots: document.querySelectorAll("pipeup-root").length,
48
+ blocks: 0,
49
+ doc: document.documentElement.hasAttribute("data-pipeup-doc"),
50
+ addons: [],
51
+ appearance: null,
52
+ tabbable: false,
53
+ agents: document.documentElement.getAttribute("data-pipeup-agents") || "",
54
+ viewport: Boolean(document.querySelector('meta[name="viewport"]')),
55
+ };
56
+ out.scripts = [...document.querySelectorAll("script[src]")].filter(
57
+ (s) =>
58
+ /(?:^|\/)pipeup(?:\.min|\.esm)?(?:@[^/]*)?\.js|\/pipeup@[^/]*\//i.test(s.src) &&
59
+ !/@pipeup\//i.test(s.src),
60
+ ).length;
61
+ const marked = (e) => e.closest("[data-pipeup-id]") !== null;
62
+ out.blocks = [...document.querySelectorAll("svg, canvas, img, figure, table, video")].filter(
63
+ (e) => !e.closest("pipeup-root") && !marked(e) && e.getBoundingClientRect().width > 40,
64
+ ).length;
65
+ try {
66
+ const pu = await w.Pipeup.mount();
67
+ out.addons = (w.Pipeup.addons?.() ?? []).map((a) => ({
68
+ id: a.id,
69
+ state: a.state,
70
+ reason: a.reason ?? "",
71
+ says: a.network?.says ?? "",
72
+ }));
73
+ out.appearance = pu.appearance?.() ?? null;
74
+ } catch {
75
+ out.addons = null;
76
+ }
77
+ const control = document.querySelector("pipeup-root")?.shadowRoot?.querySelector(".launch .mode");
78
+ out.tabbable = Boolean(control) && control.tabIndex >= 0;
79
+ return out;
80
+ };
81
+
82
+ /** The control's box and the page's interactive and text boxes it could cover, at the current width. */
83
+ const overlaps = () => {
84
+ const root = document.querySelector("pipeup-root");
85
+ const mode = root?.shadowRoot?.querySelector(".launch .mode");
86
+ if (!mode) return { control: null, hits: [] };
87
+ const c = mode.getBoundingClientRect();
88
+ const hit = (r) =>
89
+ r.width > 0 &&
90
+ r.height > 0 &&
91
+ r.left < c.right &&
92
+ r.right > c.left &&
93
+ r.top < c.bottom &&
94
+ r.bottom > c.top;
95
+ const hits = [];
96
+ for (const e of document.body.querySelectorAll("*")) {
97
+ if (e.closest("pipeup-root")) continue;
98
+ const r = e.getBoundingClientRect();
99
+ if (!hit(r)) continue;
100
+ const style = getComputedStyle(e);
101
+ if (style.visibility === "hidden" || style.display === "none" || style.pointerEvents === "none") continue;
102
+ const interactive = e.matches(
103
+ "a[href], button, input, select, textarea, summary, [role=button], [role=link], [tabindex]:not([tabindex='-1'])",
104
+ );
105
+ const text = !interactive && [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
106
+ if (interactive || text)
107
+ hits.push({ what: e.tagName.toLowerCase() + (e.id ? `#${e.id}` : ""), interactive });
108
+ }
109
+ return { control: { x: c.x, y: c.y, w: c.width, h: c.height }, hits: hits.slice(0, 5) };
110
+ };
111
+
112
+ /** Runs the check; resolves to { results: [{ status, what, detail }], ok }. */
113
+ export async function check({ page, widths = WIDTHS, log = () => {} }) {
114
+ const url = /^https?:|^file:/i.test(page) ? page : pathToFileURL(resolve(page)).href;
115
+ const { chromium } = await playwright();
116
+ const browser = await chromium.launch();
117
+ const results = [];
118
+ const add = (status, what, detail = "") => results.push({ status, what, detail });
119
+ try {
120
+ for (const width of widths) {
121
+ const without = await (await browser.newContext({ viewport: { width, height: 900 } })).newPage();
122
+ await without.route("**/*", (route) =>
123
+ PIPEUP_SCRIPT.test(route.request().url()) ? route.abort() : route.continue(),
124
+ );
125
+ await without.goto(url);
126
+ await without.waitForLoadState("load");
127
+ const before = await without.evaluate(boxes);
128
+ await without.context().close();
129
+
130
+ const withp = await (await browser.newContext({ viewport: { width, height: 900 } })).newPage();
131
+ const said = [];
132
+ withp.on("console", (m) => said.push(m.text()));
133
+ await withp.goto(url);
134
+ await withp.waitForLoadState("load");
135
+ let started = await withp.waitForSelector("pipeup-root", { state: "attached", timeout: 3000 }).then(
136
+ () => true,
137
+ () => false,
138
+ );
139
+ // A page that mounts Pipeup itself (data-pipeup-auto="off") is started the way its author would.
140
+ if (!started && (await withp.evaluate(() => typeof window.Pipeup?.mount === "function")))
141
+ started = await withp.evaluate(() =>
142
+ window.Pipeup.mount().then(
143
+ () => true,
144
+ () => false,
145
+ ),
146
+ );
147
+ if (!started) {
148
+ add(
149
+ "fail",
150
+ `Pipeup starts (${width}px)`,
151
+ "no Pipeup appeared on the page: is its script included, and does it need data-pipeup-doc?",
152
+ );
153
+ await withp.context().close();
154
+ continue;
155
+ }
156
+ await withp.waitForTimeout(400);
157
+ const after = await withp.evaluate(boxes);
158
+ const moved = [];
159
+ if (before.length !== after.length)
160
+ moved.push(`the page has ${after.length} elements with Pipeup and ${before.length} without`);
161
+ else
162
+ for (let i = 0; i < before.length; i++) {
163
+ const a = before[i];
164
+ const b = after[i];
165
+ if (
166
+ a.shown &&
167
+ b.shown &&
168
+ Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.w - b.w), Math.abs(a.h - b.h)) >
169
+ SHIFT_PX
170
+ )
171
+ moved.push(
172
+ `${a.label} moved by ${Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)).toFixed(1)}px`,
173
+ );
174
+ }
175
+ if (moved.length)
176
+ add(
177
+ "fail",
178
+ `Pipeup changes nothing on the page (${width}px)`,
179
+ `${moved.length} difference${moved.length === 1 ? "" : "s"}: ${moved.slice(0, 3).join("; ")}`,
180
+ );
181
+ else add("pass", `Pipeup changes nothing on the page (${width}px)`);
182
+
183
+ const covered = await withp.evaluate(overlaps);
184
+ const touching = covered.hits.filter((h) => h.interactive);
185
+ if (touching.length)
186
+ add(
187
+ "fail",
188
+ `Pipeup's button covers nothing you use (${width}px)`,
189
+ `it sits over ${touching.map((h) => h.what).join(", ")}`,
190
+ );
191
+ else if (covered.hits.length)
192
+ add(
193
+ "warn",
194
+ `Pipeup's button covers nothing you use (${width}px)`,
195
+ `it sits over text in ${covered.hits.map((h) => h.what).join(", ")}`,
196
+ );
197
+ else add("pass", `Pipeup's button covers nothing you use (${width}px)`);
198
+
199
+ if (width === widths[widths.length - 1]) {
200
+ const o = await withp.evaluate(inspect);
201
+ if (o.scripts > 1 || o.roots > 1)
202
+ add(
203
+ "fail",
204
+ "Pipeup is included once",
205
+ `the page loads Pipeup ${Math.max(o.scripts, o.roots)} times: remove the extra script`,
206
+ );
207
+ else add("pass", "Pipeup is included once");
208
+ add(
209
+ o.doc ? "pass" : "warn",
210
+ "The page has a document identity",
211
+ o.doc
212
+ ? ""
213
+ : "no data-pipeup-doc on <html>: copies of this file on other computers won't share comments",
214
+ );
215
+ add(
216
+ o.blocks ? "warn" : "pass",
217
+ "Charts, images and tables have stable names",
218
+ o.blocks
219
+ ? `${o.blocks} without data-pipeup-id: comments on them may lose their place when the page changes`
220
+ : "",
221
+ );
222
+ add(o.tabbable ? "pass" : "fail", "Pipeup's button can be reached with the keyboard");
223
+ add(
224
+ o.viewport ? "pass" : "warn",
225
+ "The page suits phones",
226
+ o.viewport
227
+ ? ""
228
+ : 'no <meta name="viewport" content="width=device-width, initial-scale=1">: phones show the page zoomed out, and Pipeup with it',
229
+ );
230
+ if (o.addons)
231
+ for (const a of o.addons)
232
+ add(a.state === "on" ? "pass" : "warn", `Add-on ${a.id} is ${a.state}`, a.reason || a.says);
233
+ if (o.appearance) {
234
+ const e = o.appearance.effects;
235
+ add(
236
+ e.length || o.appearance.ratio < 3 ? "warn" : "pass",
237
+ `Pipeup looks right on this page (${o.appearance.look} look, from ${o.appearance.source})`,
238
+ e.length
239
+ ? `the page's styles on <html> change it and can't be undone from inside: ${e.join("; ")}`
240
+ : "",
241
+ );
242
+ }
243
+ if (o.agents && !(o.addons ?? []).some((a) => a.id === "agents"))
244
+ add(
245
+ "warn",
246
+ "A listed agent can answer",
247
+ "the page lists an agent (data-pipeup-agents) but has no agents add-on",
248
+ );
249
+ const unreadable = said.find((t) => /couldn't be read|could not be read/i.test(t));
250
+ add(unreadable ? "warn" : "pass", "Saved comments can be read", unreadable ?? "");
251
+ }
252
+ await withp.context().close();
253
+ }
254
+ } finally {
255
+ await browser.close();
256
+ }
257
+ log(results);
258
+ return { results, ok: !results.some((r) => r.status === "fail") };
259
+ }
260
+
261
+ /** The report as a table of words for a person. */
262
+ export function table(results) {
263
+ const mark = { pass: "pass", warn: "WARN", fail: "FAIL" };
264
+ return results
265
+ .map((r) => `${mark[r.status].padEnd(5)} ${r.what}${r.detail ? `\n ${r.detail}` : ""}`)
266
+ .join("\n");
267
+ }
package/cli/link.d.mts ADDED
@@ -0,0 +1,17 @@
1
+ export type Support = "old" | "builtin" | "addon" | "unknown";
2
+ export function served(
3
+ html: string,
4
+ o: { base: string; agent: string; key: string; file: string; addon?: boolean },
5
+ ): string;
6
+ export function pageSupport(
7
+ html: string,
8
+ dir: string,
9
+ ): Promise<{ support: Support; version: string; loaded: boolean }>;
10
+ export function watch(o: {
11
+ page: string;
12
+ agent: string;
13
+ key: string;
14
+ port?: number;
15
+ log?: (line: string) => void;
16
+ }): Promise<{ url: string; sidecar: string; support: Support; addon: boolean; close(): Promise<void> }>;
17
+ export function comments(o: { page: string; all?: boolean; json?: boolean }): Promise<string>;
package/cli/link.mjs CHANGED
@@ -4,6 +4,10 @@ import { randomBytes } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { readFile, rename, writeFile } from "node:fs/promises";
6
6
  import { basename, dirname, extname, join, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ /** The agents add-on that ships with this install; `watch` serves it to the pages that need it. */
10
+ const ADDON_FILE = fileURLToPath(new URL("../dist/pipeup.agents.js", import.meta.url));
7
11
 
8
12
  const TYPES = {
9
13
  ".html": "text/html; charset=utf-8",
@@ -25,9 +29,9 @@ const TYPES = {
25
29
  const MAX_BODY = 8 * 1024 * 1024;
26
30
  const attr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
27
31
 
28
- /** The page for the browser: its own html tag, plus the link's address and the agent it lists, nothing else changed. */
29
- export function served(html, { base, agent, key, file }) {
30
- return html.replace(/<html\b([^>]*)>/i, (_, rest) => {
32
+ /** The page for the browser: its own html tag, plus the link's address and the agent it lists, and the agents add-on when it needs one. */
33
+ export function served(html, { base, agent, key, file, addon = false }) {
34
+ const out = html.replace(/<html\b([^>]*)>/i, (_, rest) => {
31
35
  let tag = rest;
32
36
  const listed = /\sdata-pipeup-agents\s*=\s*"([^"]*)"/i.exec(tag);
33
37
  if (listed) tag = tag.replace(listed[0], ` data-pipeup-agents="${attr(`${listed[1]} ${key}`.trim())}"`);
@@ -35,6 +39,55 @@ export function served(html, { base, agent, key, file }) {
35
39
  tag += ` data-pipeup-link="${base}" data-pipeup-link-agent="${attr(agent)}" data-pipeup-link-file="${attr(file)}"`;
36
40
  return `<html${tag}>`;
37
41
  });
42
+ if (!addon) return out;
43
+ // After the page's own scripts: an add-on registers in any order. The page's file on disk is not touched.
44
+ const tag = `<script src="${base}/agents.js"></script>`;
45
+ return /<\/body>/i.test(out) ? out.replace(/<\/body>/i, () => `${tag}</body>`) : out + tag;
46
+ }
47
+
48
+ /** Which of Pipeup's agent support a version has: before 0.6 none, 0.6.x built in, 0.7 and later in the agents add-on. */
49
+ function byVersion(v) {
50
+ const m = /^(\d+)\.(\d+)\.\d+/.exec(v);
51
+ if (!m) return null;
52
+ const [major, minor] = [Number(m[1]), Number(m[2])];
53
+ return major > 0 || minor >= 7 ? "addon" : minor === 6 ? "builtin" : "old";
54
+ }
55
+
56
+ /** The same, read from a Pipeup script's own text: it carries the names of what it supports. */
57
+ function bySignature(text) {
58
+ if (!text.includes("pipeup-root")) return null;
59
+ if (text.includes("pipeupReplies")) return "builtin";
60
+ return text.includes("listnote") ? "addon" : "old";
61
+ }
62
+
63
+ /**
64
+ * What the page's Pipeup can do for an agent, found without running the page: "old" (before 0.6, can't take an
65
+ * agent), "builtin" (0.6.x, agent support is part of it), "addon" (0.7 or later: the agents add-on serves an agent),
66
+ * or "unknown" (a floating address, or no Pipeup script to read). `loaded` says the page already includes the add-on.
67
+ */
68
+ export async function pageSupport(html, dir) {
69
+ const result = { support: "unknown", version: "", loaded: false };
70
+ for (const [, src] of html.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi)) {
71
+ if (/agents(\.min)?\.js|@pipeup\/agents/i.test(src)) result.loaded = true;
72
+ const named = /pipeup(?:@|-)(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/.exec(src);
73
+ if (named && result.support === "unknown") {
74
+ result.support = byVersion(named[1]) ?? "unknown";
75
+ result.version = named[1];
76
+ continue;
77
+ }
78
+ if (result.support !== "unknown" || /^(https?:)?\/\//i.test(src)) continue;
79
+ try {
80
+ const path = /^file:/i.test(src)
81
+ ? fileURLToPath(src.split(/[?#]/)[0])
82
+ : resolve(dir, decodeURIComponent(src.split(/[?#]/)[0]));
83
+ result.support = bySignature(await readFile(path, "utf8")) ?? "unknown";
84
+ } catch {
85
+ // a script that can't be read says nothing about Pipeup
86
+ }
87
+ }
88
+ // A Pipeup carried inside the page itself.
89
+ if (result.support === "unknown") result.support = bySignature(html) ?? "unknown";
90
+ return result;
38
91
  }
39
92
 
40
93
  /** Starts the link for a page file. Resolves to { url, close }. */
@@ -47,6 +100,22 @@ export async function watch({ page, agent, key, port = 0, log = console.log }) {
47
100
  let origin = "";
48
101
  let host = "";
49
102
 
103
+ // Which Pipeup the page uses decides whether the agents add-on is added to what it shows.
104
+ const found = await pageSupport(await readFile(file, "utf8"), dir);
105
+ if (found.support === "old")
106
+ throw new Error(
107
+ `this page uses ${found.version ? `Pipeup ${found.version}` : "a Pipeup older than 0.6"}, which can't take an agent. Use Pipeup 0.6 or later (pin an exact version) and run this again`,
108
+ );
109
+ const addon = found.support !== "builtin" && !found.loaded;
110
+ let addonCode = "";
111
+ if (addon) {
112
+ try {
113
+ addonCode = await readFile(ADDON_FILE, "utf8");
114
+ } catch {
115
+ throw new Error("the agents add-on isn't in this install of pipeup (dist/pipeup.agents.js is missing)");
116
+ }
117
+ }
118
+
50
119
  const send = (res, status, body = "", type = "text/plain; charset=utf-8") => {
51
120
  res.writeHead(status, {
52
121
  "content-type": type,
@@ -62,6 +131,8 @@ export async function watch({ page, agent, key, port = 0, log = console.log }) {
62
131
  if (req.headers.host !== host) return send(res, 403, "wrong host");
63
132
  const url = new URL(req.url, origin);
64
133
  if (url.pathname.startsWith("/__pipeup/")) {
134
+ if (addon && url.pathname === `${base}/agents.js` && req.method === "GET")
135
+ return send(res, 200, addonCode, TYPES[".js"]);
65
136
  if (url.pathname !== `${base}/comments` || req.method !== "POST") return send(res, 404, "not found");
66
137
  if (req.headers.origin !== origin) return send(res, 403, "wrong origin");
67
138
  const chunks = [];
@@ -95,7 +166,8 @@ export async function watch({ page, agent, key, port = 0, log = console.log }) {
95
166
  } catch {
96
167
  return send(res, 404, "not found");
97
168
  }
98
- if (target === file) bytes = Buffer.from(served(bytes.toString("utf8"), { base, agent, key, file }));
169
+ if (target === file)
170
+ bytes = Buffer.from(served(bytes.toString("utf8"), { base, agent, key, file, addon }));
99
171
  send(
100
172
  res,
101
173
  200,
@@ -113,6 +185,8 @@ export async function watch({ page, agent, key, port = 0, log = console.log }) {
113
185
  return {
114
186
  url: `${origin}/${encodeURIComponent(name)}`,
115
187
  sidecar,
188
+ support: found.support,
189
+ addon,
116
190
  close: () => new Promise((ok) => server.close(ok)),
117
191
  };
118
192
  }
package/cli/pipeup.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
+ import { check, table } from "./check.mjs";
7
8
  import { comments, watch } from "./link.mjs";
8
9
  import * as skills from "./skills.mjs";
9
10
 
@@ -18,13 +19,19 @@ const USAGE = `pipeup skills install|remove|list [--project] [--agent claude|cod
18
19
  project, or with --project for this folder only. It says what it will write, asks first (--yes to go ahead without
19
20
  asking), and never replaces a skill you changed unless you say --replace.
20
21
 
22
+ pipeup check <page.html> [--json]
23
+ Loads the page with and without Pipeup at 375, 768 and 1440 px (with Playwright, which you install) and says what
24
+ Pipeup changes or covers, and what the page could do better: pass, WARN or FAIL for each. Exits 1 on any FAIL.
25
+
21
26
  pipeup agent [--name <name>]
22
27
  Makes this computer's agent key (once) and prints the public key to list on the page:
23
28
  <html data-pipeup-agents="<key>">
24
29
 
25
30
  pipeup watch <page.html> [--agent <name>] [--port <number>]
26
31
  Shows the page from this computer, with a live link to the agent: the page offers "Share with my agent"
27
- (off until the person switches it on), and the agent is listed for this page while the link runs.
32
+ (off until the person switches it on), and the agent is listed for this page while the link runs. It adds the
33
+ agents add-on to the page it shows (the file is not changed), unless the page's Pipeup already has agent support
34
+ (0.6.x), and refuses a page whose Pipeup is older than 0.6.
28
35
 
29
36
  pipeup comments <page.html> [--all] [--json]
30
37
  Prints the comments the page has shared (open threads, or all with --all), as Markdown or, with --json, as data.
@@ -176,6 +183,16 @@ else if (command === "skills") {
176
183
  } catch (e) {
177
184
  fail(e.message);
178
185
  }
186
+ } else if (command === "check") {
187
+ const page = args._[0];
188
+ if (!page) fail(`missing the page\n\n${USAGE}`);
189
+ try {
190
+ const { results, ok } = await check({ page });
191
+ console.log(args.json === true ? JSON.stringify({ ok, results }, null, 2) : table(results));
192
+ process.exit(ok ? 0 : 1);
193
+ } catch (e) {
194
+ fail(e.message);
195
+ }
179
196
  } else if (command === "comments") {
180
197
  const page = args._[0];
181
198
  if (!page) fail(`missing the page\n\n${USAGE}`);
@@ -189,16 +206,29 @@ else if (command === "skills") {
189
206
  if (!page) fail(`missing the page\n\n${USAGE}`);
190
207
  await pageDoc(page);
191
208
  const { identity, name } = await loadAgent(typeof args.agent === "string" ? args.agent : undefined);
192
- const link = await watch({
193
- page,
194
- agent: name,
195
- key: identity.publicKey,
196
- port: typeof args.port === "string" ? Number(args.port) : 0,
197
- });
209
+ let link;
210
+ try {
211
+ link = await watch({
212
+ page,
213
+ agent: name,
214
+ key: identity.publicKey,
215
+ port: typeof args.port === "string" ? Number(args.port) : 0,
216
+ });
217
+ } catch (e) {
218
+ fail(e.message);
219
+ }
198
220
  console.log(`Open this page: ${link.url}\n`);
199
221
  console.log(
200
222
  `While this runs, ${name} is listed for the page and the page offers "Share with my agent" (off until you switch it on).`,
201
223
  );
224
+ if (link.addon)
225
+ console.log(
226
+ link.support === "unknown"
227
+ ? "It couldn't tell which Pipeup the page uses, so it adds the agents add-on to the page it shows (the add-on stays off on a Pipeup that already has agent support). The page's file is not changed."
228
+ : "It adds the agents add-on to the page it shows. The page's file is not changed.",
229
+ );
230
+ else if (link.support === "builtin")
231
+ console.log("This page's Pipeup (0.6) has agent support built in, so nothing is added to it.");
202
232
  console.log(
203
233
  `Read: npx pipeup comments ${page}\nReply: npx pipeup reply --page ${page} --thread <id> "words" --addressed "note"\nStop: Ctrl-C (comments stay in ${basename(link.sidecar)})`,
204
234
  );
@@ -0,0 +1,2 @@
1
+ "use strict";(()=>{function b(e){let t=globalThis;(t.pipeupAddons||=[]).push(e),typeof addEventListener=="function"&&addEventListener("load",()=>{t.Pipeup||console.error(`pipeup: the "${e.id}" add-on needs Pipeup on the page too; load pipeup.min.js.`)},{once:!0})}var re=2n**256n-2n**32n-977n;var L=/^\/__pipeup\/[A-Za-z0-9_-]{16,}$/;function P(e){let t=document.documentElement,n=t.getAttribute("data-pipeup-link")??"",s=L.test(n),o=!1,c=0,y=()=>{};async function r(){let d=!1;try{d=(await fetch(`${n}/comments`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(await e.snapshot())})).ok}catch{}if(!d)throw new Error("pipeup: the link to your agent isn't reachable; your comments are safe here")}let u=()=>{window.clearTimeout(c),y(),y=()=>{},o=!1};return{enabled:s,agent:t.getAttribute("data-pipeup-link-agent")??"",file:t.getAttribute("data-pipeup-link-file")??"",on:()=>o,set:async d=>{if(s){if(!d){u(),e.refresh();return}await r(),o=!0,y=e.changes(()=>{window.clearTimeout(c),c=window.setTimeout(()=>{r().catch(h=>{u(),e.refresh(),globalThis.reportError?.(h)})},300)}),e.refresh()}},destroy:u}}function T(e){let t=n=>n.replace(/\s+/g," ").trim();return[`Act on the review comments on this page, which uses Pipeup: ${t(e.title)} (${t(e.url)}).`,"",e.file?`1. Read the comments: \`npx pipeup comments ${e.file}\` (add --all for resolved ones). If it says nothing is shared, ask me to switch on Share with my agent.`:"1. Read the comments. Open the page in a browser and run `await Pipeup.comments()`, then read `.markdown`. If you can't run the page, ask me to paste its Copy as Markdown.","2. Work through the open threads in order. For each, make the change I approved, or say why you didn't. Ask me before changing anything I haven't approved, and answer a question with an answer.",e.replies?`3. Answer in each thread: \`npx pipeup reply --page ${e.file||"<the page's file>"} --thread <Thread id> "what you changed and where" --addressed "short note"\`. That writes ${t(e.replies)}.`+(e.listed?"":" The page doesn't list your key yet: run `npx pipeup agent --name <your name>` and tell me the attribute to add."):"3. Tell me what you would reply in each thread, in words I can paste.","4. Only mark a thread addressed. Never resolve, reopen, edit or delete: I close threads. Keep your key out of the page and its folder, and never answer as me.","","Guide: https://pipeup-ai.github.io/pipeup/skills/pipeup-apply/SKILL.md"].join(`
2
+ `)}var C=/^[A-Za-z0-9_-]{43}$/,_="pipeupReplies";var D=/^AI assistant\s*·?\s*/;function x(e=document.documentElement){let t=e.getAttribute("data-pipeup-agents")??"";return[...new Set(t.split(/[\s,]+/).filter(n=>C.test(n)))]}function k(e=location,t=document.documentElement){let n=t.getAttribute("data-pipeup-replies");try{let s=e.href.split(/[?#]/)[0],o=new URL(n??(/\/$/.test(s)?`${s}index.replies.js`:s.replace(/\.[^./]*$/,"")+".replies.js"),e.href),c=o.protocol==="file:"?e.protocol==="file:":o.origin===e.origin;return c||console.warn("pipeup: the replies file must be on this site or this computer; ignoring it"),c?o.href:null}catch{return null}}function E(e){let t=x(),n=0,s="",o=null;async function c(){let l=globalThis[_];if(!Array.isArray(l))return 0;let p=l.splice(0),g=[];for(let f of p){let m=f;if(!(m?.format!=="pipeup-replies"||m.doc!==e.document.id||!Array.isArray(m.ops)))for(let i of m.ops){let a=i?.body;a&&t.includes(a.author)&&(a.kind==="reply"||a.kind==="addressed"||a.kind==="react"||a.kind==="unreact")?(g.push(i),(a.name==="AI assistant"||a.name.startsWith("AI assistant "))&&(s=a.name.replace(D,""))):n++}}return g.length?e.merge(g):0}function y(){let l=k();return l?(o?.remove(),new Promise((p,g)=>{let f=document.createElement("script");o=f,f.src=`${l}?${Date.now()}`;let m=a=>{window.clearTimeout(i),f.remove(),o===f&&(o=null),a?g(new Error(`pipeup: ${a}`)):p()},i=window.setTimeout(()=>m("the replies file didn't answer"),4e3);f.addEventListener("load",()=>m()),f.addEventListener("error",()=>m("there is no replies file yet")),e.overlay().append(f)})):Promise.reject(new Error("pipeup: the replies file can't be read from here"))}async function r(){if(!t.length)return 0;let l=await c();return await y(),l+await c()}let u=!1,d=()=>{u||document.hidden||(u=!0,r().catch(()=>{}).finally(()=>u=!1))},h=0;return t.length?(d(),h=window.setInterval(d,4e3),document.addEventListener("visibilitychange",d)):c(),{enabled:t.length>0,ignored:()=>n,who:()=>s,check:r,tally:l=>{let p=l.filter(i=>!i.resolved),g=i=>i.root.replies.some(a=>t.includes(a.author)),f=new Set(l.map(i=>i.id));return{stray:new Set(e.document.ops().filter(i=>t.includes(i.body.author)&&!f.has(i.body.thread)).map(i=>i.body.thread)).size,open:p.length,answered:p.filter(g).length,addressed:p.filter(i=>i.addressed).length}},destroy:()=>{window.clearInterval(h),document.removeEventListener("visibilitychange",d),o?.remove()}}}var R=()=>globalThis.Pipeup??{},H=["M10 8h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z","M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"],N=["M21 12L3.5 4.5 6.2 12l-2.7 7.5zM6.2 12H13"],z="Reads a replies file next to this page. When the page is shown through pipeup watch, and only after you switch it on, it sends this page's comments to the program running on this computer. Nothing leaves this computer.",S=e=>e instanceof Error?e.message.replace(/^pipeup: /,""):"Something went wrong";async function K(e,t){try{await navigator.clipboard.writeText(e);return}catch{}let n=document.createElement("textarea");n.value=e,n.setAttribute("readonly",""),n.style.cssText="position:fixed;opacity:0;pointer-events:none",t.append(n),n.select();let s=document.execCommand("copy");if(n.remove(),!s)throw new Error("pipeup: your browser blocked copying")}function U(){return{id:"agents",api:1,version:"0.6.0",needs:["menu","status","notify","listnote"],network:{when:"page-configured",to:["page"],says:z},setup(e){let t=E(e),n=P({snapshot:async()=>{let r=R();if(!r.comments)throw new Error("pipeup: this page has no Pipeup to read comments from");let[u,d]=await Promise.all([r.comments(),r.comments({all:!0})]);return{v:1,at:new Date().toISOString(),title:document.title,open:u,all:d}},changes:r=>e.document.onChange(r),refresh:()=>o()}),s=()=>t.who()||n.agent,o=()=>{if(e.setStatus(t.enabled||n.enabled?{text:n.on()?`Sharing comments with ${s()||"your agent"}`:`${s()||"An AI agent"} can answer comments here`}:null),!t.enabled)return e.setListNote(null);let r=t.tally(e.document.threads()),u=s()||"An agent",d=r.answered?`${u} has answered ${r.answered} of ${r.open} open comments${r.addressed?`; ${r.addressed} ${r.addressed===1?"is":"are"} marked addressed`:""}`:"No replies from an agent yet",h=t.ignored(),l=(n.on()?`Sharing with ${n.agent||"your agent"} \xB7 `:"")+d+(h?` \xB7 ${h} from other writers ignored`:"")+(r.stray?` \xB7 replies for ${r.stray} comment${r.stray===1?"":"s"} this page doesn't have`:"");e.setListNote({text:l,action:{label:"Check for replies",run:async()=>{try{let p=await t.check();e.notify(p?`${p} new ${p===1?"reply":"replies"}`:"No new replies")}catch(p){e.notify(S(p))}finally{o()}}}})},c=[e.addMenuItem({id:"prompt",icon:H,label:()=>"Copy a prompt for my agent",hint:()=>"Tells an AI agent where the comments are and how to answer them",select:async()=>{try{await K(T({title:document.title,url:location.href,replies:k(),file:n.enabled?n.file:"",listed:x().length>0}),e.overlay()),e.notify("Copied a prompt for your agent")}catch(r){e.notify(S(r))}}})];n.enabled&&c.push(e.addMenuItem({id:"link",icon:N,label:()=>"Share with my agent",hint:()=>"Sends this page's comments to the program running on this computer, and nothing else",checked:()=>n.on(),select:async()=>{try{await n.set(!n.on())}catch(r){e.notify(S(r))}o()}}));let y=e.document.onChange(o);return o(),()=>{y(),t.destroy(),n.destroy()}}}}b(U());})();