@hachej/boring-feedback 0.0.0-stage → 0.2.5
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/INVARIANTS.md +35 -0
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/agent/capability.d.ts +52 -0
- package/dist/agent/capability.js +230 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +5 -0
- package/dist/agent/preview.d.ts +10 -0
- package/dist/agent/preview.js +27 -0
- package/dist/format/index.d.ts +7 -0
- package/dist/format/index.js +6 -0
- package/dist/format/json.d.ts +30 -0
- package/dist/format/json.js +193 -0
- package/dist/format/preview.d.ts +28 -0
- package/dist/format/preview.js +45 -0
- package/dist/format/readable.d.ts +10 -0
- package/dist/format/readable.js +19 -0
- package/dist/format/report.d.ts +187 -0
- package/dist/format/report.js +669 -0
- package/dist/format/requests.d.ts +53 -0
- package/dist/format/requests.js +69 -0
- package/dist/page/app-element.d.ts +79 -0
- package/dist/page/app-element.js +451 -0
- package/dist/page/canaries.d.ts +46 -0
- package/dist/page/canaries.js +142 -0
- package/dist/page/index.d.ts +6 -0
- package/dist/page/index.js +8 -0
- package/dist/page/picker/index.d.ts +4 -0
- package/dist/page/picker/index.js +4 -0
- package/dist/page/picker/label.d.ts +16 -0
- package/dist/page/picker/label.js +58 -0
- package/dist/page/picker/overlay.d.ts +42 -0
- package/dist/page/picker/overlay.js +126 -0
- package/dist/page/picker/picker.d.ts +72 -0
- package/dist/page/picker/picker.js +386 -0
- package/dist/page/privacy/digest.d.ts +6 -0
- package/dist/page/privacy/digest.js +24 -0
- package/dist/page/privacy/index.d.ts +6 -0
- package/dist/page/privacy/index.js +5 -0
- package/dist/page/privacy/name.d.ts +8 -0
- package/dist/page/privacy/name.js +106 -0
- package/dist/page/privacy/policy.d.ts +65 -0
- package/dist/page/privacy/policy.js +125 -0
- package/dist/page/privacy/route.d.ts +6 -0
- package/dist/page/privacy/route.js +20 -0
- package/dist/page/privacy/serialize.d.ts +42 -0
- package/dist/page/privacy/serialize.js +90 -0
- package/dist/page/voice/align.d.ts +36 -0
- package/dist/page/voice/align.js +122 -0
- package/dist/page/voice/capture.d.ts +43 -0
- package/dist/page/voice/capture.js +186 -0
- package/dist/page/voice/index.d.ts +3 -0
- package/dist/page/voice/index.js +3 -0
- package/dist/preview/agent.d.ts +34 -0
- package/dist/preview/agent.js +91 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +6 -0
- package/dist/preview/page.d.ts +50 -0
- package/dist/preview/page.js +293 -0
- package/dist/preview/session.d.ts +63 -0
- package/dist/preview/session.js +110 -0
- package/dist/source/index.d.ts +24 -0
- package/dist/source/index.js +59 -0
- package/dist/source/jsx-dev-runtime.d.ts +5 -0
- package/dist/source/jsx-dev-runtime.js +29 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.js +4 -0
- package/dist/store/mentions.d.ts +17 -0
- package/dist/store/mentions.js +27 -0
- package/dist/store/store.d.ts +121 -0
- package/dist/store/store.js +353 -0
- package/dist/tickets/index.d.ts +5 -0
- package/dist/tickets/index.js +5 -0
- package/dist/tickets/sinks.d.ts +27 -0
- package/dist/tickets/sinks.js +84 -0
- package/dist/tickets/ticket.d.ts +93 -0
- package/dist/tickets/ticket.js +148 -0
- package/dist/ui/annotate.d.ts +103 -0
- package/dist/ui/annotate.js +154 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/report.d.ts +51 -0
- package/dist/ui/report.js +58 -0
- package/dist/ui/save.d.ts +45 -0
- package/dist/ui/save.js +65 -0
- package/dist/ui/session.d.ts +188 -0
- package/dist/ui/session.js +787 -0
- package/dist/ui/show.d.ts +43 -0
- package/dist/ui/show.js +72 -0
- package/dist/ui/state.d.ts +11 -0
- package/dist/ui/state.js +21 -0
- package/package.json +98 -4
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Voice capture for a feedback session: the person's microphone recorded while they point, with the page-clock time of the first
|
|
2
|
+
// recorded sample so speech can be aligned with the pointer (./align.ts).
|
|
3
|
+
//
|
|
4
|
+
// Microphone access (getUserMedia) exists only in secure contexts (https or localhost) and needs the person's permission. The
|
|
5
|
+
// capture never opens it on load: only `start()`, called from the person's click, does, through an injectable `getUserMedia`
|
|
6
|
+
// whose default feature-detects `navigator.mediaDevices.getUserMedia` and refuses with a reason when it is missing. A host that
|
|
7
|
+
// owns microphone policy passes its own. `npm run check` (scripts/check-platform-apis.mjs) allowlists this file on that condition.
|
|
8
|
+
//
|
|
9
|
+
// The clock. MediaRecorder hands audio over late and in chunks, so neither its `start` event nor a chunk's arrival says when the
|
|
10
|
+
// first recorded sample was heard. `npm run feedback:voice-sync` (tools/feedback-voice-sync.mjs) measured seven candidate
|
|
11
|
+
// strategies in Chromium against an independent reader of the same microphone; the page time read immediately before
|
|
12
|
+
// `MediaRecorder.start()` was the most accurate (2–4 ms mean, under 9 ms max over three batches, with the fake device's dropped
|
|
13
|
+
// time removed), while the `start` event is about 60 ms late (up to 125 ms) and stop-minus-duration 30–40 ms late. So
|
|
14
|
+
// `startedAt` is that time.
|
|
15
|
+
// `endedAt` is `startedAt` plus the decoded duration when the page can decode the recording, else the `stop` event's time.
|
|
16
|
+
// Measured in Chromium only; a microphone that warms up after getUserMedia resolves (no audio yet when recording starts) would
|
|
17
|
+
// make `startedAt` early by the warm-up.
|
|
18
|
+
export const DEFAULT_MAX_VOICE_MS = 5 * 60_000;
|
|
19
|
+
/** Container types tried in order; the first the browser records wins. */
|
|
20
|
+
export const VOICE_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
|
21
|
+
/** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
|
|
22
|
+
export function browserGetUserMedia() {
|
|
23
|
+
if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function')
|
|
24
|
+
return null;
|
|
25
|
+
const devices = globalThis.navigator.mediaDevices;
|
|
26
|
+
return constraints => devices.getUserMedia(constraints);
|
|
27
|
+
}
|
|
28
|
+
/** Short human messages, one per refusal; each says what to do next. */
|
|
29
|
+
export const VOICE_REFUSALS = {
|
|
30
|
+
denied: 'Microphone blocked. Allow it for this site in the browser, then press Record again.',
|
|
31
|
+
'no-microphone': 'No microphone found. Connect one, then press Record again.',
|
|
32
|
+
busy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',
|
|
33
|
+
insecure: 'Recording needs a secure page (https or localhost).',
|
|
34
|
+
unsupported: 'This browser cannot record audio here.',
|
|
35
|
+
failed: 'The microphone could not be started.',
|
|
36
|
+
};
|
|
37
|
+
const refused = (code, detail) => ({ kind: 'refused', code, reason: detail ? `${VOICE_REFUSALS[code]} (${detail})` : VOICE_REFUSALS[code] });
|
|
38
|
+
function refusalOf(error) {
|
|
39
|
+
const name = error?.name;
|
|
40
|
+
if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError')
|
|
41
|
+
return refused('denied');
|
|
42
|
+
if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError')
|
|
43
|
+
return refused('no-microphone');
|
|
44
|
+
if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError')
|
|
45
|
+
return refused('busy');
|
|
46
|
+
return refused('failed', error instanceof Error ? error.message : String(error));
|
|
47
|
+
}
|
|
48
|
+
/** Media devices missing on a plain-http page: the usual reason is an insecure origin (http on a name that is not local; a local
|
|
49
|
+
* http page has them). Wording only; the capability itself is feature-detected (`browserGetUserMedia`). */
|
|
50
|
+
const plainHttpPage = () => globalThis.location?.protocol === 'http:';
|
|
51
|
+
const release = (stream) => { for (const track of stream?.getTracks() ?? [])
|
|
52
|
+
track.stop(); };
|
|
53
|
+
/** Duration in milliseconds of recorded audio, when the page can decode it (OfflineAudioContext or AudioContext). */
|
|
54
|
+
async function decodedMs(audio) {
|
|
55
|
+
try {
|
|
56
|
+
const Offline = globalThis.OfflineAudioContext;
|
|
57
|
+
if (typeof Offline !== 'function' || audio.size === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const context = new Offline(1, 1, 48_000);
|
|
60
|
+
const buffer = await context.decodeAudioData(await audio.arrayBuffer());
|
|
61
|
+
return Number.isFinite(buffer.duration) ? buffer.duration * 1000 : null;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function createVoiceCapture(options = {}) {
|
|
68
|
+
const now = options.now ?? (() => performance.now());
|
|
69
|
+
const maxMs = options.maxMs ?? DEFAULT_MAX_VOICE_MS;
|
|
70
|
+
let session = null;
|
|
71
|
+
let opening = false;
|
|
72
|
+
let cancelledWhileOpening = false;
|
|
73
|
+
const finish = (current, truncated) => {
|
|
74
|
+
if (current.timer !== null) {
|
|
75
|
+
clearTimeout(current.timer);
|
|
76
|
+
current.timer = null;
|
|
77
|
+
}
|
|
78
|
+
if (truncated)
|
|
79
|
+
current.truncated = true;
|
|
80
|
+
if (current.recorder.state !== 'inactive') {
|
|
81
|
+
try {
|
|
82
|
+
current.recorder.stop();
|
|
83
|
+
}
|
|
84
|
+
catch { /* already stopping */ }
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
async start() {
|
|
89
|
+
if (session || opening)
|
|
90
|
+
return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
|
|
91
|
+
const Recorder = globalThis.MediaRecorder;
|
|
92
|
+
const open = options.getUserMedia ?? browserGetUserMedia();
|
|
93
|
+
// No media devices on an insecure origin: say that, not "no microphone" (the person can fix the address, not the hardware).
|
|
94
|
+
if (!open)
|
|
95
|
+
return refused(plainHttpPage() ? 'insecure' : 'unsupported');
|
|
96
|
+
if (typeof Recorder !== 'function')
|
|
97
|
+
return refused('unsupported', 'no MediaRecorder');
|
|
98
|
+
// getUserMedia is called synchronously from here, inside the person's click (no await before it), so browsers that tie the
|
|
99
|
+
// permission prompt to a user gesture show it.
|
|
100
|
+
opening = true;
|
|
101
|
+
cancelledWhileOpening = false;
|
|
102
|
+
let stream;
|
|
103
|
+
try {
|
|
104
|
+
stream = await open({ audio: true });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
opening = false;
|
|
108
|
+
return refusalOf(error);
|
|
109
|
+
}
|
|
110
|
+
if (cancelledWhileOpening) {
|
|
111
|
+
opening = false;
|
|
112
|
+
release(stream);
|
|
113
|
+
return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
|
|
114
|
+
}
|
|
115
|
+
const supported = (type) => { try {
|
|
116
|
+
return typeof Recorder.isTypeSupported !== 'function' || Recorder.isTypeSupported(type);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
} };
|
|
121
|
+
const preferred = VOICE_MIME_TYPES.find(supported);
|
|
122
|
+
let recorder;
|
|
123
|
+
try {
|
|
124
|
+
recorder = preferred ? new Recorder(stream, { mimeType: preferred }) : new Recorder(stream);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
opening = false;
|
|
128
|
+
release(stream);
|
|
129
|
+
return refused('unsupported', error instanceof Error ? error.message : String(error));
|
|
130
|
+
}
|
|
131
|
+
let settle;
|
|
132
|
+
const current = {
|
|
133
|
+
stream, recorder, mimeType: preferred ?? '', chunks: [], startedAt: null, stoppedAt: null, truncated: false, timer: null,
|
|
134
|
+
stopped: new Promise(resolve => { settle = resolve; }), cancelled: false,
|
|
135
|
+
};
|
|
136
|
+
recorder.addEventListener('dataavailable', event => { if (event.data && event.data.size > 0)
|
|
137
|
+
current.chunks.push(event.data); });
|
|
138
|
+
recorder.addEventListener('stop', () => { current.stoppedAt = now(); release(stream); settle(); });
|
|
139
|
+
recorder.addEventListener('error', () => { current.stoppedAt ??= now(); release(stream); settle(); });
|
|
140
|
+
try {
|
|
141
|
+
current.startedAt = now();
|
|
142
|
+
recorder.start(1000);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
opening = false;
|
|
146
|
+
release(stream);
|
|
147
|
+
return refused('failed', error instanceof Error ? error.message : String(error));
|
|
148
|
+
}
|
|
149
|
+
if (Number.isFinite(maxMs) && maxMs > 0)
|
|
150
|
+
current.timer = setTimeout(() => finish(current, true), maxMs);
|
|
151
|
+
session = current;
|
|
152
|
+
opening = false;
|
|
153
|
+
return { kind: 'recording' };
|
|
154
|
+
},
|
|
155
|
+
async stop() {
|
|
156
|
+
const current = session;
|
|
157
|
+
if (!current)
|
|
158
|
+
return null;
|
|
159
|
+
finish(current, false);
|
|
160
|
+
await current.stopped;
|
|
161
|
+
session = null;
|
|
162
|
+
if (current.cancelled)
|
|
163
|
+
return null;
|
|
164
|
+
const mimeType = current.recorder.mimeType || current.mimeType || current.chunks[0]?.type || 'audio/webm';
|
|
165
|
+
const audio = new Blob(current.chunks, { type: mimeType });
|
|
166
|
+
if (audio.size === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const startedAt = current.startedAt ?? current.stoppedAt ?? now();
|
|
169
|
+
const duration = await decodedMs(audio);
|
|
170
|
+
const endedAt = duration === null ? Math.max(startedAt, current.stoppedAt ?? now()) : startedAt + duration;
|
|
171
|
+
return { audio, mimeType, startedAt, endedAt, truncated: current.truncated };
|
|
172
|
+
},
|
|
173
|
+
cancel() {
|
|
174
|
+
const current = session;
|
|
175
|
+
if (opening)
|
|
176
|
+
cancelledWhileOpening = true;
|
|
177
|
+
if (!current)
|
|
178
|
+
return;
|
|
179
|
+
session = null;
|
|
180
|
+
current.cancelled = true;
|
|
181
|
+
current.chunks.length = 0;
|
|
182
|
+
finish(current, false);
|
|
183
|
+
release(current.stream);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createVoiceCapture, browserGetUserMedia, DEFAULT_MAX_VOICE_MS, VOICE_MIME_TYPES, VOICE_REFUSALS, type VoiceCapture, type VoiceRefusal, type VoiceCaptureOptions, type VoiceStart, type VoiceRecording, } from './capture.js';
|
|
2
|
+
export { alignSpeech, PIN_AFTER_MS, type SpeechSegment, type SpeechWord, type PointerSample, type PinMark, type AlignedSpeech } from './align.js';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Voice in a feedback session: the microphone capture and the alignment of what was said with where the pointer was.
|
|
2
|
+
export { createVoiceCapture, browserGetUserMedia, DEFAULT_MAX_VOICE_MS, VOICE_MIME_TYPES, VOICE_REFUSALS, } from './capture.js';
|
|
3
|
+
export { alignSpeech, PIN_AFTER_MS } from './align.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Api, Message, Model, Models, Tool } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { PreviewPage } from './page.js';
|
|
3
|
+
import { type PreviewAnswer } from '../format/index.js';
|
|
4
|
+
export declare const PREVIEW_SYSTEM_PROMPT = "You preview a requested change on a live web page, for a person who will approve or discard it. Nothing you do is saved.\nUse only the tools. Reference elements by the pinned ids (p1, p2...) or by ids from inspect (e1, e2...). Call inspect when you need to see the page.\nPrefer the smallest change that does what was asked, on the pinned elements. set_style takes plain CSS declarations (\"background-color: #2f9e44; color: #ffffff\").\nText shown as masked is private: never guess or rewrite it. When the change is done, answer with one short sentence saying what you changed.";
|
|
5
|
+
/** The page tools, as Pi tool declarations. */
|
|
6
|
+
export declare const PREVIEW_TOOLS: readonly Tool[];
|
|
7
|
+
export interface PreviewAgentOptions {
|
|
8
|
+
readonly models: Models;
|
|
9
|
+
readonly model: Model<Api>;
|
|
10
|
+
readonly page: PreviewPage;
|
|
11
|
+
/** What to preview, from the server's `browser_preview` call. */
|
|
12
|
+
readonly instructions: string;
|
|
13
|
+
/** Model turns per `say`, tool calls included. Default 8. */
|
|
14
|
+
readonly maxSteps?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface PreviewAgent {
|
|
17
|
+
/** Runs the loop on the first instructions (`say()` with no text) or on a follow-up ("darker"). Resolves with the final sentence. */
|
|
18
|
+
readonly say: (text?: string, signal?: AbortSignal) => Promise<{
|
|
19
|
+
readonly text: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly error: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** Everything sent to and received from the model, in order. */
|
|
24
|
+
readonly messages: () => readonly Message[];
|
|
25
|
+
/** The approved answer from the current page changes and the last sentence. */
|
|
26
|
+
readonly approve: () => PreviewAnswer;
|
|
27
|
+
/** Reverts the page and returns the discarded answer. */
|
|
28
|
+
readonly discard: () => PreviewAnswer;
|
|
29
|
+
}
|
|
30
|
+
/** The first message: the instructions and the pinned elements as the privacy policy names them. */
|
|
31
|
+
export declare function previewBrief(instructions: string, page: PreviewPage): string;
|
|
32
|
+
/** A preview subagent over one page. Several `say` calls continue the same conversation; they must not overlap. */
|
|
33
|
+
export declare function createPreviewAgent(options: PreviewAgentOptions): PreviewAgent;
|
|
34
|
+
//# sourceMappingURL=agent.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The browser preview subagent (FEEDBACK.md, "Preview"): a small tool-calling loop on Pi's model layer, in the page. The host gives
|
|
2
|
+
// it native Pi `Models` whose provider is its model gateway (`gatewayProvider` from `@hachej/boring-agent/gateway-provider`), so the page
|
|
3
|
+
// holds no key; this module never builds a provider itself. Tool calls are validated by Pi (`validateToolCall`) and executed by the
|
|
4
|
+
// preview page tools (./page.ts) only: there is no other tool, so a call to anything else is an error result. The loop is in memory
|
|
5
|
+
// on purpose: a preview is not saved, and a reload reverts the page anyway; the durable part is the server's `browser_preview` wait.
|
|
6
|
+
import { Type, validateToolCall } from '@earendil-works/pi-ai';
|
|
7
|
+
import { PREVIEW_LIMITS } from '../format/index.js';
|
|
8
|
+
export const PREVIEW_SYSTEM_PROMPT = `You preview a requested change on a live web page, for a person who will approve or discard it. Nothing you do is saved.
|
|
9
|
+
Use only the tools. Reference elements by the pinned ids (p1, p2...) or by ids from inspect (e1, e2...). Call inspect when you need to see the page.
|
|
10
|
+
Prefer the smallest change that does what was asked, on the pinned elements. set_style takes plain CSS declarations ("background-color: #2f9e44; color: #ffffff").
|
|
11
|
+
Text shown as masked is private: never guess or rewrite it. When the change is done, answer with one short sentence saying what you changed.`;
|
|
12
|
+
const element = Type.String({ minLength: 1, maxLength: 20, description: 'A pin id (p1...) or an id from inspect (e1...).' });
|
|
13
|
+
/** The page tools, as Pi tool declarations. */
|
|
14
|
+
export const PREVIEW_TOOLS = Object.freeze([
|
|
15
|
+
{ name: 'inspect', description: 'The masked outline of the application page with element ids, the pinned elements first, and a few computed styles.', parameters: Type.Object({}, { additionalProperties: false }) },
|
|
16
|
+
{ name: 'set_style', description: 'Set inline CSS properties on one element (colors, borders, spacing, fonts, sizes, display). No url(), no !important.', parameters: Type.Object({ element, css: Type.String({ minLength: 1, maxLength: 600 }) }, { additionalProperties: false }) },
|
|
17
|
+
{ name: 'set_text', description: 'Replace the visible text of one text-only element (a button label, a heading...).', parameters: Type.Object({ element, text: Type.String({ minLength: 1, maxLength: 200 }) }, { additionalProperties: false }) },
|
|
18
|
+
{ name: 'hide', description: 'Hide one element.', parameters: Type.Object({ element }, { additionalProperties: false }) },
|
|
19
|
+
{ name: 'show', description: 'Show again an element this preview hid.', parameters: Type.Object({ element }, { additionalProperties: false }) },
|
|
20
|
+
]);
|
|
21
|
+
const textOf = (message) => message.content.filter(part => part.type === 'text').map(part => part.text).join('').trim();
|
|
22
|
+
function run(page, call) {
|
|
23
|
+
const args = validateToolCall([...PREVIEW_TOOLS], call);
|
|
24
|
+
switch (call.name) {
|
|
25
|
+
case 'inspect': return { ok: true, message: page.inspect() };
|
|
26
|
+
case 'set_style': return page.setStyle(args['element'] ?? '', args['css'] ?? '');
|
|
27
|
+
case 'set_text': return page.setText(args['element'] ?? '', args['text'] ?? '');
|
|
28
|
+
case 'hide': return page.hide(args['element'] ?? '');
|
|
29
|
+
case 'show': return page.show(args['element'] ?? '');
|
|
30
|
+
default: return { ok: false, reason: `unknown tool ${call.name}` };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The first message: the instructions and the pinned elements as the privacy policy names them. */
|
|
34
|
+
export function previewBrief(instructions, page) {
|
|
35
|
+
const pins = page.pins.map(pin => `- ${pin.id}: ${pin.fallback}${pin.source ? ` [source ${pin.source}]` : ''}${pin.unplaced ? ` (not usable: ${pin.unplaced})` : ''}`);
|
|
36
|
+
return `Preview this change: ${instructions}\nPinned elements:\n${pins.join('\n') || '- none: call inspect to find the element'}`;
|
|
37
|
+
}
|
|
38
|
+
/** A preview subagent over one page. Several `say` calls continue the same conversation; they must not overlap. */
|
|
39
|
+
export function createPreviewAgent(options) {
|
|
40
|
+
const { models, model, page } = options;
|
|
41
|
+
const maxSteps = options.maxSteps ?? 8;
|
|
42
|
+
const messages = [];
|
|
43
|
+
let summary = '';
|
|
44
|
+
let running = false;
|
|
45
|
+
const say = async (text, signal) => {
|
|
46
|
+
if (running)
|
|
47
|
+
return { error: 'The preview is still working.' };
|
|
48
|
+
running = true;
|
|
49
|
+
try {
|
|
50
|
+
const content = messages.length === 0 ? previewBrief(options.instructions, page) + (text ? `\n${text}` : '') : text?.trim();
|
|
51
|
+
if (!content)
|
|
52
|
+
return { error: 'Say what to change.' };
|
|
53
|
+
messages.push({ role: 'user', content, timestamp: Date.now() });
|
|
54
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
return { error: 'Stopped.' };
|
|
57
|
+
const reply = await models.complete(model, { systemPrompt: PREVIEW_SYSTEM_PROMPT, messages: [...messages], tools: [...PREVIEW_TOOLS] }, signal ? { signal } : {});
|
|
58
|
+
messages.push(reply);
|
|
59
|
+
if (reply.stopReason === 'error' || reply.stopReason === 'aborted')
|
|
60
|
+
return { error: reply.errorMessage ?? 'The model could not answer.' };
|
|
61
|
+
const calls = reply.content.filter((part) => part.type === 'toolCall');
|
|
62
|
+
if (!calls.length) {
|
|
63
|
+
summary = textOf(reply).slice(0, PREVIEW_LIMITS.summary);
|
|
64
|
+
return { text: summary };
|
|
65
|
+
}
|
|
66
|
+
for (const call of calls) {
|
|
67
|
+
let outcome;
|
|
68
|
+
try {
|
|
69
|
+
outcome = run(page, call);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
outcome = { ok: false, reason: error instanceof Error ? error.message : 'invalid call' };
|
|
73
|
+
}
|
|
74
|
+
const result = { role: 'toolResult', toolCallId: call.id, toolName: call.name, isError: !outcome.ok,
|
|
75
|
+
content: [{ type: 'text', text: outcome.ok ? outcome.message : `Refused: ${outcome.reason}` }], timestamp: Date.now() };
|
|
76
|
+
messages.push(result);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { error: `The preview stopped after ${maxSteps} steps.` };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
running = false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
say,
|
|
87
|
+
messages: () => [...messages],
|
|
88
|
+
approve: () => ({ kind: 'approved', summary: summary || 'Previewed on the page.', changes: page.changes().slice(0, PREVIEW_LIMITS.changes) }),
|
|
89
|
+
discard: () => { page.revert(); return { kind: 'discarded' }; },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createPreviewPage, parseDeclarations, PREVIEW_STYLE_PROPERTIES, type PreviewPage, type PreviewPageOptions, type PreviewPin, type PreviewOutcome } from './page.js';
|
|
2
|
+
export { createPreviewAgent, previewBrief, PREVIEW_SYSTEM_PROMPT, PREVIEW_TOOLS, type PreviewAgent, type PreviewAgentOptions } from './agent.js';
|
|
3
|
+
export { createPreviewSession, pendingBrowserTasks, BROWSER_PREVIEW_TOOL, type PreviewSession, type PreviewSessionOptions, type PreviewSnapshot, type PreviewStatus, type PendingBrowserTask, type PreviewAnswerOutcome, } from './session.js';
|
|
4
|
+
export type { PreviewAnswer, PreviewChange } from '../format/index.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @hachej/boring-feedback/preview (FEEDBACK.md, "Preview"): the browser side of `browser_preview`. The page tools over the live
|
|
2
|
+
// application root (privacy policy in, logged and revertible changes out), the in-page Pi subagent that drives them through the
|
|
3
|
+
// host's model gateway, and the session the preview banner shows. Imports `./page`, `./format` and Pi's model layer only.
|
|
4
|
+
export { createPreviewPage, parseDeclarations, PREVIEW_STYLE_PROPERTIES } from './page.js';
|
|
5
|
+
export { createPreviewAgent, previewBrief, PREVIEW_SYSTEM_PROMPT, PREVIEW_TOOLS } from './agent.js';
|
|
6
|
+
export { createPreviewSession, pendingBrowserTasks, BROWSER_PREVIEW_TOOL, } from './session.js';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type PrivacyPolicy } from '../page/index.js';
|
|
2
|
+
import type { PreviewChange } from '../format/index.js';
|
|
3
|
+
/** CSS properties a preview may set inline. Layout-breaking or resource-loading ones (`content`, `cursor`, `list-style-image`...) are not here. */
|
|
4
|
+
export declare const PREVIEW_STYLE_PROPERTIES: readonly string[];
|
|
5
|
+
export type PreviewOutcome = {
|
|
6
|
+
readonly ok: true;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly reason: string;
|
|
11
|
+
};
|
|
12
|
+
export interface PreviewPin {
|
|
13
|
+
/** `p1`, `p2`... in the report's anchor order. */
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly fallback: string;
|
|
16
|
+
readonly source?: string;
|
|
17
|
+
/** Why the pin is not on this page now (missing, ambiguous...); the pin cannot be changed then. */
|
|
18
|
+
readonly unplaced?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PreviewPage {
|
|
21
|
+
readonly pins: readonly PreviewPin[];
|
|
22
|
+
/** The masked outline of the application root with element ids, pins first. */
|
|
23
|
+
readonly inspect: () => string;
|
|
24
|
+
readonly setStyle: (element: string, css: string) => PreviewOutcome;
|
|
25
|
+
readonly setText: (element: string, text: string) => PreviewOutcome;
|
|
26
|
+
readonly hide: (element: string) => PreviewOutcome;
|
|
27
|
+
readonly show: (element: string) => PreviewOutcome;
|
|
28
|
+
/** Net changes so far (first `from`, last `to`; no-ops dropped), in the order they were first made. */
|
|
29
|
+
readonly changes: () => readonly PreviewChange[];
|
|
30
|
+
/** Restores every touched element's original `style` attribute and text nodes, newest first. Idempotent. */
|
|
31
|
+
readonly revert: () => void;
|
|
32
|
+
}
|
|
33
|
+
export interface PreviewPageOptions {
|
|
34
|
+
/** The application root. Nothing outside it can be referenced. */
|
|
35
|
+
readonly root: Element;
|
|
36
|
+
readonly policy: PrivacyPolicy;
|
|
37
|
+
/** The feedback report's anchors; `app.element@1` ones placed `exact` or `moved` become pins. */
|
|
38
|
+
readonly anchors?: readonly unknown[];
|
|
39
|
+
}
|
|
40
|
+
/** `prop: value; prop: value`, each checked. Returns the declarations or the reason for refusing. */
|
|
41
|
+
export declare function parseDeclarations(css: string): {
|
|
42
|
+
readonly ok: true;
|
|
43
|
+
readonly declarations: readonly (readonly [string, string])[];
|
|
44
|
+
} | {
|
|
45
|
+
readonly ok: false;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
/** Builds the preview tools over the live application root. Pins are placed now, against a fresh masked snapshot. */
|
|
49
|
+
export declare function createPreviewPage(options: PreviewPageOptions): Promise<PreviewPage>;
|
|
50
|
+
//# sourceMappingURL=page.d.ts.map
|