pipeup 0.8.1 → 0.9.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/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.8.1).** The API and the stored comment format may change before 1.0; pin an exact version.
5
+ > **Alpha (0.9.0).** 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,
@@ -16,14 +16,20 @@ colour on a tint of it) at the top right of their comments; once they add a name
16
16
  identity row at the top of the menu) the avatar shows their initial in the same colours. Copy for AI names unnamed reviewers by
17
17
  their colour and animal.
18
18
 
19
- As a classic script (also works from `file://`), it mounts itself on pages that carry a document id:
19
+ As a classic script (also works from `file://`), it mounts itself (`data-pipeup-auto="off"` stops that). The script
20
+ line alone is enough; a document id on `<html>` keeps comments with the page wherever it is opened, instead of with
21
+ its address:
20
22
 
21
23
  ```html
22
24
  <html data-pipeup-doc="<id>:<key>">
23
25
  ...
24
- <script src="https://cdn.jsdelivr.net/npm/pipeup@0.8.1/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
26
+ <script src="https://cdn.jsdelivr.net/npm/pipeup@0.9.0/dist/pipeup.min.js" integrity="sha384-…" crossorigin="anonymous" defer></script>
25
27
  ```
26
28
 
29
+ On a host that writes its own `<html>` (claude.ai Artifacts, for example), put the same settings on the script
30
+ line instead: `<script src="…/pipeup.min.js" data-pipeup-doc="<id>:<key>" …>`. Every `data-pipeup-*` setting works
31
+ there, for add-ons too.
32
+
27
33
  Each release's notes on GitHub give the `integrity` value
28
34
  (`node scripts/sri.mjs` prints it for a local build). Make a document key with
29
35
  `await Pipeup.newDocumentAttribute()` and keep it once people have commented.
@@ -39,11 +45,21 @@ Install with `npm i pipeup`. From a bundler (no side effects; call `mount` yours
39
45
 
40
46
  ```js
41
47
  import { mount } from "pipeup";
42
- const pipeup = await mount({ name: "Sam" }); // options: root, name (optional), store, slides
48
+ const pipeup = await mount({ name: "Sam" }); // options: root, name, store, slides, doc, layout, theme, select
43
49
  await pipeup.flush(); // write any unsaved changes now (this also happens when the page is hidden)
44
50
  pipeup.unmount(); // remove Pipeup from the page; the comments stay saved
45
51
  ```
46
52
 
53
+ A page with its own review controls (a Comment button in its header, a name field) drives Pipeup without reaching
54
+ inside it:
55
+
56
+ ```js
57
+ await pipeup.setCommentMode(true); // resolves once comment mode is on; pipeup.commentMode reads it
58
+ await pipeup.setName("Ada"); // kept as if the reviewer had typed it; pipeup.name reads it
59
+ const stop = pipeup.on("change", ({ commentMode, name }) => label(commentMode, name)); // however it changed
60
+ mount().catch((e) => show(e.code, e.message)); // "another-copy" or "failed", with words for people
61
+ ```
62
+
47
63
  Pages designed for review can reserve a comment gutter: add `data-pipeup-reserve` to `<html>` and lay the
48
64
  page out with `var(--pipeup-gutter, 0px)`. While All comments is open the page is moved over for it and its
49
65
  width is published as `--pipeup-panel`, so fixed bars can use `right: var(--pipeup-panel, 0px)` — see [examples/review-document.html](https://github.com/pipeup-ai/pipeup/blob/main/libs/ts/pipeup/examples/review-document.html).
@@ -64,14 +80,17 @@ Examples in the source repository, one per kind of page, each following the agen
64
80
  - [examples/review-site.html](https://github.com/pipeup-ai/pipeup/blob/main/libs/ts/pipeup/examples/review-site.html) — a site: clear blocks with stable ids for comment mode and pins; navigation ignored.
65
81
  - [examples/review-deck.html](https://github.com/pipeup-ai/pipeup/blob/main/libs/ts/pipeup/examples/review-deck.html) — a deck: one marked element per slide; slide controls ignored so they keep working, and one line so Pipeup can go to a comment's slide.
66
82
 
83
+ Selecting words offers a comment icon beside them, in comment mode or out of it; outside it, the comment leaves
84
+ comment mode off and the page keeps working (`data-pipeup-select="comment-mode"` keeps it to comment mode).
67
85
  Comments show whether comment mode is on or off: highlights, bubbles and the column can be read and opened, and
68
86
  outside comment mode the rest of the page works as normal. **Hide comments** in the control's menu puts them out
69
87
  of sight on that page (remembered in the browser); the control still counts the open threads, and comment mode,
70
88
  All comments or a comment being written show them again while they last.
71
89
 
72
90
  The comment control is a small square of frosted glass tucked into the bottom-right corner: the comment icon, or
73
- the number of open threads inside a comment bubble. Pointed at, it reaches left to show its shortcut; clicking (or tapping) it opens its menu of
74
- one-line rows, each explained by a tooltip on hover or focus. From the top: who you are (avatar and name, with
91
+ the number of open threads inside a comment bubble. One click (or tap) starts comment mode and another stops it.
92
+ Pointed at, it reaches left to show its shortcut and **⋯**, which opens its menu (by touch, press and hold the
93
+ control; from the keyboard, ↑ on it): one-line rows, each explained by a tooltip on hover or focus. From the top: who you are (avatar and name, with
75
94
  Add name, edited in place in that row), Copy comments (as Markdown for AI, or as text), Theme, All comments, a Hide comments switch, and nearest the button **Start commenting** with its shortcut and a switch, which leaves the menu open so you see it move. The menu closes on Esc, a click outside, or 3 seconds after the pointer leaves it.
76
95
  **All comments** is a panel on the right that moves the page over to make room: every thread in page order as a
77
96
  card, threads whose content is gone under "No longer on the page", and two icon toggles in its header, **@** (mentions
@@ -86,7 +105,7 @@ comment goes away; deleting a thread's first comment removes the thread. The com
86
105
  reply boxes have Cancel and Comment (Reply) buttons; Enter also sends. Elements the page
87
106
  fixes to the window don't move over with the page.
88
107
 
89
- Comment mode (press **Shift+Alt+C**, or **⇧⌥C** on a Mac, or switch on Start commenting in the control's menu) lets reviewers comment on blocks and drop pins
108
+ Comment mode (click the comment control, press **Shift+Alt+C**, or **⇧⌥C** on a Mac) lets reviewers comment on blocks and drop pins
90
109
  (Option-click, Alt-click, or the naming bar's Pin) on any page. While it is on, clicks and submits on the page do
91
110
  nothing, and selecting text offers a comment icon, clicking a highlight opens its thread, and
92
111
  clicking a block opens its comment box at once (a bar names the block; Around it and Inside it move the box to the
package/cli/asks.d.mts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Thread } from "../src/model/types";
2
+
3
+ export interface PageAsk {
4
+ id: string;
5
+ question: string;
6
+ kind: "open" | "agree" | "choice";
7
+ options: string[];
8
+ other: boolean;
9
+ needed: boolean;
10
+ }
11
+ export interface ReadAnswer {
12
+ thread: string;
13
+ name: string;
14
+ author: string;
15
+ choice: string;
16
+ words: string;
17
+ text: string;
18
+ at: string;
19
+ edited: boolean;
20
+ replies: number;
21
+ askedAs?: string;
22
+ }
23
+ export function asksIn(html: string): PageAsk[];
24
+ export function parseAnswer(ask: PageAsk, text: string): { choice: string; words: string } | null;
25
+ export function asksWithAnswers(
26
+ html: string,
27
+ threads: readonly Thread[],
28
+ agents?: readonly string[],
29
+ ): (PageAsk & { answers: ReadAnswer[] })[];
30
+ export function asksMarkdown(
31
+ asks: (PageAsk & { answers: ReadAnswer[] })[],
32
+ nameOf: (key: string) => string,
33
+ ): string;
package/cli/asks.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // The asks a page puts in its content (spec §11b, the inline add-on), with each reviewer's latest answer, for
2
+ // `pipeup read`. Read from the page's markup and the comments, as the add-on reads them: an answer is a comment on the
3
+ // ask's block, kept with the question it answered ("Asked: …"), whose words fit the ask's kind.
4
+
5
+ const ASKED = "Asked: ";
6
+ const AGREE = "Agree";
7
+ const CHANGE = "Needs change";
8
+ const OTHER = "Other";
9
+
10
+ const unescape = (s) =>
11
+ s
12
+ .replace(/&quot;/g, '"')
13
+ .replace(/&#39;|&apos;/g, "'")
14
+ .replace(/&lt;/g, "<")
15
+ .replace(/&gt;/g, ">")
16
+ .replace(/&amp;/g, "&");
17
+
18
+ /** A tag's attributes, by name (lower case); a bare attribute is "". */
19
+ function attrs(tag) {
20
+ const out = {};
21
+ for (const m of tag.matchAll(/\s([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g))
22
+ out[m[1].toLowerCase()] = unescape(m[2] ?? m[3] ?? m[4] ?? "");
23
+ return out;
24
+ }
25
+
26
+ /** The page's asks, in page order: { id, question, kind, options, other, needed }. */
27
+ export function asksIn(html) {
28
+ const asks = [];
29
+ for (const m of html.matchAll(/<[a-z][a-z0-9-]*\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi)) {
30
+ const a = attrs(m[0]);
31
+ if (!("data-pipeup-ask" in a)) continue;
32
+ const id = a["data-pipeup-id"] ?? "";
33
+ const question = a["data-pipeup-ask"].replace(/\s+/g, " ").trim();
34
+ if (!id || !question) continue;
35
+ const given = a["data-pipeup-ask-kind"];
36
+ const kind = given === "agree" || given === "choice" ? given : "open";
37
+ const options =
38
+ kind === "agree"
39
+ ? [AGREE, CHANGE]
40
+ : kind === "choice"
41
+ ? [
42
+ ...new Set(
43
+ (a["data-pipeup-ask-options"] ?? "")
44
+ .split("|")
45
+ .map((o) => o.trim())
46
+ .filter(Boolean),
47
+ ),
48
+ ]
49
+ : [];
50
+ if (kind === "choice" && (options.length < 2 || options.length > 6)) continue;
51
+ asks.push({
52
+ id,
53
+ question,
54
+ kind,
55
+ options,
56
+ other: kind === "choice" && "data-pipeup-ask-other" in a,
57
+ needed: "data-pipeup-ask-needed" in a,
58
+ });
59
+ }
60
+ return asks;
61
+ }
62
+
63
+ /** A comment's words as an answer to `ask` ({ choice, words }), or null. */
64
+ export function parseAnswer(ask, text) {
65
+ const t = text.trim();
66
+ if (!t) return null;
67
+ if (ask.kind === "open") return { choice: "", words: t };
68
+ for (const choice of [...ask.options, ...(ask.other ? [OTHER] : [])]) {
69
+ if (t === choice && choice !== CHANGE && choice !== OTHER) return { choice, words: "" };
70
+ if ((ask.kind === "agree" || choice === OTHER) && t.startsWith(`${choice}:`)) {
71
+ const words = t.slice(choice.length + 1).trim();
72
+ if (words || choice === AGREE) return { choice, words };
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Pipeup's name for AI writers: "AI assistant", or that and more words. */
79
+ const AI_NAME = "AI assistant";
80
+
81
+ /**
82
+ * Each ask with every reviewer's latest answer (and the question it answered, when the words have changed since).
83
+ * Agents never answer asks: a thread there by an agent the page lists (`agents`), or under an AI name, isn't counted.
84
+ */
85
+ export function asksWithAnswers(html, threads, agents = []) {
86
+ return asksIn(html).map((ask) => {
87
+ const latest = new Map();
88
+ for (const t of [...threads].sort((a, b) => a.root.at - b.root.at)) {
89
+ if (t.anchor.id !== ask.id || t.root.deleted || !t.anchor.snapshot?.startsWith(ASKED)) continue;
90
+ const name = t.root.name ?? "";
91
+ if (agents.includes(t.root.author) || name === AI_NAME || name.startsWith(`${AI_NAME} `)) continue;
92
+ const answer = parseAnswer(ask, t.root.text);
93
+ if (!answer) continue;
94
+ const was = t.anchor.snapshot.slice(ASKED.length);
95
+ latest.set(t.root.author, {
96
+ thread: t.id,
97
+ name: t.root.name,
98
+ author: t.root.author,
99
+ ...answer,
100
+ text: t.root.text,
101
+ at: new Date(t.root.at).toISOString(),
102
+ edited: t.root.edited,
103
+ replies: t.root.replies.length,
104
+ ...(was && was !== ask.question.slice(0, was.length) ? { askedAs: was } : {}),
105
+ });
106
+ }
107
+ return { ...ask, answers: [...latest.values()] };
108
+ });
109
+ }
110
+
111
+ /** The asks as Markdown, after the comments: each question, its kind, and who answered what. */
112
+ export function asksMarkdown(asks, nameOf) {
113
+ if (!asks.length) return "";
114
+ const lines = ["", "## Asks", ""];
115
+ for (const a of asks) {
116
+ const kind =
117
+ a.kind === "choice"
118
+ ? `choice: ${[...a.options, ...(a.other ? [OTHER] : [])].join(", ")}`
119
+ : a.kind === "agree"
120
+ ? "agree or needs change"
121
+ : "open";
122
+ lines.push(`### "${a.question}"`, "", `- Name: \`${a.id}\` · ${kind}${a.needed ? " · needed" : ""}`);
123
+ if (!a.answers.length) lines.push("- No answers yet.");
124
+ for (const r of a.answers)
125
+ lines.push(
126
+ `- ${r.name || nameOf(r.author)}: ${r.text.split("\n")[0]}${r.edited ? " (changed)" : ""}${r.askedAs ? ` (asked as: "${r.askedAs}")` : ""} — thread ${r.thread}`,
127
+ );
128
+ lines.push("");
129
+ }
130
+ return lines.join("\n");
131
+ }
package/cli/init.mjs CHANGED
@@ -67,7 +67,7 @@ function attrSpan(tag, name) {
67
67
  const attrEnd = (tag) => tag.length - (/\s*\/?>$/.exec(tag)?.[0].length ?? 1);
68
68
 
69
69
  /** What the page already has: its <html> tag, Pipeup's script, add-ons. */
70
- function survey(html) {
70
+ export function survey(html) {
71
71
  const h = HTML_TAG.exec(html);
72
72
  if (!h) throw new InitError("the page has no <html> element; add one around the page and run this again");
73
73
  const found = { html: { at: h.index, tag: h[0] }, core: null, addons: new Map(), lastScriptEnd: -1 };
@@ -298,7 +298,7 @@ export function candidates(html) {
298
298
  }
299
299
 
300
300
  /** Applies edits (each { at, del, text }) from the end of the file back, so earlier offsets stay right. */
301
- function apply(html, edits) {
301
+ export function apply(html, edits) {
302
302
  let out = html;
303
303
  for (const e of [...edits].sort((a, b) => b.at - a.at || b.order - a.order))
304
304
  out = out.slice(0, e.at) + e.text + out.slice(e.at + (e.del ?? 0));
@@ -306,7 +306,7 @@ function apply(html, edits) {
306
306
  }
307
307
 
308
308
  /** Sets (or appends to) an attribute on the <html> start tag: returns the edit, or null when nothing changes. */
309
- function htmlAttr(found, name, value, { append = false } = {}) {
309
+ export function htmlAttr(found, name, value, { append = false } = {}) {
310
310
  const { at, tag } = found.html;
311
311
  const span = attrSpan(tag, name);
312
312
  if (!span) return { at: at + attrEnd(tag), text: ` ${name}="${escapeAttr(value)}"` };
package/cli/pipeup.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // pipeup agent / pipeup reply: how an AI agent answers comments on a Pipeup page.
3
3
  // The agent's key stays in ~/.pipeup/agent.json (owner-only) and never goes in the page or its folder.
4
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { basename, dirname, join, resolve } from "node:path";
6
+ import { basename, dirname, join, relative, resolve } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { check, table } from "./check.mjs";
8
- import { init, InitError } from "./init.mjs";
9
+ import { apply, htmlAttr, init, InitError, survey } from "./init.mjs";
9
10
  import { read, ReadError } from "./read.mjs";
10
11
  import { comments, watch } from "./link.mjs";
11
12
  import * as skills from "./skills.mjs";
@@ -30,6 +31,7 @@ pipeup read <page.html> [--from <file or folder>]… [--deep] [--all] [--json] [
30
31
  feedback files and folders of them (--deep looks in folders inside), the page's shared copy (with @pipeup/share
31
32
  installed; --no-share reads nothing from the network) and its replies file. Every comment is checked as the page
32
33
  checks it; what was read, skipped or left out is said on standard error. --out writes one merged feedback file.
34
+ Asks the page puts in its content (the inline add-on) follow, each with its question, kind and answers.
33
35
  Exit codes: 0 done, 1 a source couldn't be read, 2 the command needs something first.
34
36
 
35
37
  pipeup skills install|remove|list [--project] [--agent claude|codex|all] [--yes] [--replace] [--dry-run]
@@ -41,9 +43,10 @@ pipeup check <page.html> [--json]
41
43
  Loads the page with and without Pipeup at 375, 768 and 1440 px (with Playwright, which you install) and says what
42
44
  Pipeup changes or covers, and what the page could do better: pass, WARN or FAIL for each. Exits 1 on any FAIL.
43
45
 
44
- pipeup agent [--name <name>]
46
+ pipeup agent [--name <name>] [--page <page.html>] [--yes]
45
47
  Makes this computer's agent key (once) and prints the public key to list on the page:
46
- <html data-pipeup-agents="<key>">
48
+ <html data-pipeup-agents="<key>">. With --page it lists the agent on that page itself: it shows the one change and
49
+ asks first (--yes to go ahead without asking; where it can't ask, it changes nothing and exits 2).
47
50
 
48
51
  pipeup watch <page.html> [--agent <name>] [--port <number>]
49
52
  Shows the page from this computer, with a live link to the agent: the page offers "Share with my agent"
@@ -55,8 +58,66 @@ pipeup comments <page.html> [--all] [--json]
55
58
  Prints the comments the page has shared (open threads, or all with --all), as Markdown or, with --json, as data.
56
59
 
57
60
  pipeup reply --page <file.html> --thread <id> "<words>" [--addressed "<short note>"]
58
- Writes a signed reply (and, with --addressed, an addressed note) to <file>.replies.js next to the page.
59
- It never resolves, reopens, edits or deletes anything: the author closes threads.`;
61
+ Writes a signed reply (and, with --addressed, an addressed note) where the page reads replies: <file>.replies.js
62
+ next to the page, or the file the page names. The page is the HTML file that runs Pipeup, never a file it shows.
63
+ It never resolves, reopens, edits or deletes anything: the author closes threads.
64
+
65
+ pipeup reply --print (--doc <document id> | --page <file.html>) --thread <id> "<words>" [--addressed "<short note>"]
66
+ Prints the same signed reply as a replies block for the person to paste into the page (Paste my agent's replies),
67
+ for an agent that can't write next to the page. Copy for my agent gives the document id. Nothing is written.
68
+
69
+ Agents run these as \`npx --yes pipeup@0 …\`: any release from 0.6 answers agent, reply and comments the same way.`;
70
+
71
+ /** How agents are told to run this command line: never stopping to ask, and any release npm will give. */
72
+ const RUN = "npx --yes pipeup@0";
73
+ /** A path as the person typed it, or relative to where they are. */
74
+ const shown = (file) => relative(process.cwd(), resolve(file)) || file;
75
+ const DOC = /<html\b[^>]*\sdata-pipeup-doc\s*=\s*["']([^"']+)["']/i;
76
+
77
+ /**
78
+ * The options each agent command knows: one this release doesn't know is said, and the rest carry on (a newer
79
+ * agent's command still works with an older release).
80
+ */
81
+ const KNOWN = {
82
+ agent: ["name", "page", "yes"],
83
+ reply: ["page", "thread", "addressed", "print", "doc"],
84
+ comments: ["all", "json"],
85
+ watch: ["agent", "port"],
86
+ };
87
+ function unknown(command, args) {
88
+ for (const k of Object.keys(args))
89
+ if (k !== "_" && KNOWN[command] && !KNOWN[command].includes(k))
90
+ console.warn(`pipeup: this release doesn't know --${k}; carrying on without it`);
91
+ }
92
+
93
+ /** The HTML file nearest to `file` (its folder, then the folders above) that runs Pipeup, or null. */
94
+ async function nearbyPage(file) {
95
+ const self = resolve(file);
96
+ let dir = dirname(self);
97
+ for (let i = 0; i < 4; i++) {
98
+ const names = await readdir(dir).catch(() => []);
99
+ for (const n of names.filter((n) => /\.html?$/i.test(n)).sort()) {
100
+ const p = join(dir, n);
101
+ if (p !== self && DOC.test(await readFile(p, "utf8").catch(() => ""))) return p;
102
+ }
103
+ const up = dirname(dir);
104
+ if (up === dir) break;
105
+ dir = up;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** `file` doesn't run Pipeup (a note a viewer shows, say): nothing is written, and the page nearby is named. */
111
+ async function notPipeup(file, what) {
112
+ const near = await nearbyPage(file);
113
+ console.error(`pipeup: ${shown(file)} doesn't run Pipeup, so ${what}.`);
114
+ console.error(
115
+ near
116
+ ? `The page that shows it is probably ${shown(near)}:\n --page ${shown(near)}`
117
+ : "Pass the HTML page the comments were left on: --page <page.html>",
118
+ );
119
+ process.exit(1);
120
+ }
60
121
 
61
122
  function fail(message) {
62
123
  console.error(`pipeup: ${message}`);
@@ -109,13 +170,26 @@ async function loadAgent(name) {
109
170
  /** A name that shows it is an agent: "AI assistant · Claude". */
110
171
  const signedName = (name) => (name && name !== AI ? `${AI} · ${name}`.slice(0, 80) : AI);
111
172
 
173
+ /** Reads a page: its text, or a plain failure when it can't be read. */
174
+ const readPage = (file) => readFile(file, "utf8").catch((e) => fail(`can't read ${file}: ${e.message}`));
175
+
112
176
  async function pageDoc(file) {
113
- const html = await readFile(file, "utf8");
114
- const m = /<html\b[^>]*\sdata-pipeup-doc\s*=\s*["']([^"']+)["']/i.exec(html);
115
- if (!m) fail(`${file} has no data-pipeup-doc attribute; add Pipeup to the page first`);
177
+ const html = await readPage(file);
178
+ const m = DOC.exec(html);
179
+ if (!m) await notPipeup(file, "nothing was written");
116
180
  const { id } = await (await core()).parseDocumentAttribute(m[1]);
117
181
  const listed = /<html\b[^>]*\sdata-pipeup-agents\s*=\s*["']([^"']*)["']/i.exec(html)?.[1] ?? "";
118
- return { id, listed: listed.split(/[\s,]+/).filter(Boolean) };
182
+ // Where the page reads replies: the file it names (a path beside it), else <page>.replies.js.
183
+ const given = /<html\b[^>]*\sdata-pipeup-replies\s*=\s*["']([^"']+)["']/i.exec(html)?.[1];
184
+ const local = given && !/^[a-z][\w+.-]*:|^\/\//i.test(given);
185
+ if (given && !local)
186
+ console.warn(
187
+ `pipeup: the page reads replies from ${given}, which isn't a file here; writing next to the page`,
188
+ );
189
+ const replies = local
190
+ ? resolve(dirname(file), given.split(/[?#]/)[0])
191
+ : resolve(file.replace(/\.[^./\\]*$/, "") + ".replies.js");
192
+ return { id, listed: listed.split(/[\s,]+/).filter(Boolean), replies };
119
193
  }
120
194
 
121
195
  const headerOf = (file) =>
@@ -132,24 +206,71 @@ async function readReplies(path, doc) {
132
206
  }
133
207
  }
134
208
 
209
+ /** One entry of a replies block, signed: the person pastes it into the page, which checks it against the listed keys. */
210
+ function printBlock(name, thread, words, note, ops) {
211
+ const field = (key, text) =>
212
+ text
213
+ .split("\n")
214
+ .map((line, i) => (i ? ` ${line}` : `${key}: ${line}`))
215
+ .join("\n");
216
+ return [
217
+ "```pipeup-replies",
218
+ `agent: ${name}`,
219
+ `thread: ${thread}`,
220
+ field("reply", words),
221
+ ...(note ? [field("addressed", note)] : []),
222
+ `signed: ${b64u(new TextEncoder().encode(JSON.stringify(ops)))}`,
223
+ "```",
224
+ ].join("\n");
225
+ }
226
+
135
227
  async function reply(args) {
136
228
  const page = args.page;
137
229
  const thread = args.thread;
138
230
  const words = args._[0]?.trim();
139
- if (typeof page !== "string" || typeof thread !== "string" || !words)
140
- fail(`missing words, --page or --thread\n\n${USAGE}`);
231
+ const print = args.print === true;
232
+ const given = typeof args.doc === "string" && print ? args.doc.trim() : "";
233
+ if ((typeof page !== "string" && !given) || typeof thread !== "string" || !words)
234
+ fail(`missing words, ${print ? "--doc" : "--page"} or --thread\n\n${USAGE}`);
235
+ if (given && given.length > 64) fail("that isn't a document id");
141
236
  const note = typeof args.addressed === "string" ? args.addressed.trim() : "";
142
- const { id: doc, listed } = await pageDoc(page);
237
+ const {
238
+ id: doc,
239
+ listed,
240
+ replies: out,
241
+ } = given ? { id: given, listed: null, replies: "" } : await pageDoc(page);
143
242
  const { identity, name } = await loadAgent();
243
+ if (print) {
244
+ let clock = Math.floor(Date.now() / 1000);
245
+ let at = Date.now();
246
+ const { computeOpId, signOp } = await core();
247
+ const make = async (fields) => {
248
+ const body = {
249
+ v: 1,
250
+ ...fields,
251
+ id: "",
252
+ doc,
253
+ author: identity.publicKey,
254
+ name: signedName(name),
255
+ clock: clock++,
256
+ at: at++,
257
+ };
258
+ body.id = await computeOpId(body);
259
+ return signOp(body, identity);
260
+ };
261
+ const ops = [await make({ kind: "reply", thread, target: thread, text: words })];
262
+ if (note) ops.push(await make({ kind: "addressed", thread, text: note }));
263
+ if (listed && !listed.includes(identity.publicKey))
264
+ console.error(
265
+ `pipeup: this page doesn't list your key, so these replies will show as pasted by the person.\n To be listed: <html data-pipeup-agents="${identity.publicKey}">`,
266
+ );
267
+ console.log(printBlock(name, thread, words, note, ops));
268
+ return;
269
+ }
144
270
  const linked = await readFile(page.replace(/\.[^./\\]*$/, "") + ".comments.json", "utf8").then(
145
271
  () => true,
146
272
  () => false,
147
273
  );
148
- if (!listed.includes(identity.publicKey) && !linked)
149
- console.warn(
150
- `pipeup: this page doesn't list your key yet, so it will ignore these replies.\n Add: <html data-pipeup-agents="${identity.publicKey}">`,
151
- );
152
- const out = resolve(page.replace(/\.[^./\\]*$/, "") + ".replies.js");
153
274
  const ops = await readReplies(out, doc);
154
275
  // Seconds since 1970: later than any counter a browser has kept, so these follow the thread they answer.
155
276
  let clock = Math.max(Math.floor(Date.now() / 1000), ...ops.map((o) => o.body.clock + 1));
@@ -173,16 +294,57 @@ async function reply(args) {
173
294
  if (note) added.push(await make({ kind: "addressed", thread, text: note }));
174
295
  const file = { format: "pipeup-replies", v: 1, doc, ops: [...ops, ...added] };
175
296
  await writeFile(out, `${headerOf(page)}(globalThis.pipeupReplies ||= []).push(${JSON.stringify(file)});\n`);
176
- console.log(`Wrote ${added.length} ${added.length === 1 ? "entry" : "entries"} to ${out}`);
297
+ console.log(`Wrote ${added.length} ${added.length === 1 ? "entry" : "entries"} to ${shown(out)}.`);
298
+ // Written either way, so nothing is lost: the page shows them once it lists the agent.
299
+ if (!listed.includes(identity.publicKey) && !linked)
300
+ console.warn(
301
+ `The page will ignore them until it lists ${name || "your agent"}. Ask the page's author. After their yes, run:\n ${RUN} agent --page ${shown(page)} --yes`,
302
+ );
303
+ else console.log("The page reads this file: the reply shows in its thread within a few seconds.");
304
+ }
305
+
306
+ /**
307
+ * Lists the agent on a page: the one change (its key in data-pipeup-agents on <html>) is shown and asked about first;
308
+ * --yes goes ahead. Where it can't ask (an agent's shell, with nobody to answer), nothing changes and it exits 2.
309
+ */
310
+ async function listOn(page, identity, name, yes) {
311
+ const html = await readPage(page);
312
+ if (!DOC.test(html)) await notPipeup(page, "nothing was changed");
313
+ const found = survey(html);
314
+ const edit = htmlAttr(found, "data-pipeup-agents", identity.publicKey, { append: true });
315
+ if (!edit) return console.log(`${name} is already listed on ${shown(page)}; nothing was changed.`);
316
+ const next = apply(html, [{ ...edit, order: 0 }]);
317
+ console.log(
318
+ `\nThis ${edit.del ? `adds ${name}'s key to the page's agents` : "adds one attribute"} on ${shown(page)}, and changes nothing else:\n ${survey(next).html.tag}\n`,
319
+ );
320
+ if (!yes) {
321
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
322
+ console.log(
323
+ "This shell can't ask, so nothing was changed.\nAsk the page's author. After their yes, run it again with --yes.",
324
+ );
325
+ process.exitCode = 2;
326
+ return;
327
+ }
328
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
329
+ const answer = await rl.question("Add it? (y/N) ");
330
+ rl.close();
331
+ if (!/^y(es)?$/i.test(answer.trim())) return console.log("Nothing was changed.");
332
+ }
333
+ await writeFile(page, next);
334
+ console.log(`Listed ${name} on ${shown(page)}. Reload the page, and it shows ${name}'s replies.`);
177
335
  }
178
336
 
179
337
  const [command, ...rest] = process.argv.slice(2);
180
338
  const args = flags(rest);
339
+ unknown(command, args);
181
340
  if (command === "agent") {
182
341
  const { identity, name } = await loadAgent(args.name);
183
- console.log(
184
- `${name} (${KEYFILE})\n\nList this key on the page:\n <html data-pipeup-agents="${identity.publicKey}">`,
185
- );
342
+ console.log(`${name} (${KEYFILE})`);
343
+ if (typeof args.page === "string") await listOn(args.page, identity, name, args.yes === true);
344
+ else
345
+ console.log(
346
+ `\nList this key on the page:\n <html data-pipeup-agents="${identity.publicKey}">\nOr list it in one step: ${RUN} agent --page <page.html>`,
347
+ );
186
348
  } else if (command === "reply") await reply(args);
187
349
  else if (command === "skills") {
188
350
  const sub = args._[0];
@@ -299,7 +461,7 @@ else if (command === "skills") {
299
461
  else if (link.support === "builtin")
300
462
  console.log("This page's Pipeup (0.6) has agent support built in, so nothing is added to it.");
301
463
  console.log(
302
- `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)})`,
464
+ `Read: ${RUN} comments ${page}\nReply: ${RUN} reply --page ${page} --thread <id> "words" --addressed "note"\nStop: Ctrl-C (comments stay in ${basename(link.sidecar)})`,
303
465
  );
304
466
  const stop = () => link.close().then(() => process.exit(0));
305
467
  process.on("SIGINT", stop);
package/cli/read.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readdir, readFile, stat, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { asksMarkdown, asksWithAnswers } from "./asks.mjs";
6
7
 
7
8
  const core = () => import("../dist/pipeup.core.js");
8
9
  const MAX_MERGE = 5000;
@@ -52,7 +53,7 @@ async function filesIn(dir, deep) {
52
53
 
53
54
  /**
54
55
  * The share add-on's headless reader, if it is installed: beside this package, or in the project the command runs in
55
- * (as with `npx pipeup read` in a folder where `@pipeup/share` is installed). It is never a dependency of `pipeup`.
56
+ * (as with `npx --yes pipeup@0 read` in a folder where `@pipeup/share` is installed). It is never a dependency of `pipeup`.
56
57
  */
57
58
  async function shareReader() {
58
59
  try {
@@ -118,6 +119,7 @@ export async function read(o) {
118
119
  parseDocumentAttribute,
119
120
  readFeedbackFile,
120
121
  agentComments,
122
+ animalName,
121
123
  } = await core();
122
124
  let id;
123
125
  let key;
@@ -286,6 +288,16 @@ export async function read(o) {
286
288
  all: o.all,
287
289
  });
288
290
 
291
+ // The asks the page puts in its content (§11b), each with every reviewer's latest answer, so an agent can work through them.
292
+ const asks = asksWithAnswers(
293
+ html,
294
+ threads,
295
+ (attrOf(html, "data-pipeup-agents") ?? "").split(/[\s,]+/).filter(Boolean),
296
+ );
297
+ if (asks.length) {
298
+ out.asks = asks;
299
+ out.markdown = `${out.markdown.trimEnd()}\n${asksMarkdown(asks, animalName)}`;
300
+ }
289
301
  if (o.out) {
290
302
  try {
291
303
  await writeFile(o.out, await doc.exportFile());