letmeknow-cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +21 -97
  2. package/SKILL.md +125 -33
  3. package/bin/letmeknow.js +190 -64
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -1,129 +1,53 @@
1
1
  # LetMeKnow
2
2
 
3
- LetMeKnow gives an agent a temporary public browser surface and structured human feedback. The agent authors ordinary HTML and static assets; a running CLI serves them and accepts page updates over a single ordered event stream.
3
+ LetMeKnow gives an agent a temporary public browser surface for structured human feedback. The agent serves ordinary HTML and static assets, receives browser submissions, and can push ordered HTML replacements.
4
4
 
5
- ## Start a session
5
+ ## Quick start
6
6
 
7
- Create a directory containing the public files, including an initial `index.html`, then run:
7
+ Create a directory with an `index.html`, then run:
8
8
 
9
9
  ```bash
10
10
  npx letmeknow-cli serve ./preview
11
11
  ```
12
12
 
13
- `serve` reads `index.html` once as the canonical dynamic page and serves it at `/`. Other files in the directory—such as CSS, JavaScript, images, and data—are served live as static assets. The CLI prints one JSON line containing the public bearer URL:
13
+ The CLI prints one JSON line containing the public bearer URL:
14
14
 
15
15
  ```json
16
16
  {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
17
17
  ```
18
18
 
19
- Give the URL to the human. Anyone with the URL can view the page and submit its forms. Canonical page state and the event stream live in memory while `serve` runs; they do not survive a stopped session. The CLI connects outbound and does not listen on a network port.
19
+ Give the URL to the human. Anyone with the URL can view the page and submit its forms.
20
20
 
21
- The agent’s files are never modified by `serve`.
22
-
23
- ## Agent workflow
24
-
25
- Pull browser events, update the page, and push the resulting page:
26
-
27
- ```bash
28
- batch=$(npx letmeknow-cli pull ./preview --wait 30)
29
- token=$(printf '%s\n' "$batch" | jq -r .token)
30
- # inspect events, edit index.html, then:
31
- npx letmeknow-cli push ./preview --batch "$token" --page index.html
32
- ```
33
-
34
- `pull` returns an opaque batch token, current-page metadata, and the browser events not yet committed by the agent. Pulling does not consume events. Events that arrive while the agent works remain for a later pull.
35
-
36
- ```json
37
- {"token":"…","frontier":7,"page_event":5,"page_hash":"…","events":[{"type":"submit","id":"…","event_number":7,"page_event":5,"form_id":"decision","action":"/decide","trigger":{"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}]}
38
- ```
39
-
40
- An event's `page_event` identifies the page the browser displayed when it submitted. Compare it with the batch's current `page_event` when deciding whether the input still applies.
41
-
42
- A push with a page:
43
-
44
- ```bash
45
- npx letmeknow-cli push ./preview --batch "$token" --page index.html
46
- ```
47
-
48
- atomically commits the events represented by the token, replaces the canonical dynamic page with the complete HTML from `index.html`, appends one page-update event to the global event stream, and broadcasts that page to connected browsers. Browsers morph the page without navigating or reloading.
49
-
50
- A push without `--page` only commits the pulled browser events:
51
-
52
- ```bash
53
- npx letmeknow-cli push ./preview --batch "$token"
54
- ```
55
-
56
- Use `--page -` to read the complete desired page from standard input:
57
-
58
- ```bash
59
- npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
60
- ```
61
-
62
- The update is all-or-nothing. If the token or page input is invalid, neither the browser events nor the page update is committed.
63
-
64
- ## Inspect the current page
65
-
66
- `show` writes the canonical dynamic HTML held by `serve` to standard output:
67
-
68
- ```bash
69
- npx letmeknow-cli show ./preview > current.html
70
- ```
71
-
72
- It is read-only and does not create or commit an event. This is different from opening the public URL: `show` returns canonical HTML, while the URL shows a particular browser’s rendered DOM, including local focus, open/closed controls, unsent values, and JavaScript state.
73
-
74
- ## The event stream
75
-
76
- Browser submissions and CLI page updates share one ordered, in-memory event stream:
21
+ ## Commands
77
22
 
78
23
  ```text
79
- submit browser
80
- submit browser
81
- update_ui CLI: complete desired HTML page
24
+ serve <dir>
25
+ show <dir>
26
+ pull <dir> [--wait seconds]
27
+ push <dir> --batch TOKEN [--updates FILE|-]
82
28
  ```
83
29
 
84
- Browser submission events are delivered to the agent through `pull`. Page-update events are broadcast to all connected browsers. There is no per-browser audience or dynamic view system in the initial model.
85
-
86
- The CLI assigns the event order. The number indicates acceptance order, not the physical time a person clicked. Submission IDs make retries distinguishable from new intentional submissions.
30
+ `serve` reads `index.html` once as the canonical page. Other files are served live from the directory. `show` prints the accepted canonical HTML. `pull` returns browser events without consuming them; `push` commits a pulled batch, optionally with ordered replacements. See [`SKILL.md`](SKILL.md) for the complete workflow and protocol.
87
31
 
88
- ## Forms
32
+ ## Page updates
89
33
 
90
- Use ordinary HTML forms with stable IDs and meaningful field names:
34
+ Use stable IDs as replacement boundaries:
91
35
 
92
36
  ```html
93
- <form id="decision" action="/decide" method="post">
94
- <label>Comment <textarea id="comment" name="comment"></textarea></label>
95
- <button name="decision" value="approve">Approve</button>
96
- <button name="decision" value="reject">Reject</button>
97
- </form>
37
+ <output id="counter">41</output>
98
38
  ```
99
39
 
100
- The runtime intercepts native form submission and turns it into a durable JSON `submit` event. It assigns an opaque UUID, stores the event in the browser’s local outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct submissions remain distinct, including rapid repeated clicks.
101
-
102
- Form values are untrusted input and should be validated by the agent. File uploads are not supported.
103
-
104
- ## Authoring the dynamic page
105
-
106
- Each page update supplies the complete desired HTML document. The browser morphs the current document toward it, so a small change such as a counter update need not recreate the whole DOM.
107
-
108
- Give elements stable unique IDs. They help the morphing runtime retain unchanged elements, including controls whose local state should survive an update:
109
-
110
- ```html
111
- <output id="count">0</output>
40
+ ```json
41
+ {"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
112
42
  ```
113
43
 
114
- Agent-authored JavaScript should be loaded by the initial page as a static asset and use delegated event listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
115
-
116
- The CLI owns rendered page content. The browser preserves focus, scrolling, dirty controls with stable IDs, and the open state of `<details id="…">`. Mark an element with a stable ID and `data-letmeknow-local` when its `hidden` state is browser-owned. Avoid having browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
117
-
118
- A page update is shared with all browsers. Keep private or browser-specific behavior local unless a future requirement introduces targeted updates.
119
-
120
- ## Static assets
44
+ Each replacement must contain one element with the target's ID and cannot contain scripts. Replacements in one push are applied in order and atomically. Replacing `letmeknow-root` is the broad page-update case and intentionally discards browser-local state inside that root.
121
45
 
122
- Static assets are read live from the directory, independently of the canonical dynamic page. An agent can change CSS, JavaScript, images, and other assets without a page push. Finish an asset before pushing HTML that references it, write files atomically, and use versioned filenames or cache-busting URLs when cached assets must change with the page.
46
+ ## Session and security basics
123
47
 
124
- ## Security
48
+ Sessions are temporary and held in memory by the CLI and relay. The service may expire a session at any time. If the producer disconnects, reconnect is best-effort and may be available only for a limited period; clients should handle disconnects without assuming that reconnect will succeed.
125
49
 
126
- The URL is a bearer capability. Anyone who has it can view the page and submit forms. Keep secrets and unrelated files outside the served directory. Browser values are untrusted input; escape them before placing them in HTML.
50
+ The public URL is a bearer capability: anyone who has it can view the page and submit forms. Treat browser values as untrusted input and escape them before placing them in HTML. Keep secrets and unrelated files outside the served directory.
127
51
 
128
52
  ## Development
129
53
 
@@ -135,4 +59,4 @@ npm run dev
135
59
  npm run deploy
136
60
  ```
137
61
 
138
- The browser test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay.
62
+ The browser smoke test requires Firefox and geckodriver. `npm run dev` and `npm run deploy` operate the Cloudflare relay. For the detailed agent workflow, forms, event causality, reconnect behavior, and static-asset guidance, read [`SKILL.md`](SKILL.md).
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: letmeknow
3
- description: Serve a temporary live HTML page, collect structured human feedback, and push agent-authored page updates.
3
+ description: Serve a temporary live HTML page, collect structured human feedback, and push ordered HTML replacements.
4
4
  ---
5
5
 
6
6
  # LetMeKnow
@@ -9,29 +9,46 @@ Use LetMeKnow when a human should inspect or interact with an agent-authored pag
9
9
 
10
10
  ## Start
11
11
 
12
- Create a directory containing the public files and an initial `index.html`:
12
+ Create a directory containing the public files and an initial `index.html`, then run:
13
13
 
14
14
  ```bash
15
15
  npx letmeknow-cli serve ./preview
16
16
  ```
17
17
 
18
- `serve` reads `index.html` once as the canonical dynamic document and serves it at `/`. It serves the other files in the directory live as static assets. The first stdout JSON line contains the public bearer URL:
18
+ `serve` reads `index.html` once as the initial canonical dynamic page and serves it at `/`. Other files—such as CSS, JavaScript, images, and data—are served live from the directory. The first stdout JSON line contains the public bearer URL and initial page metadata:
19
19
 
20
20
  ```json
21
21
  {"type":"ready","url":"https://0123456789abcdef0123.letmeknow.dev/","page_event":0,"page_hash":"…"}
22
22
  ```
23
23
 
24
- Give the URL to the human. Anyone with the URL can view the page and submit its forms. The CLI connects outbound and opens no network port. Canonical page state and events are temporary in-memory session state; they end when `serve` stops. `serve` never modifies agent files.
24
+ The initial page should have a stable shell and a dynamic root:
25
+
26
+ ```html
27
+ <body>
28
+ <main id="letmeknow-root">
29
+ ...
30
+ </main>
31
+ <script type="module" src="/app.js"></script>
32
+ </body>
33
+ ```
34
+
35
+ The runtime is injected into the page separately. Keep agent-authored scripts and the runtime outside normal replacement targets.
36
+
37
+ ## Session lifetime and reconnect
38
+
39
+ A producer must successfully send `open` shortly after connecting. Sessions are temporary and may be expired by the service at any time.
40
+
41
+ If an opened producer disconnects, reconnect is best-effort and may be available only for a limited period. A reconnect uses the existing session credential, but clients must handle disconnects without assuming that reconnect will succeed. When the service expires a session, the session and its browser connections end.
25
42
 
26
43
  ## Agent loop
27
44
 
28
- Pull browser events, update the desired page, and push it:
45
+ Pull browser events, update one or more HTML fragments, and push the replacements:
29
46
 
30
47
  ```bash
31
48
  batch=$(npx letmeknow-cli pull ./preview --wait 30)
32
49
  token=$(printf '%s\n' "$batch" | jq -r .token)
33
- # inspect the events and edit index.html
34
- npx letmeknow-cli push ./preview --batch "$token" --page index.html
50
+ # inspect events and write fragments such as counter.html and status.html
51
+ npx letmeknow-cli push ./preview --batch "$token" --updates updates.json
35
52
  ```
36
53
 
37
54
  Commands:
@@ -40,74 +57,149 @@ Commands:
40
57
  serve <dir>
41
58
  show <dir>
42
59
  pull <dir> [--wait seconds]
43
- push <dir> --batch TOKEN [--page FILE|-]
60
+ push <dir> --batch TOKEN [--updates FILE|-]
44
61
  ```
45
62
 
46
- `pull` returns an opaque batch token, current-page metadata, and browser events not yet committed by the agent. Pulling does not consume events. Events arriving while the agent works remain for a later pull.
63
+ `show` returns the current canonical page without changing the event stream:
64
+
65
+ ```bash
66
+ npx letmeknow-cli show ./preview > current.html
67
+ ```
68
+
69
+ The public URL is the visual preview. `show` returns canonical HTML, not a browser's local focus, open disclosures, unsent input, scroll position, or JavaScript state.
70
+
71
+ ## Push updates
72
+
73
+ A push accepts one JSON document:
47
74
 
48
75
  ```json
49
- {"token":"…","frontier":7,"page_event":5,"page_hash":"…","events":[{"type":"submit","id":"…","event_number":7,"page_event":5,"form_id":"decision","action":"/decide","trigger":{"name":"decision","value":"approve"},"values":{"comment":"Looks good","decision":"approve"}}]}
76
+ {
77
+ "updates": [
78
+ {"target": "counter", "file": "counter.html"},
79
+ {"target": "status", "html": "<output id=\"status\">Saved</output>"}
80
+ ]
81
+ }
50
82
  ```
51
83
 
52
- An event's `page_event` identifies the page displayed when the browser submitted it. Compare it with the batch's current `page_event` before applying old input to the current page.
84
+ Each update must contain exactly one of `html` or `file`. A file supplies the replacement HTML. The normal operation is direct replacement of one element identified by its unique stable `id`:
53
85
 
54
- `push --page FILE` atomically commits the events represented by the token, makes FILE the complete desired dynamic document, appends one page-update event to the global event stream, and broadcasts it to all connected browsers. Browsers morph the page without navigation.
86
+ ```html
87
+ <output id="counter">41</output>
88
+ ```
55
89
 
56
- ```bash
57
- npx letmeknow-cli push ./preview --batch "$token" --page index.html
90
+ ```json
91
+ {"updates":[{"target":"counter","html":"<output id=\"counter\">42</output>"}]}
92
+ ```
93
+
94
+ The replacement must contain exactly one element, and that element must have the same ID as the target. Replacement HTML cannot contain scripts. The target must exist when the replacement is applied.
95
+
96
+ Use small output regions for ordinary updates. The replacement is destructive inside its target but leaves the rest of the page alone, so a counter update does not disturb a form or button elsewhere.
97
+
98
+ The document root is just another target. To intentionally replace all dynamic content, target the root:
99
+
100
+ ```html
101
+ <main id="letmeknow-root">
102
+ ...
103
+ </main>
104
+ ```
105
+
106
+ ```json
107
+ {"updates":[{"target":"letmeknow-root","file":"root.html"}]}
58
108
  ```
59
109
 
60
- A push without `--page` only commits the pulled browser events:
110
+ Replacing the root intentionally discards browser-local state inside it. Use it when that is acceptable, not for every small change.
111
+
112
+ A single push may contain many replacements. They are applied in the order listed. Later replacements may target elements introduced by earlier replacements in the same push, so introduce a target before updating it. Conversely, a replacement that removes a later target makes a following update invalid. The CLI validates the complete ordered batch before committing anything.
113
+
114
+ Each replacement becomes its own `update_ui` event with its own global event number. The complete push is still atomic: either all replacements and the pulled browser-event batch commit, or none do. Connected browsers receive committed replacements in order.
115
+
116
+ A push without `--updates` commits the pulled browser events without changing the page:
61
117
 
62
118
  ```bash
63
119
  npx letmeknow-cli push ./preview --batch "$token"
64
120
  ```
65
121
 
66
- Use `--page -` for standard input:
122
+ There is no separate acknowledgement command and no `--page` mode. Replacing `letmeknow-root` provides the broad page-update case.
67
123
 
68
- ```bash
69
- npx letmeknow-cli push ./preview --batch "$token" --page - < updated.html
124
+ ## Event stream and page causality
125
+
126
+ Browser submissions and CLI page replacements share one ordered, in-memory event stream:
127
+
128
+ ```text
129
+ submit browser
130
+ submit browser
131
+ update_ui CLI: replace #counter
132
+ update_ui CLI: replace #status
70
133
  ```
71
134
 
72
- The page push is all-or-nothing. Invalid input or an invalid token commits nothing. The CLI assigns one global order to each browser submission and each page-update event.
135
+ The CLI assigns event numbers in acceptance order. They do not claim to be the physical order in which people clicked. Browser submissions are delivered to the agent through `pull`; raw submissions are not broadcast to other browsers. Page replacements are broadcast to all connected browsers.
73
136
 
74
- `show` retrieves the canonical dynamic HTML held by `serve` without changing the event stream:
137
+ `pull` returns an opaque batch token, current-page metadata, and browser events not yet committed by the agent. Pulling does not consume events. Events arriving while the agent works remain for a later pull.
75
138
 
76
- ```bash
77
- npx letmeknow-cli show ./preview > current.html
139
+ ```json
140
+ {
141
+ "token": "…",
142
+ "frontier": 7,
143
+ "page_event": 5,
144
+ "page_hash": "…",
145
+ "events": [
146
+ {
147
+ "type": "submit",
148
+ "id": "…",
149
+ "event_number": 7,
150
+ "page_event": 5,
151
+ "form_id": "decision",
152
+ "action": "/decide",
153
+ "trigger": {"name": "decision", "value": "approve"},
154
+ "values": {"comment": "Looks good", "decision": "approve"}
155
+ }
156
+ ]
157
+ }
78
158
  ```
79
159
 
80
- The public URL is the visual preview. `show` returns canonical HTML, not a browser’s local DOM state such as focus, open disclosures, unsent input, scroll position, or JavaScript state.
160
+ `page_event` is the page-update event number displayed when the browser submitted. Compare each event's `page_event` with the batch's current page before applying old input to the current HTML. `frontier` is the latest global event number, including events that are not browser submissions.
161
+
162
+ The token identifies exactly the browser-event frontier the agent saw. A successful push commits that frontier and its ordered replacements together. Browser events accepted after the pull remain for the next batch.
81
163
 
82
164
  ## Forms
83
165
 
84
- Use native forms with stable IDs and meaningful names:
166
+ Use ordinary same-origin forms with stable IDs and meaningful field names:
85
167
 
86
168
  ```html
87
- <form id="review" action="/review" method="post">
169
+ <form id="decision" action="/decide" method="post">
88
170
  <label>Comment <textarea id="comment" name="comment"></textarea></label>
89
171
  <button name="decision" value="approve">Approve</button>
90
172
  <button name="decision" value="reject">Reject</button>
91
173
  </form>
92
174
  ```
93
175
 
94
- The browser runtime serializes native form submissions as JSON `submit` events. It assigns an opaque ID, stores each event in a local durable outbox before sending it, retries after connection failures, and reuses the ID on retry. The CLI deduplicates repeated delivery. Ten intentional rapid clicks should produce ten distinct events. File uploads are not supported.
176
+ The runtime converts native form submission into a JSON `submit` event. It assigns an opaque UUID, stores each event in a durable browser outbox before delivery, retries after connection failures, and reuses the UUID on retry. The CLI deduplicates repeated delivery of the same event. Distinct intentional submissions remain distinct, including rapid repeated clicks. File uploads are not supported.
95
177
 
96
178
  Treat pulled values as untrusted input. Validate them and escape them before putting them into HTML.
97
179
 
98
- ## Dynamic page rules
99
-
100
- Every page push supplies the complete desired dynamic document. The browser uses HTML morphing, so unchanged DOM nodes can survive while changed content is updated.
180
+ ## Browser and HTML rules
101
181
 
102
- Give elements stable unique IDs. Load agent-authored JavaScript from the initial page as a static asset and use delegated listeners. Existing scripts remain active across morphs, but scripts added or changed by a pushed page are not executed in connected browsers; keep script references fixed for the session.
182
+ Normal updates are direct replacements, not DOM morphs. Stable IDs are therefore important for naming update boundaries, not for preserving DOM nodes.
103
183
 
104
- The CLI owns page content. The browser preserves focus, scrolling, dirty controls with stable IDs, and the open state of `<details id="…">`. Mark an element with a stable ID and `data-letmeknow-local` when its `hidden` state is browser-owned. Do not have browser JavaScript and pushed HTML otherwise mutate the same state; a later morph may replace browser-created changes.
184
+ - The CLI owns content inside replacement targets.
185
+ - The browser owns local focus, open/closed disclosure state, and hide/show behavior outside deliberately replaced targets.
186
+ - A root replacement can destroy all local state inside the root.
187
+ - Load agent-authored JavaScript from the initial page as a static asset.
188
+ - Use delegated event listeners because replaced elements are new DOM nodes.
189
+ - Scripts in update fragments are not executed.
190
+ - Do not have browser JavaScript and pushed HTML independently own the same state.
105
191
 
106
- Page updates are shared with all connected browsers. There is no dynamic view or per-browser update system. Use ordinary static links and files when the application needs more persistent pages.
192
+ When a session ends, the browser shows a permanent closed status and discards unsent submissions rather than retrying them.
107
193
 
108
194
  ## Static assets
109
195
 
110
- CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the new page.
196
+ CSS, JavaScript, images, and other non-`index.html` files are served live. Finish writing an asset before pushing HTML that references it. Write assets atomically, and use versioned filenames or cache-busting URLs when the browser must fetch a changed asset with the page update.
197
+
198
+ This design intentionally does not provide atomic publication of the whole directory. The dynamic page changes through ordered replacements; other files can change as soon as they are written.
199
+
200
+ ## Security
201
+
202
+ The URL is a bearer capability. Anyone who has it can view the page and submit forms. Keep secrets and unrelated files outside the served directory. Browser values are untrusted input; escape them before placing them in HTML.
111
203
 
112
204
  ## Stop
113
205
 
package/bin/letmeknow.js CHANGED
@@ -1,17 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { constants, existsSync, readFileSync, statSync, writeSync } from "node:fs";
3
+ import { constants, createReadStream, existsSync, readFileSync, statSync, writeSync } from "node:fs";
4
4
  import { chmod, open, readFile, realpath, stat, unlink } from "node:fs/promises";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import net from "node:net";
7
7
  import { dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { tmpdir } from "node:os";
9
9
  import { parseArgs } from "node:util";
10
+ import { parse, parseFragment, serialize } from "parse5";
10
11
  import { lookup } from "mrmime";
11
12
 
12
13
  const MAX_BODY_BYTES = 1024 * 1024;
13
- const CONTROL_MAX_BYTES = MAX_BODY_BYTES * 2 + 16 * 1024;
14
- const GRACE_SECONDS = 10 * 60;
14
+ const UPDATE_BATCH_MAX_BYTES = 16 * MAX_BODY_BYTES;
15
+ const MAX_BATCH_TOKENS = 100_000;
16
+ const MAX_UNIQUE_SUBMISSIONS = 100_000;
17
+ const MAX_RETAINED_SUBMISSION_BYTES = 256 * 1024 * 1024;
18
+ const CONTROL_MAX_BYTES = UPDATE_BATCH_MAX_BYTES * 2;
19
+ const RECONNECT_RETRY_SECONDS = 10 * 60;
15
20
  const CONNECTION_TIMEOUT = 10_000;
16
21
  const CONTROL_TIMEOUT = 35_000;
17
22
  const CONTROL_PREFIX = "letmeknow-control-";
@@ -20,6 +25,7 @@ const credentialPattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
20
25
  const privateNames = new Set([".env", ".git", ".ssh", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]);
21
26
  const privateFilePattern = /^\.env\.|\.(?:key|pem|p12|ppk|p8|sqlite|sqlite3|db|db3)$|-(?:wal|shm|journal)$/i;
22
27
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ const forbiddenUpdateTargets = new Set(["html", "head", "body", "script"]);
23
29
 
24
30
  function getMimeType(filename) {
25
31
  const type = lookup(filename);
@@ -153,6 +159,119 @@ async function readInitialPage(root) {
153
159
  return (await readFile(target)).toString("utf8");
154
160
  }
155
161
 
162
+ function nodeId(node) {
163
+ return node.attrs?.find(attribute => attribute.name === "id")?.value;
164
+ }
165
+
166
+ function elementsWithId(node, id, matches = []) {
167
+ if (node.nodeName !== "#text" && node.nodeName !== "#comment" && nodeId(node) === id) matches.push(node);
168
+ for (const child of node.childNodes || []) elementsWithId(child, id, matches);
169
+ return matches;
170
+ }
171
+
172
+ function containsScript(node) {
173
+ if (node.nodeName === "script") return true;
174
+ if (node.content && containsScript(node.content)) return true;
175
+ return (node.childNodes || []).some(containsScript);
176
+ }
177
+
178
+ function updateFragment(target, html, context) {
179
+ const fragment = parseFragment(context, html);
180
+ const meaningful = (fragment.childNodes || []).filter(node => node.nodeName !== "#text" || node.value.trim() !== "");
181
+ if (meaningful.length !== 1 || meaningful[0].nodeName === "#comment" || meaningful[0].nodeName?.startsWith("#")) throw new Error(`update for ${target} must contain exactly one root element`);
182
+ const root = meaningful[0];
183
+ if (containsScript(root)) throw new Error(`update for ${target} cannot contain script elements`);
184
+ if (nodeId(root) !== target) throw new Error(`update root ID must be ${target}`);
185
+ return root;
186
+ }
187
+
188
+ function replaceElement(document, target, html) {
189
+ if (forbiddenUpdateTargets.has(target.nodeName)) throw new Error(`cannot update ${target.nodeName} element`);
190
+ const parent = target.parentNode;
191
+ const index = parent?.childNodes.indexOf(target);
192
+ if (!parent || index === undefined || index < 0) throw new Error("update target has no parent");
193
+ const replacement = updateFragment(nodeId(target), html, parent);
194
+ parent.childNodes[index] = replacement;
195
+ replacement.parentNode = parent;
196
+ target.parentNode = null;
197
+ }
198
+
199
+ function validateUpdates(updates) {
200
+ if (!Array.isArray(updates)) throw new Error("updates must be an array");
201
+ let bytes = 0;
202
+ for (const update of updates) {
203
+ if (!update || typeof update !== "object" || Array.isArray(update)) throw new Error("each update must be an object");
204
+ if (typeof update.target !== "string" || update.target.trim() === "") throw new Error("update target must be a non-empty ID");
205
+ if (typeof update.html !== "string") throw new Error("update html must be text");
206
+ bytes += Buffer.byteLength(update.html, "utf8");
207
+ if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
208
+ }
209
+ return updates;
210
+ }
211
+
212
+ function applyUpdates(page, updates) {
213
+ validateUpdates(updates);
214
+ const document = parse(page);
215
+ for (const update of updates) {
216
+ const matches = elementsWithId(document, update.target);
217
+ if (matches.length !== 1) throw new Error(`update target ${update.target} must match exactly one element`);
218
+ replaceElement(document, matches[0], update.html);
219
+ }
220
+ const result = serialize(document);
221
+ if (Buffer.byteLength(result, "utf8") > MAX_BODY_BYTES) throw new Error("updated page is too large");
222
+ return result;
223
+ }
224
+
225
+ async function readInput(filename) {
226
+ const chunks = [];
227
+ let length = 0;
228
+ const input = filename === "-" ? process.stdin : createReadStream(filename);
229
+ for await (const chunk of input) {
230
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
231
+ length += value.byteLength;
232
+ if (length > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
233
+ chunks.push(value);
234
+ }
235
+ return Buffer.concat(chunks).toString("utf8");
236
+ }
237
+
238
+ async function readUpdatesInput(filename) {
239
+ const manifest = await readInput(filename);
240
+ let value;
241
+ try { value = JSON.parse(manifest); } catch { throw new Error("invalid updates JSON"); }
242
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.updates)) throw new Error("updates must be an object with an updates array");
243
+ const base = filename === "-" ? process.cwd() : dirname(resolve(filename));
244
+ const updates = [];
245
+ let bytes = 0;
246
+ for (const item of value.updates) {
247
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("each update must be an object");
248
+ if (typeof item.target !== "string" || item.target.trim() === "") throw new Error("update target must be a non-empty ID");
249
+ const hasHtml = Object.prototype.hasOwnProperty.call(item, "html");
250
+ const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
251
+ if (hasHtml === hasFile) throw new Error(`update for ${item.target} must have exactly one of html or file`);
252
+ let html;
253
+ if (hasHtml) {
254
+ if (typeof item.html !== "string") throw new Error(`update html for ${item.target} must be text`);
255
+ html = item.html;
256
+ } else {
257
+ if (typeof item.file !== "string" || item.file === "") throw new Error(`update file for ${item.target} must be a path`);
258
+ const file = resolve(base, item.file);
259
+ const info = await stat(file);
260
+ if (!info.isFile()) throw new Error(`update file for ${item.target} must be a regular file`);
261
+ if (info.size > UPDATE_BATCH_MAX_BYTES - bytes) throw new Error("updates are too large");
262
+ html = (await readFile(file)).toString("utf8");
263
+ }
264
+ bytes += Buffer.byteLength(html, "utf8");
265
+ if (bytes > UPDATE_BATCH_MAX_BYTES) throw new Error("updates are too large");
266
+ updates.push({ target: item.target, html });
267
+ }
268
+ return updates;
269
+ }
270
+
271
+ function updatesHash(updates) {
272
+ return createHash("sha256").update(JSON.stringify(updates)).digest("hex");
273
+ }
274
+
156
275
  async function submission(packet, recordInteraction) {
157
276
  const request = requestUrl(packet);
158
277
  const method = typeof packet.method === "string" ? packet.method.toUpperCase() : "";
@@ -170,7 +289,7 @@ async function submission(packet, recordInteraction) {
170
289
  if (typeof value.action !== "string") throw new Error("action is required");
171
290
  if (value.trigger !== null && (typeof value.trigger !== "object" || Array.isArray(value.trigger))) throw new Error("trigger must be an object or null");
172
291
  if (!value.values || typeof value.values !== "object" || Array.isArray(value.values)) throw new Error("values are required");
173
- await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values });
292
+ await recordInteraction({ type: "submit", id: value.id, page_event: value.page_event, form_id: value.form_id, action: value.action, trigger: value.trigger, values: value.values }, body.byteLength);
174
293
  return response(packet, 202);
175
294
  }
176
295
 
@@ -245,8 +364,9 @@ async function start(directory) {
245
364
  let pageEvent = 0;
246
365
  let eventNumber = 0;
247
366
  const currentPageHash = () => pageHash(page);
248
- const eventLog = [];
249
367
  const browserEvents = [];
368
+ const browserEventBytes = [];
369
+ let retainedSubmissionBytes = 0;
250
370
  let committedBrowserCursor = 0;
251
371
  const seenEvents = new Set();
252
372
  const tokens = new Map();
@@ -264,24 +384,28 @@ async function start(directory) {
264
384
  let retryUntil = 0;
265
385
  let stopped = false;
266
386
  let ready = false;
387
+ let stopSession = () => {};
267
388
 
268
389
  const batch = () => {
269
390
  const start = committedBrowserCursor;
270
- const end = browserEvents.length;
391
+ const end = start + browserEvents.length;
271
392
  const key = `${pageEvent}:${start}:${end}`;
272
- const existing = pendingTokens.get(key);
273
- if (existing) return existing;
274
- const token = randomUUID();
275
- const events = browserEvents.slice(start, end);
276
- tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, page_hash: currentPageHash(), has_page: null, requested_page_hash: null });
277
- const result = { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events };
278
- pendingTokens.set(key, result);
279
- return result;
393
+ let token = pendingTokens.get(key)?.token;
394
+ if (!token) {
395
+ if (tokens.size >= MAX_BATCH_TOKENS) {
396
+ stopSession();
397
+ throw new Error("session batch token limit exceeded");
398
+ }
399
+ token = randomUUID();
400
+ tokens.set(token, { start, end, page_event: pageEvent, status: "pending", key, updates_hash: null });
401
+ pendingTokens.set(key, { token });
402
+ }
403
+ return { ok: true, type: "batch", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: browserEvents.slice() };
280
404
  };
281
405
 
282
406
  const notifyPullWaiters = () => {
283
407
  for (const waiter of [...pullWaiters]) {
284
- if (browserEvents.length === committedBrowserCursor) continue;
408
+ if (browserEvents.length === 0) continue;
285
409
  pullWaiters.delete(waiter);
286
410
  clearTimeout(waiter.timer);
287
411
  waiter.resolve(batch());
@@ -289,45 +413,60 @@ async function start(directory) {
289
413
  };
290
414
 
291
415
  const pull = waitSeconds => {
292
- if (browserEvents.length > committedBrowserCursor || waitSeconds <= 0) return Promise.resolve(batch());
416
+ if (browserEvents.length > 0 || waitSeconds <= 0) return Promise.resolve(batch());
293
417
  return new Promise(resolve => {
294
418
  const waiter = { resolve, timer: setTimeout(() => { pullWaiters.delete(waiter); resolve(batch()); }, waitSeconds * 1_000) };
295
419
  pullWaiters.add(waiter);
296
420
  });
297
421
  };
298
422
 
299
- const commit = async (token, requestedPage) => {
423
+ const commit = async (token, requestedUpdates) => {
300
424
  const record = tokens.get(token);
301
425
  if (!record) return { ok: false, error: "unknown batch token" };
302
- const hasPage = requestedPage !== undefined;
303
- const requestedPageHash = hasPage ? pageHash(requestedPage) : null;
426
+ const updates = validateUpdates(requestedUpdates);
427
+ const requestedUpdatesHash = updatesHash(updates);
304
428
  if (record.status !== "pending") {
305
- if (record.has_page === hasPage && record.requested_page_hash === requestedPageHash) return record.result;
306
- return { ok: false, error: "batch was already committed with a different page payload" };
429
+ if (record.updates_hash === requestedUpdatesHash) return record.result;
430
+ return { ok: false, error: "batch was already committed with a different updates payload" };
307
431
  }
308
- record.has_page = hasPage;
309
- record.requested_page_hash = requestedPageHash;
310
432
  if (record.page_event !== pageEvent || record.start !== committedBrowserCursor) {
433
+ record.updates_hash = requestedUpdatesHash;
311
434
  pendingTokens.delete(record.key);
312
435
  record.status = "failed";
313
436
  record.result = { ok: false, error: "batch is based on an old page or browser cursor", frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash() };
314
437
  return record.result;
315
438
  }
316
- if (hasPage && Buffer.byteLength(requestedPage, "utf8") > MAX_BODY_BYTES) return { ok: false, error: "page is too large" };
317
- const committedEvents = browserEvents.slice(record.start, record.end).map(event => event.id);
318
- let update;
319
- if (hasPage) {
320
- page = requestedPage;
439
+ let nextPage = page;
440
+ try { if (updates.length) nextPage = applyUpdates(page, updates); } catch (cause) {
441
+ throw new Error(cause instanceof Error ? cause.message : "invalid page updates");
442
+ }
443
+ const committedCount = record.end - record.start;
444
+ const committedEvents = browserEvents.slice(0, committedCount).map(event => event.id);
445
+ retainedSubmissionBytes -= browserEventBytes.slice(0, committedCount).reduce((total, bytes) => total + bytes, 0);
446
+ const updateEvents = [];
447
+ for (const update of updates) {
321
448
  eventNumber += 1;
322
- pageEvent = eventNumber;
323
- update = { type: "update_ui", event_number: pageEvent, html: page };
324
- eventLog.push(update);
449
+ updateEvents.push({ type: "update_ui", event_number: eventNumber, target: update.target, html: update.html });
325
450
  }
451
+ page = nextPage;
452
+ pageEvent = updateEvents.at(-1)?.event_number ?? pageEvent;
453
+ browserEvents.splice(0, committedCount);
454
+ browserEventBytes.splice(0, committedCount);
326
455
  committedBrowserCursor = record.end;
327
456
  pendingTokens.delete(record.key);
328
457
  record.status = "committed";
329
- record.result = { ok: true, type: "committed", token, frontier: eventNumber, page_event: pageEvent, page_hash: currentPageHash(), events: committedEvents };
330
- if (update) send(update);
458
+ record.updates_hash = requestedUpdatesHash;
459
+ record.result = {
460
+ ok: true,
461
+ type: "committed",
462
+ token,
463
+ frontier: eventNumber,
464
+ page_event: pageEvent,
465
+ page_hash: currentPageHash(),
466
+ events: committedEvents,
467
+ updates: updateEvents.map(({ event_number, target }) => ({ event_number, target }))
468
+ };
469
+ for (const update of updateEvents) send(update);
331
470
  return record.result;
332
471
  };
333
472
 
@@ -337,8 +476,7 @@ async function start(directory) {
337
476
  if (request.type === "show") return { ok: true, type: "page", page_event: pageEvent, page_hash: currentPageHash(), html: page };
338
477
  if (request.type === "push") {
339
478
  if (typeof request.token !== "string") return { ok: false, error: "batch token is required" };
340
- if (request.page !== undefined && typeof request.page !== "string") return { ok: false, error: "page must be text" };
341
- return commit(request.token, request.page);
479
+ return commit(request.token, request.updates === undefined ? [] : request.updates);
342
480
  }
343
481
  return { ok: false, error: "unknown control request" };
344
482
  };
@@ -351,7 +489,7 @@ async function start(directory) {
351
489
  let handled = false;
352
490
  connection.on("data", async chunk => {
353
491
  input += chunk;
354
- if (input.length > CONTROL_MAX_BYTES || handled) return;
492
+ if (Buffer.byteLength(input, "utf8") > CONTROL_MAX_BYTES || handled) return;
355
493
  const newline = input.indexOf("\n");
356
494
  if (newline < 0) return;
357
495
  handled = true;
@@ -374,13 +512,18 @@ async function start(directory) {
374
512
  });
375
513
  }).catch(cause => { throw new Error(`cannot start local control channel: ${cause.message}`); });
376
514
 
377
- const recordInteraction = event => mutate(async () => {
515
+ const recordInteraction = (event, bytes) => mutate(async () => {
378
516
  if (seenEvents.has(event.id)) return;
517
+ if (seenEvents.size >= MAX_UNIQUE_SUBMISSIONS || retainedSubmissionBytes + bytes > MAX_RETAINED_SUBMISSION_BYTES) {
518
+ stopSession();
519
+ throw new Error("session submission limit exceeded");
520
+ }
379
521
  seenEvents.add(event.id);
522
+ retainedSubmissionBytes += bytes;
380
523
  eventNumber += 1;
381
524
  const numbered = { ...event, event_number: eventNumber };
382
- eventLog.push(numbered);
383
525
  browserEvents.push(numbered);
526
+ browserEventBytes.push(bytes);
384
527
  notifyPullWaiters();
385
528
  });
386
529
 
@@ -396,6 +539,7 @@ async function start(directory) {
396
539
  await unlink(controlPath(root)).catch(() => {});
397
540
  process.exit(code);
398
541
  };
542
+ stopSession = () => { void stop(1); };
399
543
  process.once("SIGINT", () => void stop(0));
400
544
  process.once("SIGTERM", () => void stop(0));
401
545
 
@@ -453,7 +597,7 @@ async function start(directory) {
453
597
  send = () => false;
454
598
  socket = undefined;
455
599
  if (!credential || !sessionUrl) return void stop(1);
456
- if (!retryUntil) retryUntil = Date.now() + GRACE_SECONDS * 1_000;
600
+ if (!retryUntil) retryUntil = Date.now() + RECONNECT_RETRY_SECONDS * 1_000;
457
601
  retry();
458
602
  });
459
603
  };
@@ -483,7 +627,7 @@ function validSessionUrl(value) {
483
627
  }
484
628
 
485
629
  function usage() {
486
- return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--page <file|->]\n";
630
+ return "Usage:\n npx letmeknow-cli serve <directory>\n npx letmeknow-cli show <directory>\n npx letmeknow-cli pull <directory> [--wait <seconds>]\n npx letmeknow-cli push <directory> --batch <token> [--updates <file|->]\n";
487
631
  }
488
632
 
489
633
  function commandArgs() {
@@ -496,7 +640,7 @@ function commandArgs() {
496
640
  help: { type: "boolean", short: "h" },
497
641
  wait: { type: "string" },
498
642
  batch: { type: "string" },
499
- page: { type: "string" }
643
+ updates: { type: "string" }
500
644
  },
501
645
  allowPositionals: true,
502
646
  strict: true
@@ -505,14 +649,14 @@ function commandArgs() {
505
649
  throw new Error(cause instanceof Error ? cause.message : "invalid arguments");
506
650
  }
507
651
  if (parsed.values.skill || parsed.values.help) {
508
- if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.page !== undefined) throw new Error(usage());
652
+ if (parsed.positionals.length || parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined) throw new Error(usage());
509
653
  return { command: parsed.values.skill ? "skill" : "help" };
510
654
  }
511
655
  const [command, directory, ...extra] = parsed.positionals;
512
656
  if (!command || !directory || extra.length) throw new Error(usage());
513
657
  if (!["serve", "show", "pull", "push"].includes(command)) throw new Error(usage());
514
- if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.page !== undefined)) throw new Error(usage());
515
- if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.page !== undefined)) throw new Error(usage());
658
+ if ((command === "serve" || command === "show") && (parsed.values.wait !== undefined || parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
659
+ if (command === "pull" && (parsed.values.batch !== undefined || parsed.values.updates !== undefined)) throw new Error(usage());
516
660
  if (command === "push" && parsed.values.wait !== undefined) throw new Error(usage());
517
661
  let wait = 0;
518
662
  if (parsed.values.wait !== undefined) {
@@ -520,26 +664,9 @@ function commandArgs() {
520
664
  if (!Number.isFinite(wait) || wait < 0) throw new Error("--wait must be a non-negative number");
521
665
  }
522
666
  if (command === "push" && typeof parsed.values.batch !== "string") throw new Error("--batch is required");
523
- return { command, directory, wait, token: parsed.values.batch, page: parsed.values.page };
667
+ return { command, directory, wait, token: parsed.values.batch, updates: parsed.values.updates };
524
668
  }
525
669
 
526
- async function readPageInput(filename) {
527
- const chunks = [];
528
- let length = 0;
529
- if (filename === "-") {
530
- for await (const chunk of process.stdin) {
531
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
532
- length += value.byteLength;
533
- if (length > MAX_BODY_BYTES) throw new Error("page is too large");
534
- chunks.push(value);
535
- }
536
- } else {
537
- const body = await readFile(resolve(filename));
538
- if (body.byteLength > MAX_BODY_BYTES) throw new Error("page is too large");
539
- chunks.push(body);
540
- }
541
- return Buffer.concat(chunks).toString("utf8");
542
- }
543
670
 
544
671
  let command;
545
672
  try {
@@ -553,8 +680,7 @@ try {
553
680
  if (command.command === "pull") packet = { type: "pull", wait_seconds: command.wait };
554
681
  else if (command.command === "show") packet = { type: "show" };
555
682
  else {
556
- packet = { type: "push", token: command.token };
557
- if (command.page !== undefined) packet.page = await readPageInput(command.page);
683
+ packet = { type: "push", token: command.token, updates: command.updates === undefined ? [] : await readUpdatesInput(command.updates) };
558
684
  }
559
685
  const result = await connectControl(root, packet);
560
686
  if (command.command === "show" && result.ok) process.stdout.write(result.html);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "letmeknow-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "A live agent-authored page with structured browser feedback.",
5
5
  "files": [
6
6
  "bin",
@@ -24,13 +24,13 @@
24
24
  "@cloudflare/vitest-plugin": "^1.1.0",
25
25
  "@cloudflare/workers-types": "^5.20260825.1",
26
26
  "@types/node": "^22.15.17",
27
- "idiomorph": "0.7.4",
28
27
  "typescript": "^5.9.2",
29
28
  "vitest": "^4.1.11",
30
29
  "wrangler": "^4.126.0",
31
30
  "ws": "^8.21.3"
32
31
  },
33
32
  "dependencies": {
34
- "mrmime": "^2.0.1"
33
+ "mrmime": "^2.0.1",
34
+ "parse5": "^8.0.1"
35
35
  }
36
36
  }