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 +26 -7
- package/cli/asks.d.mts +33 -0
- package/cli/asks.mjs +131 -0
- package/cli/init.mjs +3 -3
- package/cli/pipeup.mjs +186 -24
- package/cli/read.mjs +13 -1
- package/dist/addons.json +15 -11
- package/dist/pipeup.agents.js +16 -2
- package/dist/pipeup.core.js +1 -1
- package/dist/pipeup.esm.js +1 -1
- package/dist/pipeup.min.js +1 -1
- package/dist/types/anchor/locate.d.ts +4 -2
- package/dist/types/core.d.ts +4 -4
- package/dist/types/document.d.ts +6 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/model/animals.d.ts +10 -0
- package/dist/types/model/ops.d.ts +2 -0
- package/dist/types/ui/addons.d.ts +62 -1
- package/dist/types/ui/app.d.ts +2 -0
- package/dist/types/ui/composer.d.ts +6 -0
- package/dist/types/ui/context.d.ts +2 -0
- package/dist/types/ui/here.d.ts +8 -0
- package/dist/types/ui/icons.d.ts +1 -0
- package/dist/types/ui/mentions.d.ts +2 -0
- package/dist/types/ui/mount.d.ts +34 -1
- package/dist/types/ui/notes.d.ts +8 -1
- package/dist/types/ui/pick.d.ts +2 -2
- package/dist/types/ui/select.d.ts +5 -1
- package/dist/types/ui/settings.d.ts +6 -0
- package/dist/types/ui/styles.d.ts +1 -1
- package/dist/types/ui/theme.d.ts +1 -1
- package/dist/types/ui/thread-view.d.ts +5 -0
- package/package.json +1 -1
- package/skills/pipeup-add/SKILL.md +3 -3
- package/skills/pipeup-apply/SKILL.md +44 -12
- package/skills/pipeup-integrate/SKILL.md +45 -12
- package/skills/pipeup-send-to-git-setup/SKILL.md +1 -1
- package/skills/pipeup-summarise/SKILL.md +7 -6
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
74
|
-
|
|
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
|
|
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(/"/g, '"')
|
|
13
|
+
.replace(/'|'/g, "'")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/&/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)
|
|
59
|
-
|
|
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
|
|
114
|
-
const m =
|
|
115
|
-
if (!m)
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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());
|