@hachej/boring-feedback 0.0.0-stage → 0.2.4

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 (92) hide show
  1. package/INVARIANTS.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +20 -2
  4. package/dist/agent/capability.d.ts +52 -0
  5. package/dist/agent/capability.js +230 -0
  6. package/dist/agent/index.d.ts +4 -0
  7. package/dist/agent/index.js +5 -0
  8. package/dist/agent/preview.d.ts +10 -0
  9. package/dist/agent/preview.js +27 -0
  10. package/dist/format/index.d.ts +7 -0
  11. package/dist/format/index.js +6 -0
  12. package/dist/format/json.d.ts +30 -0
  13. package/dist/format/json.js +193 -0
  14. package/dist/format/preview.d.ts +28 -0
  15. package/dist/format/preview.js +45 -0
  16. package/dist/format/readable.d.ts +10 -0
  17. package/dist/format/readable.js +19 -0
  18. package/dist/format/report.d.ts +187 -0
  19. package/dist/format/report.js +669 -0
  20. package/dist/format/requests.d.ts +53 -0
  21. package/dist/format/requests.js +69 -0
  22. package/dist/page/app-element.d.ts +79 -0
  23. package/dist/page/app-element.js +451 -0
  24. package/dist/page/canaries.d.ts +46 -0
  25. package/dist/page/canaries.js +142 -0
  26. package/dist/page/index.d.ts +6 -0
  27. package/dist/page/index.js +8 -0
  28. package/dist/page/picker/index.d.ts +4 -0
  29. package/dist/page/picker/index.js +4 -0
  30. package/dist/page/picker/label.d.ts +16 -0
  31. package/dist/page/picker/label.js +58 -0
  32. package/dist/page/picker/overlay.d.ts +42 -0
  33. package/dist/page/picker/overlay.js +126 -0
  34. package/dist/page/picker/picker.d.ts +72 -0
  35. package/dist/page/picker/picker.js +386 -0
  36. package/dist/page/privacy/digest.d.ts +6 -0
  37. package/dist/page/privacy/digest.js +24 -0
  38. package/dist/page/privacy/index.d.ts +6 -0
  39. package/dist/page/privacy/index.js +5 -0
  40. package/dist/page/privacy/name.d.ts +8 -0
  41. package/dist/page/privacy/name.js +106 -0
  42. package/dist/page/privacy/policy.d.ts +65 -0
  43. package/dist/page/privacy/policy.js +125 -0
  44. package/dist/page/privacy/route.d.ts +6 -0
  45. package/dist/page/privacy/route.js +20 -0
  46. package/dist/page/privacy/serialize.d.ts +42 -0
  47. package/dist/page/privacy/serialize.js +90 -0
  48. package/dist/page/voice/align.d.ts +36 -0
  49. package/dist/page/voice/align.js +122 -0
  50. package/dist/page/voice/capture.d.ts +43 -0
  51. package/dist/page/voice/capture.js +186 -0
  52. package/dist/page/voice/index.d.ts +3 -0
  53. package/dist/page/voice/index.js +3 -0
  54. package/dist/preview/agent.d.ts +34 -0
  55. package/dist/preview/agent.js +91 -0
  56. package/dist/preview/index.d.ts +5 -0
  57. package/dist/preview/index.js +6 -0
  58. package/dist/preview/page.d.ts +50 -0
  59. package/dist/preview/page.js +293 -0
  60. package/dist/preview/session.d.ts +63 -0
  61. package/dist/preview/session.js +110 -0
  62. package/dist/source/index.d.ts +24 -0
  63. package/dist/source/index.js +59 -0
  64. package/dist/source/jsx-dev-runtime.d.ts +5 -0
  65. package/dist/source/jsx-dev-runtime.js +29 -0
  66. package/dist/store/index.d.ts +5 -0
  67. package/dist/store/index.js +4 -0
  68. package/dist/store/mentions.d.ts +17 -0
  69. package/dist/store/mentions.js +27 -0
  70. package/dist/store/store.d.ts +121 -0
  71. package/dist/store/store.js +353 -0
  72. package/dist/tickets/index.d.ts +5 -0
  73. package/dist/tickets/index.js +5 -0
  74. package/dist/tickets/sinks.d.ts +27 -0
  75. package/dist/tickets/sinks.js +84 -0
  76. package/dist/tickets/ticket.d.ts +93 -0
  77. package/dist/tickets/ticket.js +148 -0
  78. package/dist/ui/annotate.d.ts +103 -0
  79. package/dist/ui/annotate.js +154 -0
  80. package/dist/ui/index.d.ts +7 -0
  81. package/dist/ui/index.js +8 -0
  82. package/dist/ui/report.d.ts +51 -0
  83. package/dist/ui/report.js +58 -0
  84. package/dist/ui/save.d.ts +45 -0
  85. package/dist/ui/save.js +65 -0
  86. package/dist/ui/session.d.ts +186 -0
  87. package/dist/ui/session.js +762 -0
  88. package/dist/ui/show.d.ts +43 -0
  89. package/dist/ui/show.js +72 -0
  90. package/dist/ui/state.d.ts +11 -0
  91. package/dist/ui/state.js +21 -0
  92. package/package.json +98 -4
@@ -0,0 +1,293 @@
1
+ // The page side of a preview (FEEDBACK.md, "Preview"): what the browser subagent may do to the live application page, and nothing
2
+ // else. Elements are referenced by the feedback pins (`p1`...) or by ids from `inspect` (`e1`...), always inside the application
3
+ // root and never inside an ignored subtree. The only writes are inline CSS properties from an allowlist (no `url()`, no unknown
4
+ // function, no `!important`) and the text of text-only elements; no attribute other than `style` is written, nothing is inserted,
5
+ // submitted, fetched, navigated, stored or evaluated. Every change is logged and `revert` restores the original `style` attribute
6
+ // and text nodes, so Discard (or a reload) leaves the page as it was. What the model reads goes through the privacy policy:
7
+ // masked `app.dom@1` text, policy-kept attributes, accessible names, a few computed colors and sizes (never a value with `url(`).
8
+ import { anchorOf, appElementResolution, elementAt, isExcluded, MASKED_NAME, pageDigest, serializeElement, serializePage, } from '../page/index.js';
9
+ /** CSS properties a preview may set inline. Layout-breaking or resource-loading ones (`content`, `cursor`, `list-style-image`...) are not here. */
10
+ export const PREVIEW_STYLE_PROPERTIES = Object.freeze([
11
+ 'color', 'background', 'background-color', 'opacity', 'visibility',
12
+ 'border', 'border-color', 'border-width', 'border-style', 'border-radius',
13
+ ...['top', 'right', 'bottom', 'left'].flatMap(side => [`border-${side}`, `border-${side}-color`, `border-${side}-width`, `border-${side}-style`]),
14
+ 'outline', 'outline-color', 'outline-width', 'outline-style', 'outline-offset', 'box-shadow', 'text-shadow',
15
+ 'font-size', 'font-weight', 'font-style', 'font-family', 'letter-spacing', 'line-height', 'text-align', 'text-decoration', 'text-transform', 'white-space',
16
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
17
+ 'gap', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
18
+ 'display', 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'align-self', 'order',
19
+ ]);
20
+ /** CSS functions a value may call; anything else (`url`, `image-set`, `attr`, `var`, `expression`, `paint`...) is refused. */
21
+ const FUNCTIONS = new Set(['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color-mix', 'calc', 'min', 'max', 'clamp', 'linear-gradient', 'radial-gradient']);
22
+ /** Never written to: their text is not page copy, or a change would touch a form value or a document. */
23
+ const TEXT_REFUSED = new Set(['input', 'textarea', 'select', 'option', 'optgroup', 'script', 'style', 'iframe', 'object', 'embed', 'template', 'noscript', 'svg', 'math']);
24
+ const STYLE_REFUSED = new Set(['script', 'style', 'template', 'noscript', 'head', 'meta', 'link', 'base']);
25
+ /** Computed properties shown to the model by `inspect`. */
26
+ const SHOWN = ['color', 'background-color', 'font-size', 'font-weight', 'display'];
27
+ const MAX_CSS = 600, MAX_VALUE = 200, MAX_TEXT = 200, MAX_LISTED = 160;
28
+ /** `prop: value; prop: value`, each checked. Returns the declarations or the reason for refusing. */
29
+ export function parseDeclarations(css) {
30
+ if (typeof css !== 'string' || !css.trim())
31
+ return { ok: false, reason: 'give CSS declarations such as "background-color: #2f9e44"' };
32
+ if (css.length > MAX_CSS)
33
+ return { ok: false, reason: `at most ${MAX_CSS} characters of CSS` };
34
+ if (/[{}<>@\\]|\/\*/.test(css))
35
+ return { ok: false, reason: 'only plain declarations: no braces, at-rules, comments, escapes or markup' };
36
+ const declarations = [];
37
+ for (const part of css.split(';')) {
38
+ if (!part.trim())
39
+ continue;
40
+ const colon = part.indexOf(':');
41
+ if (colon < 0)
42
+ return { ok: false, reason: `"${part.trim().slice(0, 40)}" is not a declaration` };
43
+ const property = part.slice(0, colon).trim().toLowerCase(), value = part.slice(colon + 1).trim();
44
+ if (!PREVIEW_STYLE_PROPERTIES.includes(property))
45
+ return { ok: false, reason: `the property "${property.slice(0, 40)}" cannot be previewed` };
46
+ if (!value || value.length > MAX_VALUE)
47
+ return { ok: false, reason: `the value of ${property} must be 1 to ${MAX_VALUE} characters` };
48
+ if (value.includes('!'))
49
+ return { ok: false, reason: '!important is not allowed' };
50
+ if (/["'`]/.test(value) && property !== 'font-family')
51
+ return { ok: false, reason: 'quotes are allowed in font-family only' };
52
+ for (const match of value.matchAll(/([a-z-]*)\s*\(/gi)) {
53
+ const name = (match[1] ?? '').toLowerCase();
54
+ if (!FUNCTIONS.has(name))
55
+ return { ok: false, reason: `the CSS function "${name || '('}" is not allowed (no url(), image-set(), attr(), var() or expressions)` };
56
+ }
57
+ if (/javascript:|expression|behavior|binding/i.test(value))
58
+ return { ok: false, reason: 'that value is not allowed' };
59
+ declarations.push([property, value]);
60
+ }
61
+ return declarations.length ? { ok: true, declarations } : { ok: false, reason: 'no declaration was given' };
62
+ }
63
+ /** A shown value: never one that names a resource. */
64
+ const safeValue = (value) => /url\s*\(|image-set|\(\s*["']/i.test(value) ? '(not shown)' : value.slice(0, MAX_VALUE);
65
+ /** Builds the preview tools over the live application root. Pins are placed now, against a fresh masked snapshot. */
66
+ export async function createPreviewPage(options) {
67
+ const { root, policy } = options;
68
+ const view = root.ownerDocument.defaultView;
69
+ const ids = new Map();
70
+ const idOf = new WeakMap();
71
+ let counter = 0;
72
+ const register = (element) => {
73
+ const known = idOf.get(element);
74
+ if (known)
75
+ return known;
76
+ const id = `e${++counter}`;
77
+ ids.set(id, element);
78
+ idOf.set(element, id);
79
+ return id;
80
+ };
81
+ // Pins: each anchor resolved against the masked page now; only exact and moved placements are usable.
82
+ const pins = [];
83
+ const pinned = new Map();
84
+ const snapshot = serializePage(root, policy);
85
+ const evaluated = await pageDigest(snapshot);
86
+ for (const [index, raw] of (options.anchors ?? []).entries()) {
87
+ const id = `p${index + 1}`;
88
+ let anchor;
89
+ try {
90
+ anchor = appElementResolution.schema.parse(raw);
91
+ }
92
+ catch {
93
+ pins.push({ id, fallback: 'an element of another kind', unplaced: 'this page places app.element@1 pins only' });
94
+ continue;
95
+ }
96
+ const base = { id, fallback: anchor.fallback, ...(anchor.signals.source ? { source: anchor.signals.source } : {}) };
97
+ const placement = appElementResolution.resolve(anchor, snapshot, evaluated);
98
+ const element = placement.kind === 'exact' || placement.kind === 'moved' ? elementAt(placement.range, root, snapshot) : undefined;
99
+ if (!element) {
100
+ pins.push({ ...base, unplaced: placement.kind === 'ambiguous' ? 'it matches more than one element now' : 'it is not on this page now' });
101
+ continue;
102
+ }
103
+ pins.push(base);
104
+ pinned.set(id, element);
105
+ }
106
+ const touched = new Map();
107
+ const log = [];
108
+ const labelOf = (element) => {
109
+ const captured = anchorOf(element, policy, { root });
110
+ if (captured.kind !== 'captured')
111
+ return { fallback: 'an element of the page' };
112
+ return { fallback: captured.anchor.fallback, ...(captured.anchor.signals.source ? { source: captured.anchor.signals.source } : {}) };
113
+ };
114
+ const touch = (element) => {
115
+ let record = touched.get(element);
116
+ if (!record) {
117
+ record = { element, label: labelOf(element), style: element.getAttribute('style') };
118
+ touched.set(element, record);
119
+ }
120
+ return record;
121
+ };
122
+ const resolve = (reference) => {
123
+ if (typeof reference !== 'string')
124
+ return { reason: 'give an element id such as p1 or e3' };
125
+ const element = pinned.get(reference) ?? ids.get(reference);
126
+ if (!element)
127
+ return { reason: `no element "${reference.slice(0, 20)}": use a pin (${pins.filter(pin => !pin.unplaced).map(pin => pin.id).join(', ') || 'none placed'}) or an id from inspect` };
128
+ if (!element.isConnected || !root.contains(element))
129
+ return { reason: `${reference} is no longer on the page` };
130
+ if (isExcluded(element))
131
+ return { reason: `${reference} is not part of the application` };
132
+ return { element };
133
+ };
134
+ const computed = (element, property) => {
135
+ try {
136
+ return view?.getComputedStyle(element).getPropertyValue(property).trim() ?? '';
137
+ }
138
+ catch {
139
+ return '';
140
+ }
141
+ };
142
+ const styleOf = (element) => element.style;
143
+ function setStyle(reference, css) {
144
+ const found = resolve(reference);
145
+ if ('reason' in found)
146
+ return { ok: false, reason: found.reason };
147
+ const { element } = found;
148
+ if (STYLE_REFUSED.has(element.localName) || !styleOf(element))
149
+ return { ok: false, reason: `${reference} cannot be styled` };
150
+ const parsed = parseDeclarations(css);
151
+ if (!parsed.ok)
152
+ return { ok: false, reason: parsed.reason };
153
+ const record = touch(element);
154
+ const style = styleOf(element);
155
+ const applied = [];
156
+ for (const [property, value] of parsed.declarations) {
157
+ const from = safeValue(style.getPropertyValue(property).trim() || computed(element, property));
158
+ const before = style.getPropertyValue(property);
159
+ style.setProperty(property, value);
160
+ if (!style.getPropertyValue(property)) {
161
+ if (before)
162
+ style.setProperty(property, before);
163
+ else
164
+ style.removeProperty(property);
165
+ return { ok: false, reason: `"${value}" is not a valid ${property}${applied.length ? ` (${applied.join(', ')} applied)` : ''}` };
166
+ }
167
+ log.push({ touched: record, property, from, to: safeValue(value) });
168
+ applied.push(`${property}: ${value}`);
169
+ }
170
+ return { ok: true, message: `${reference}: ${applied.join('; ')}` };
171
+ }
172
+ function setText(reference, text) {
173
+ const found = resolve(reference);
174
+ if ('reason' in found)
175
+ return { ok: false, reason: found.reason };
176
+ const { element } = found;
177
+ if (typeof text !== 'string' || !text.trim() || text.length > MAX_TEXT || /[<>]/.test(text))
178
+ return { ok: false, reason: `the text must be 1 to ${MAX_TEXT} characters, without markup` };
179
+ if (TEXT_REFUSED.has(element.localName) || element.closest('[contenteditable]:not([contenteditable=false])'))
180
+ return { ok: false, reason: `the text of ${reference} cannot be changed` };
181
+ if (element.children.length)
182
+ return { ok: false, reason: `${reference} contains other elements: change the text of the innermost element` };
183
+ const own = serializeElement(element, policy, { subtreeLimit: 1 }).root;
184
+ if (!own || own.text === '' || own.text !== (element.textContent ?? '').replace(/\s+/g, ' ').trim())
185
+ return { ok: false, reason: `the text of ${reference} is private on this page and cannot be changed in a preview` };
186
+ const record = touch(element);
187
+ if (!record.text)
188
+ record.text = Array.from(element.childNodes);
189
+ const from = (element.textContent ?? '').replace(/\s+/g, ' ').trim();
190
+ element.replaceChildren(element.ownerDocument.createTextNode(text));
191
+ log.push({ touched: record, text: true, from, to: text });
192
+ return { ok: true, message: `${reference}: text is now "${text}"` };
193
+ }
194
+ function hide(reference) {
195
+ const outcome = setStyle(reference, 'display: none');
196
+ return outcome.ok ? { ok: true, message: `${reference} is hidden` } : outcome;
197
+ }
198
+ function show(reference) {
199
+ const found = resolve(reference);
200
+ if ('reason' in found)
201
+ return { ok: false, reason: found.reason };
202
+ const hidden = [...log].reverse().find(entry => entry.touched.element === found.element && entry.property === 'display');
203
+ if (!hidden || hidden.to !== 'none')
204
+ return { ok: false, reason: `${reference} was not hidden by this preview` };
205
+ const original = /(?:^|;)\s*display\s*:\s*([^;]+)/i.exec(hidden.touched.style ?? '')?.[1]?.trim();
206
+ const style = styleOf(found.element);
207
+ if (original)
208
+ style.setProperty('display', original);
209
+ else
210
+ style.removeProperty('display');
211
+ log.push({ touched: hidden.touched, property: 'display', from: 'none', to: original ?? computed(found.element, 'display') });
212
+ return { ok: true, message: `${reference} is shown again` };
213
+ }
214
+ function inspect() {
215
+ const page = serializePage(root, policy);
216
+ const lines = [];
217
+ for (const pin of pins) {
218
+ const element = pinned.get(pin.id);
219
+ lines.push(element ? `${pin.id} = ${register(element)}: ${pin.fallback}` : `${pin.id}: ${pin.fallback} (not usable: ${pin.unplaced})`);
220
+ }
221
+ let listed = 0;
222
+ const visit = (node, live, depth) => {
223
+ if (listed >= MAX_LISTED)
224
+ return;
225
+ const role = node.attrs['role'];
226
+ const interesting = node.text || role || node.attrs['data-testid'] || node.attrs['data-feedback-id'] || node.attrs['data-source'] || depth === 0;
227
+ if (interesting) {
228
+ listed++;
229
+ const id = register(live);
230
+ const parts = [`${' '.repeat(Math.min(depth, 8))}${id} ${node.tag}`];
231
+ if (role)
232
+ parts.push(`role=${role}`);
233
+ if (node.text)
234
+ parts.push(JSON.stringify(node.text.slice(0, 80)));
235
+ for (const key of ['data-testid', 'data-feedback-id', 'data-source'])
236
+ if (node.attrs[key])
237
+ parts.push(`${key}=${node.attrs[key]}`);
238
+ const styles = SHOWN.map(property => [property, safeValue(computed(live, property))]).filter(([, value]) => value);
239
+ if (styles.length)
240
+ parts.push(`{${styles.map(([property, value]) => `${property}: ${value}`).join('; ')}}`);
241
+ lines.push(parts.join(' '));
242
+ }
243
+ // The live children in the snapshot's order: same tag, same index among same-tag siblings (as `elementAt` walks).
244
+ for (const child of node.children) {
245
+ let seen = 0, found;
246
+ for (const candidate of Array.from(live.children))
247
+ if (candidate.localName === child.tag && seen++ === child.index) {
248
+ found = candidate;
249
+ break;
250
+ }
251
+ if (found)
252
+ visit(child, found, depth + 1);
253
+ }
254
+ };
255
+ if (page.root)
256
+ visit(page.root, root, 0);
257
+ if (page.truncated || listed >= MAX_LISTED)
258
+ lines.push('(outline truncated)');
259
+ lines.push(`Text shown as ${JSON.stringify(MASKED_NAME)} or with masked characters is private: do not guess it.`);
260
+ return lines.join('\n');
261
+ }
262
+ function changes() {
263
+ const net = new Map();
264
+ const order = [];
265
+ for (const entry of log) {
266
+ const key = `${[...touched.keys()].indexOf(entry.touched.element)}:${entry.text ? '#text' : entry.property}`;
267
+ const known = net.get(key);
268
+ if (known)
269
+ known.to = entry.to;
270
+ else {
271
+ net.set(key, { touched: entry.touched, ...(entry.property ? { property: entry.property } : {}), ...(entry.text ? { text: true } : {}), from: entry.from, to: entry.to });
272
+ order.push(key);
273
+ }
274
+ }
275
+ return order.map(key => net.get(key)).filter(item => item.from !== item.to).map(item => Object.freeze({
276
+ element: item.touched.label.fallback, ...(item.touched.label.source ? { source: item.touched.label.source } : {}),
277
+ ...(item.property ? { property: item.property } : { text: true }), from: item.from, to: item.to,
278
+ }));
279
+ }
280
+ function revert() {
281
+ for (const record of [...touched.values()].reverse()) {
282
+ if (record.style === null)
283
+ record.element.removeAttribute('style');
284
+ else
285
+ record.element.setAttribute('style', record.style);
286
+ if (record.text)
287
+ record.element.replaceChildren(...record.text);
288
+ }
289
+ touched.clear();
290
+ log.length = 0;
291
+ }
292
+ return Object.freeze({ pins: Object.freeze(pins.map(pin => Object.freeze(pin))), inspect, setStyle, setText, hide, show, changes, revert });
293
+ }
@@ -0,0 +1,63 @@
1
+ import type { Api, Model, Models } from '@earendil-works/pi-ai';
2
+ import type { PrivacyPolicy } from '../page/index.js';
3
+ import type { PreviewChange } from '../format/index.js';
4
+ export declare const BROWSER_PREVIEW_TOOL = "browser_preview";
5
+ /** A pending browser task found in a conversation view: its answer id (`[assistantEntryId, callId]`) and its arguments. */
6
+ export interface PendingBrowserTask {
7
+ readonly id: string;
8
+ readonly callId: string;
9
+ readonly arguments: Readonly<Record<string, unknown>>;
10
+ }
11
+ interface ViewLike {
12
+ readonly entries: readonly {
13
+ readonly id: number | bigint | string;
14
+ readonly model?: readonly unknown[] | undefined;
15
+ }[];
16
+ readonly docs?: Readonly<Record<string, unknown>>;
17
+ }
18
+ /**
19
+ * The calls of `tool` in a conversation view that have no result yet, oldest first, while the conversation is running. The id is the
20
+ * binding `browserTaskId(entry, call)` of `@hachej/boring-agent/browser-task`, which the chat transport's `answer` takes.
21
+ */
22
+ export declare function pendingBrowserTasks(view: ViewLike | undefined, tool?: string): readonly PendingBrowserTask[];
23
+ export type PreviewStatus = 'starting' | 'working' | 'ready' | 'failed' | 'answering' | 'approved' | 'discarded';
24
+ export interface PreviewSnapshot {
25
+ readonly status: PreviewStatus;
26
+ /** The subagent's last sentence. */
27
+ readonly summary: string;
28
+ readonly changes: readonly PreviewChange[];
29
+ /** Why the last step failed or the answer was refused. */
30
+ readonly error?: string;
31
+ }
32
+ export interface PreviewSession {
33
+ readonly getSnapshot: () => PreviewSnapshot;
34
+ readonly subscribe: (listener: () => void) => () => void;
35
+ /** Places the pins and runs the instructions. */
36
+ readonly start: () => Promise<void>;
37
+ /** A follow-up to the subagent ("darker"). */
38
+ readonly say: (text: string) => Promise<void>;
39
+ readonly approve: () => Promise<void>;
40
+ readonly discard: () => Promise<void>;
41
+ /** Reverts the page without answering (the page is closing, or another preview replaces this one). */
42
+ readonly dispose: () => void;
43
+ }
44
+ export type PreviewAnswerOutcome = {
45
+ readonly kind: 'answered';
46
+ } | {
47
+ readonly kind: string;
48
+ readonly reason?: string;
49
+ };
50
+ export interface PreviewSessionOptions {
51
+ readonly task: PendingBrowserTask;
52
+ readonly root: () => Element | null;
53
+ readonly policy: PrivacyPolicy;
54
+ readonly models: Models;
55
+ readonly model: Model<Api>;
56
+ /** The anchors of the report the call names (`arguments.feedback`), read with the person's own access; none when it names none. */
57
+ readonly anchors?: (feedbackId: string) => Promise<readonly unknown[]>;
58
+ /** Sends the answer through the host's authenticated path (the chat transport's `answer` with `task.id`). */
59
+ readonly answer: (id: string, answer: string) => Promise<PreviewAnswerOutcome>;
60
+ }
61
+ export declare function createPreviewSession(options: PreviewSessionOptions): PreviewSession;
62
+ export {};
63
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,110 @@
1
+ import { createPreviewPage } from './page.js';
2
+ import { createPreviewAgent } from './agent.js';
3
+ export const BROWSER_PREVIEW_TOOL = 'browser_preview';
4
+ /**
5
+ * The calls of `tool` in a conversation view that have no result yet, oldest first, while the conversation is running. The id is the
6
+ * binding `browserTaskId(entry, call)` of `@hachej/boring-agent/browser-task`, which the chat transport's `answer` takes.
7
+ */
8
+ export function pendingBrowserTasks(view, tool = BROWSER_PREVIEW_TOOL) {
9
+ if (!view)
10
+ return [];
11
+ const live = view.docs?.['pi.live'];
12
+ if (!live?.run)
13
+ return [];
14
+ const calls = new Map();
15
+ for (const entry of view.entries)
16
+ for (const message of entry.model ?? []) {
17
+ const item = message;
18
+ if (item.role === 'assistant' && Array.isArray(item.content)) {
19
+ for (const part of item.content) {
20
+ if (part.type === 'toolCall' && part.name === tool && typeof part.id === 'string') {
21
+ calls.set(part.id, { id: JSON.stringify([Number(entry.id), part.id]), callId: part.id, arguments: part.arguments ?? {} });
22
+ }
23
+ }
24
+ }
25
+ if (item.role === 'toolResult' && typeof item.toolCallId === 'string')
26
+ calls.delete(item.toolCallId);
27
+ }
28
+ return [...calls.values()];
29
+ }
30
+ export function createPreviewSession(options) {
31
+ let snapshot = Object.freeze({ status: 'starting', summary: '', changes: Object.freeze([]) });
32
+ const listeners = new Set();
33
+ const publish = (change) => {
34
+ const { error: _old, ...rest } = snapshot;
35
+ snapshot = Object.freeze({ ...rest, ...change });
36
+ for (const listener of [...listeners])
37
+ listener();
38
+ };
39
+ let page, agent;
40
+ const abort = new AbortController();
41
+ const settled = () => snapshot.status === 'approved' || snapshot.status === 'discarded';
42
+ const instructions = typeof options.task.arguments['instructions'] === 'string' ? options.task.arguments['instructions'] : '';
43
+ const feedback = typeof options.task.arguments['feedback'] === 'string' ? options.task.arguments['feedback'] : undefined;
44
+ const step = async (text) => {
45
+ if (!agent || !page || settled())
46
+ return;
47
+ publish({ status: 'working' });
48
+ const outcome = await agent.say(text, abort.signal);
49
+ if (settled())
50
+ return;
51
+ publish('error' in outcome
52
+ ? { status: page.changes().length ? 'ready' : 'failed', changes: page.changes(), error: outcome.error }
53
+ : { status: 'ready', summary: outcome.text, changes: page.changes() });
54
+ };
55
+ const send = async (answer, status) => {
56
+ publish({ status: 'answering' });
57
+ try {
58
+ const outcome = await options.answer(options.task.id, JSON.stringify(answer));
59
+ // Approved or discarded, the live page goes back to what is really saved: the approved changes travel in the answer.
60
+ if (outcome.kind === 'answered') {
61
+ page?.revert();
62
+ publish({ status });
63
+ return;
64
+ }
65
+ publish({ status: 'ready', error: ('reason' in outcome && outcome.reason) || `The answer was not accepted (${outcome.kind}).` });
66
+ }
67
+ catch (error) {
68
+ publish({ status: 'ready', error: error instanceof Error ? error.message : 'The answer could not be sent.' });
69
+ }
70
+ };
71
+ return Object.freeze({
72
+ getSnapshot: () => snapshot,
73
+ subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
74
+ start: async () => {
75
+ if (page)
76
+ return;
77
+ const root = options.root();
78
+ if (!root) {
79
+ publish({ status: 'failed', error: 'The application page is not open here.' });
80
+ return;
81
+ }
82
+ let anchors = [];
83
+ if (feedback && options.anchors) {
84
+ try {
85
+ anchors = await options.anchors(feedback);
86
+ }
87
+ catch { /* preview without pins */ }
88
+ }
89
+ page = await createPreviewPage({ root, policy: options.policy, anchors });
90
+ agent = createPreviewAgent({ models: options.models, model: options.model, page, instructions: instructions || 'Preview the requested change.' });
91
+ await step();
92
+ },
93
+ say: async (text) => { if (text.trim() && snapshot.status !== 'working')
94
+ await step(text.trim()); },
95
+ approve: async () => { if (agent && !settled() && snapshot.status !== 'working')
96
+ await send(agent.approve(), 'approved'); },
97
+ discard: async () => {
98
+ if (settled())
99
+ return;
100
+ abort.abort();
101
+ if (agent) {
102
+ await send(agent.discard(), 'discarded');
103
+ return;
104
+ }
105
+ page?.revert();
106
+ await send({ kind: 'discarded' }, 'discarded');
107
+ },
108
+ dispose: () => { abort.abort(); page?.revert(); listeners.clear(); },
109
+ });
110
+ }
@@ -0,0 +1,24 @@
1
+ import type { Plugin } from 'esbuild';
2
+ /** Build modes. Only `development` and `preview` may carry source locations. */
3
+ export type FeedbackSourceMode = 'development' | 'preview' | 'production';
4
+ export interface FeedbackSourceOptions {
5
+ /**
6
+ * Absolute project root. It must equal the build's `absWorkingDir`: esbuild names each JSX source relative to that
7
+ * directory, so this is what makes stamped paths project-relative. React is resolved from here too, so the page
8
+ * keeps one React instance.
9
+ */
10
+ readonly root: string;
11
+ readonly mode: FeedbackSourceMode;
12
+ }
13
+ /** The browser runtime module the plugin substitutes for `react/jsx-dev-runtime`. */
14
+ export declare const FEEDBACK_SOURCE_RUNTIME = "@hachej/boring-feedback/source/jsx-dev-runtime";
15
+ /**
16
+ * esbuild plugin for development and preview builds.
17
+ *
18
+ * The build must set `bundle: true`, `jsx: 'automatic'`, `jsxDev: true` and `absWorkingDir: root`. Every
19
+ * `react/jsx-dev-runtime` import (except the runtime's own) then resolves to the feedback runtime, which adds
20
+ * `data-source` to intrinsic elements. Production builds are refused: creating the plugin with `mode: 'production'`
21
+ * throws, so a production build that enables it fails.
22
+ */
23
+ export declare function feedbackSourcePlugin(options: FeedbackSourceOptions): Plugin;
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,59 @@
1
+ /** The browser runtime module the plugin substitutes for `react/jsx-dev-runtime`. */
2
+ export const FEEDBACK_SOURCE_RUNTIME = '@hachej/boring-feedback/source/jsx-dev-runtime';
3
+ const REACT_DEV_RUNTIME = 'react/jsx-dev-runtime';
4
+ const NAME = 'feedback-source';
5
+ // Marks the plugin's own lookup of React's runtime so that lookup is not redirected again.
6
+ const ORIGINAL = { feedbackSource: 'react-dev-runtime' };
7
+ const trimEnd = (path) => path.replace(/[\\/]+$/, '');
8
+ const isAbsolute = (path) => /^(?:\/|[A-Za-z]:[\\/])/.test(path);
9
+ /**
10
+ * esbuild plugin for development and preview builds.
11
+ *
12
+ * The build must set `bundle: true`, `jsx: 'automatic'`, `jsxDev: true` and `absWorkingDir: root`. Every
13
+ * `react/jsx-dev-runtime` import (except the runtime's own) then resolves to the feedback runtime, which adds
14
+ * `data-source` to intrinsic elements. Production builds are refused: creating the plugin with `mode: 'production'`
15
+ * throws, so a production build that enables it fails.
16
+ */
17
+ export function feedbackSourcePlugin(options) {
18
+ const { root, mode } = options;
19
+ if (mode === 'production')
20
+ throw new Error(`${NAME}: refusing a production build; source locations are for development and preview builds only`);
21
+ if (mode !== 'development' && mode !== 'preview')
22
+ throw new Error(`${NAME}: unknown mode ${JSON.stringify(mode)}; expected "development" or "preview"`);
23
+ if (typeof root !== 'string' || !isAbsolute(root))
24
+ throw new Error(`${NAME}: root must be an absolute project directory`);
25
+ return {
26
+ name: NAME,
27
+ setup(build) {
28
+ const initial = build.initialOptions;
29
+ if (initial.bundle !== true)
30
+ throw new Error(`${NAME}: the build must set bundle: true`);
31
+ if (initial.jsx !== 'automatic' || initial.jsxDev !== true)
32
+ throw new Error(`${NAME}: the build must set jsx: 'automatic' and jsxDev: true`);
33
+ if (initial.jsxImportSource !== undefined && initial.jsxImportSource !== 'react')
34
+ throw new Error(`${NAME}: jsxImportSource must be react`);
35
+ if (initial.absWorkingDir === undefined || trimEnd(initial.absWorkingDir) !== trimEnd(root)) {
36
+ throw new Error(`${NAME}: the build must set absWorkingDir to the project root (${root}) so source paths are project-relative`);
37
+ }
38
+ let runtime;
39
+ build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, async (args) => {
40
+ if (args.pluginData === ORIGINAL)
41
+ return undefined;
42
+ const path = await (runtime ??= resolveRuntime(build, root, args.kind));
43
+ if (args.importer !== path)
44
+ return { path };
45
+ const original = await build.resolve(REACT_DEV_RUNTIME, { kind: args.kind, resolveDir: root, pluginData: ORIGINAL });
46
+ if (original.errors.length)
47
+ return { errors: original.errors };
48
+ return { path: original.path, external: original.external, namespace: original.namespace, sideEffects: original.sideEffects };
49
+ });
50
+ },
51
+ };
52
+ }
53
+ async function resolveRuntime(build, root, kind) {
54
+ const result = await build.resolve(FEEDBACK_SOURCE_RUNTIME, { kind, resolveDir: root });
55
+ if (result.errors.length || result.external || result.namespace !== 'file') {
56
+ throw new Error(`${NAME}: cannot bundle ${FEEDBACK_SOURCE_RUNTIME} from ${root}; install @hachej/boring-feedback in the project and do not mark it external`);
57
+ }
58
+ return result.path;
59
+ }
@@ -0,0 +1,5 @@
1
+ import { Fragment, jsxDEV as reactJsxDEV } from 'react/jsx-dev-runtime';
2
+ export { Fragment };
3
+ type Parameter = Parameters<typeof reactJsxDEV>;
4
+ export declare function jsxDEV(type: Parameter[0], props: Parameter[1], key: Parameter[2], isStatic: Parameter[3], source?: Parameter[4], self?: Parameter[5]): ReturnType<typeof reactJsxDEV>;
5
+ //# sourceMappingURL=jsx-dev-runtime.d.ts.map
@@ -0,0 +1,29 @@
1
+ // @hachej/boring-feedback/source/jsx-dev-runtime: browser code, bundled into development and preview pages by
2
+ // `feedbackSourcePlugin`. It is React's JSX development runtime plus one attribute: intrinsic elements get
3
+ // `data-source="<path>:<line>"` from the source esbuild passes. It uses no `process` or other Node API, and imports
4
+ // nothing but React's own development runtime.
5
+ import { Fragment, jsxDEV as reactJsxDEV } from 'react/jsx-dev-runtime';
6
+ export { Fragment };
7
+ /**
8
+ * `<path>:<line>` for a project-relative source, or `undefined` when the path is absolute, carries a drive letter or
9
+ * scheme, or contains `..` (the attribute is then omitted rather than leak or misname a location).
10
+ */
11
+ function sourceLocation(source) {
12
+ const file = source?.fileName;
13
+ const line = source?.lineNumber;
14
+ if (typeof file !== 'string' || typeof line !== 'number' || !Number.isInteger(line) || line < 1)
15
+ return undefined;
16
+ const path = file.replaceAll('\\', '/');
17
+ if (path === '' || path.startsWith('/') || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(path) || path.split('/').includes('..'))
18
+ return undefined;
19
+ return `${path}:${line}`;
20
+ }
21
+ const isProps = (value) => typeof value === 'object' && value !== null;
22
+ export function jsxDEV(type, props, key, isStatic, source, self) {
23
+ const location = typeof type === 'string' ? sourceLocation(source) : undefined;
24
+ // Never override an explicit data-source, and never mutate the caller's props object.
25
+ const stamped = location !== undefined && isProps(props) && !Object.hasOwn(props, 'data-source')
26
+ ? { ...props, 'data-source': location }
27
+ : props;
28
+ return reactJsxDEV(type, stamped, key, isStatic, source, self);
29
+ }
@@ -0,0 +1,5 @@
1
+ export { createFeedbackStore, feedbackId, FEEDBACK_PAGE_SIZE, FEEDBACK_LIST_LIMIT } from './store.js';
2
+ export type { FeedbackProtection, FeedbackPermission, FeedbackAction, FeedbackSubject, FeedbackOperation, FeedbackStoreOptions, FeedbackStore, FeedbackDenied, FeedbackUnavailable, FeedbackMissing, FeedbackConflict, FeedbackUnknown, FeedbackRefusal, FeedbackStored, FeedbackApplied, FeedbackCreateResult, FeedbackResolveResult, FeedbackReadResult, FeedbackListItem, FeedbackListResult, FeedbackListQuery, FeedbackResolveInput, } from './store.js';
3
+ export { feedbackMentionReader } from './mentions.js';
4
+ export type { FeedbackMentionFile, FeedbackMentionReader } from './mentions.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ // @hachej/boring-feedback/store (FEEDBACK.md, "Storage"): the server-side store over injected resource contracts, its operation
2
+ // admission and reconciliation, and the mention reader. Imports `./format` and `@hachej/boring-files/platform` only.
3
+ export { createFeedbackStore, feedbackId, FEEDBACK_PAGE_SIZE, FEEDBACK_LIST_LIMIT } from './store.js';
4
+ export { feedbackMentionReader } from './mentions.js';
@@ -0,0 +1,17 @@
1
+ import type { ResourceAccess } from '@hachej/boring-files';
2
+ import type { FeedbackStore } from './store.js';
3
+ /** Structurally the `MentionFile` of `@hachej/boring-agent` (`packages/agent/src/mentions.ts`), which this folder may not import. */
4
+ export interface FeedbackMentionFile {
5
+ readonly size: number;
6
+ readonly bytes?: Uint8Array;
7
+ }
8
+ /** Structurally a `MentionReader`: undefined when the path is not a readable report. Never throws. */
9
+ export type FeedbackMentionReader = (path: string) => Promise<FeedbackMentionFile | undefined>;
10
+ /**
11
+ * A mention reader for `@<root><id>.md`: the same read authorization as `store.read`, on the report's subject, with access
12
+ * resolved per read. Mention paths are taken as resource paths, so the host mounts the root at the same workspace-relative
13
+ * path it gives the store; any other path is not this reader's and returns undefined (compose it in
14
+ * front of the host's file reader). The bytes are the canonical serialization of the stored report.
15
+ */
16
+ export declare function feedbackMentionReader<Context>(store: Pick<FeedbackStore<Context>, 'root' | 'read'>, resolveAccess: () => ResourceAccess | Promise<ResourceAccess>): FeedbackMentionReader;
17
+ //# sourceMappingURL=mentions.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { FEEDBACK_ID, serializeFeedback } from '../format/index.js';
2
+ /**
3
+ * A mention reader for `@<root><id>.md`: the same read authorization as `store.read`, on the report's subject, with access
4
+ * resolved per read. Mention paths are taken as resource paths, so the host mounts the root at the same workspace-relative
5
+ * path it gives the store; any other path is not this reader's and returns undefined (compose it in
6
+ * front of the host's file reader). The bytes are the canonical serialization of the stored report.
7
+ */
8
+ export function feedbackMentionReader(store, resolveAccess) {
9
+ const prefix = store.root.path;
10
+ return async (path) => {
11
+ try {
12
+ if (typeof path !== 'string' || !path.startsWith(prefix) || !path.endsWith('.md'))
13
+ return undefined;
14
+ const id = path.slice(prefix.length, -'.md'.length);
15
+ if (!FEEDBACK_ID.test(id))
16
+ return undefined;
17
+ const read = await store.read(id, await resolveAccess());
18
+ if (read.kind !== 'available')
19
+ return undefined;
20
+ const bytes = serializeFeedback(read.report);
21
+ return { size: bytes.length, bytes };
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ };
27
+ }