effing-use 0.1.1 → 0.2.1

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.
@@ -0,0 +1,208 @@
1
+ import type { Page } from "playwright";
2
+ import { getConsoleLogs, getNetworkLogs } from "./session.js";
3
+
4
+ export type Effect = {
5
+ urlChanged: boolean;
6
+ urlBefore?: string;
7
+ urlAfter?: string;
8
+ domChanged: string[];
9
+ consoleErrors: string[];
10
+ dialog?: string;
11
+ networkFailures?: string[];
12
+ mustObserve: boolean;
13
+ };
14
+
15
+ const EFFECT_MAX_CHARS_DEFAULT = 800;
16
+
17
+ function capEffect(effect: Effect, maxChars: number): Effect {
18
+ // Hard cap 800 chars total — truncate domChanged/consoleErrors/networkFailures
19
+ let total = JSON.stringify(effect).length;
20
+ if (total <= maxChars) return effect;
21
+ // Trim arrays until under cap
22
+ while (total > maxChars && effect.domChanged.length > 0) {
23
+ effect.domChanged.pop();
24
+ total = JSON.stringify(effect).length;
25
+ }
26
+ while (total > maxChars && effect.consoleErrors.length > 0) {
27
+ effect.consoleErrors.pop();
28
+ total = JSON.stringify(effect).length;
29
+ }
30
+ while (total > maxChars && (effect.networkFailures?.length ?? 0) > 0) {
31
+ effect.networkFailures!.pop();
32
+ total = JSON.stringify(effect).length;
33
+ }
34
+ return effect;
35
+ }
36
+
37
+ export async function collectEffect(
38
+ page: Page,
39
+ sessionId: string,
40
+ urlBefore: string,
41
+ domBeforeHash: string | null,
42
+ consoleBeforeLen: number,
43
+ networkBeforeLen: number,
44
+ maxChars = EFFECT_MAX_CHARS_DEFAULT,
45
+ ): Promise<Effect> {
46
+ const urlAfter = page.url();
47
+ const urlChanged = urlBefore !== urlAfter;
48
+
49
+ // domChanged: line-level diff of body.innerText (50k cap). v0.2 only
50
+ // compared the first 200 chars, so any below-fold change looked like a no-op
51
+ // (and fed the failure contract wrong "no DOM change" signals).
52
+ let domChanged: string[] = [];
53
+ try {
54
+ const currentDom = await page.evaluate(
55
+ () => document.body?.innerText?.slice(0, 50000) ?? "",
56
+ );
57
+ if (domBeforeHash !== null && currentDom !== domBeforeHash) {
58
+ const before = new Set(
59
+ domBeforeHash
60
+ .split("\n")
61
+ .map((l) => l.trim())
62
+ .filter(Boolean),
63
+ );
64
+ const after = currentDom
65
+ .split("\n")
66
+ .map((l) => l.trim())
67
+ .filter(Boolean);
68
+ const afterSet = new Set(after);
69
+ const changed = after
70
+ .filter((l) => !before.has(l))
71
+ .slice(0, 5)
72
+ .map((l) => l.slice(0, 140));
73
+ const removed = [...before]
74
+ .filter((l) => !afterSet.has(l))
75
+ .slice(0, 3)
76
+ .map((l) => `- ${l.slice(0, 138)}`);
77
+ domChanged = [...changed, ...removed].slice(0, 5);
78
+ }
79
+ } catch {
80
+ // ignore
81
+ }
82
+
83
+ const consoleLogs = getConsoleLogs(sessionId);
84
+ const newConsole = consoleLogs.slice(consoleBeforeLen);
85
+ const consoleErrors = newConsole
86
+ .filter((c) => c.type === "error")
87
+ .slice(-3)
88
+ .map((c) => c.text.slice(0, 200));
89
+
90
+ const networkLogs = getNetworkLogs(sessionId);
91
+ const newNetwork = networkLogs.slice(networkBeforeLen);
92
+ const networkFailures = newNetwork
93
+ .filter((r) => r.status >= 400)
94
+ .slice(-3)
95
+ .map((r) => `${r.method} ${r.url.slice(0, 80)} → ${r.status}`);
96
+
97
+ // dialog detection: check if page has dialog handler armed? For now undefined
98
+ const effect: Effect = {
99
+ urlChanged,
100
+ ...(urlChanged ? { urlBefore, urlAfter } : {}),
101
+ domChanged: domChanged.slice(0, 5),
102
+ consoleErrors: consoleErrors.slice(0, 3),
103
+ ...(networkFailures.length ? { networkFailures } : {}),
104
+ mustObserve: false,
105
+ };
106
+
107
+ return capEffect(effect, maxChars);
108
+ }
109
+
110
+ const EXPECT_MAX_PATTERN = 200;
111
+
112
+ export function parseExpect(
113
+ expect: string,
114
+ ): { kind: string; pattern: string } | null {
115
+ const s = expect.trim();
116
+ let parsed: { kind: string; pattern: string } | null = null;
117
+ if (s.startsWith("url~")) parsed = { kind: "url", pattern: s.slice(4) };
118
+ else if (s.startsWith("text~"))
119
+ parsed = { kind: "text", pattern: s.slice(5) };
120
+ else if (s.startsWith("visible="))
121
+ parsed = { kind: "visible", pattern: s.slice(8) };
122
+ else if (s.startsWith("gone="))
123
+ parsed = { kind: "gone", pattern: s.slice(5) };
124
+ else return null;
125
+ if (parsed.pattern.length > EXPECT_MAX_PATTERN) return null;
126
+ // Validate regex patterns early for url~/text~
127
+ if (parsed.kind === "url" || parsed.kind === "text") {
128
+ try {
129
+ new RegExp(parsed.pattern);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ return parsed;
135
+ }
136
+
137
+ export async function evaluateExpect(
138
+ page: Page,
139
+ expect: string,
140
+ ): Promise<{ ok: boolean; message?: string }> {
141
+ const parsed = parseExpect(expect);
142
+ if (!parsed) {
143
+ return {
144
+ ok: false,
145
+ message: `Bad expect syntax "${expect}". Use url~<regex> | text~<regex> | visible=<css> | gone=<css>`,
146
+ };
147
+ }
148
+ try {
149
+ switch (parsed.kind) {
150
+ case "url": {
151
+ const re = new RegExp(parsed.pattern);
152
+ const url = page.url();
153
+ if (re.test(url)) return { ok: true };
154
+ return {
155
+ ok: false,
156
+ message: `url "${url}" does not match /${parsed.pattern}/`,
157
+ };
158
+ }
159
+ case "text": {
160
+ const text = await page.innerText("body").catch(() => "");
161
+ const slice = text.slice(0, 50000);
162
+ const re = new RegExp(parsed.pattern);
163
+ if (re.test(slice)) return { ok: true };
164
+ return {
165
+ ok: false,
166
+ message: `page text does not match /${parsed.pattern}/`,
167
+ };
168
+ }
169
+ case "visible": {
170
+ const loc = page.locator(parsed.pattern);
171
+ const count = await loc.count().catch(() => 0);
172
+ if (count === 0)
173
+ return {
174
+ ok: false,
175
+ message: `selector "${parsed.pattern}" not found`,
176
+ };
177
+ const visible = await loc
178
+ .first()
179
+ .isVisible()
180
+ .catch(() => false);
181
+ if (visible) return { ok: true };
182
+ return {
183
+ ok: false,
184
+ message: `selector "${parsed.pattern}" not visible`,
185
+ };
186
+ }
187
+ case "gone": {
188
+ const loc = page.locator(parsed.pattern);
189
+ const count = await loc.count().catch(() => 0);
190
+ if (count === 0) return { ok: true };
191
+ const visible = await loc
192
+ .first()
193
+ .isVisible()
194
+ .catch(() => false);
195
+ if (!visible) return { ok: true };
196
+ return {
197
+ ok: false,
198
+ message: `selector "${parsed.pattern}" still visible`,
199
+ };
200
+ }
201
+ default:
202
+ return { ok: false, message: `Unknown expect kind "${parsed.kind}"` };
203
+ }
204
+ } catch (e) {
205
+ const msg = e instanceof Error ? e.message : String(e);
206
+ return { ok: false, message: `expect evaluation failed: ${msg}` };
207
+ }
208
+ }
@@ -0,0 +1,237 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export type Fingerprint = {
4
+ role: string;
5
+ accessibleName: string;
6
+ textHash: string;
7
+ box: { x: number; y: number; w: number; h: number };
8
+ pathHash: string;
9
+ };
10
+
11
+ /** Raw per-element data as extracted inside the page (before normalization). */
12
+ export type RawFp = {
13
+ role: string;
14
+ accessibleName: string;
15
+ text: string;
16
+ pathHash: string;
17
+ box: { x: number; y: number; w: number; h: number };
18
+ };
19
+
20
+ /**
21
+ * Browser-side fingerprint extractor — SINGLE SOURCE OF TRUTH shared by
22
+ * snapshot registration, stale-ref validation and rebind search. Serialized
23
+ * into the page as a string so it never closes over module scope.
24
+ *
25
+ * v0.2 bug fixed here: fingerprints used tag-name roles ("input"), aria-label
26
+ * only, and own textContent (always "" for form controls), so every bare
27
+ * checkbox shared one identity — rebind then clicked the wrong element
28
+ * (TodoMVC: stale item toggle rebound to toggle-all). Now:
29
+ * - role: implicit ARIA role (input[type=checkbox] → "checkbox")
30
+ * - accessibleName: aria-label → <label> → alt → title → placeholder → text
31
+ * - textHash: own text, else contextual text (nearest li/label/td/row), else
32
+ * placeholder — so item toggles carry their todo text
33
+ */
34
+ export const RAW_FP_SCRIPT = `(() => {
35
+ const SEL = "button, a, input, select, textarea, [role=button], [tabindex]";
36
+ const norm = (s) => (s || "").trim().replace(/\\s+/g, " ").slice(0, 80);
37
+ const implicitRole = (el) => {
38
+ const r = el.getAttribute("role");
39
+ if (r) return r;
40
+ const tag = el.tagName.toLowerCase();
41
+ if (tag === "a") return el.hasAttribute("href") ? "link" : "generic";
42
+ if (tag === "button") return "button";
43
+ if (tag === "textarea") return "textbox";
44
+ if (tag === "select") return "combobox";
45
+ if (tag === "summary") return "button";
46
+ if (tag === "input") {
47
+ const t = (el.getAttribute("type") || "text").toLowerCase();
48
+ if (t === "checkbox") return "checkbox";
49
+ if (t === "radio") return "radio";
50
+ if (t === "submit" || t === "button" || t === "reset" || t === "image") return "button";
51
+ if (t === "range") return "slider";
52
+ if (t === "hidden") return "hidden";
53
+ return "textbox";
54
+ }
55
+ return tag;
56
+ };
57
+ const accName = (el) => {
58
+ const aria = el.getAttribute("aria-label");
59
+ if (aria && aria.trim()) return norm(aria);
60
+ if (el.labels && el.labels.length > 0) {
61
+ const t = norm(el.labels[0].textContent);
62
+ if (t) return t;
63
+ }
64
+ const alt = el.getAttribute("alt");
65
+ if (alt && alt.trim()) return norm(alt);
66
+ const title = el.getAttribute("title");
67
+ if (title && title.trim()) return norm(title);
68
+ const ph = el.getAttribute("placeholder");
69
+ if (ph && ph.trim()) return norm(ph);
70
+ return norm(el.textContent);
71
+ };
72
+ const textOf = (el) => {
73
+ const own = norm(el.textContent);
74
+ if (own) return own;
75
+ const ctx = el.closest && el.closest("li, label, td, [role=listitem], [role=row]");
76
+ if (ctx) {
77
+ const t = norm(ctx.textContent);
78
+ if (t) return t;
79
+ }
80
+ return norm(el.getAttribute("placeholder"));
81
+ };
82
+ const pathOf = (el) => {
83
+ const parts = [];
84
+ let cur = el;
85
+ while (cur && parts.length < 6) {
86
+ parts.push(cur.tagName.toLowerCase());
87
+ cur = cur.parentElement;
88
+ }
89
+ return parts.join(">");
90
+ };
91
+ const els = Array.prototype.slice.call(document.querySelectorAll(SEL), 0, 200);
92
+ return els.map((el) => {
93
+ const rect = el.getBoundingClientRect();
94
+ return {
95
+ role: implicitRole(el),
96
+ accessibleName: accName(el),
97
+ text: textOf(el),
98
+ pathHash: pathOf(el),
99
+ box: {
100
+ x: Math.round(rect.x),
101
+ y: Math.round(rect.y),
102
+ w: Math.round(rect.width),
103
+ h: Math.round(rect.height)
104
+ }
105
+ };
106
+ });
107
+ })()`;
108
+
109
+ /** Extract fingerprints for every snapshot-visible element, as e-refs. */
110
+ export async function extractFingerprints(
111
+ page: Page,
112
+ ): Promise<Array<{ ref: string; fp: Fingerprint }>> {
113
+ const raw = (await page
114
+ .evaluate(RAW_FP_SCRIPT)
115
+ .catch(() => [] as RawFp[])) as RawFp[];
116
+ return raw.map((r, i) => ({ ref: `e${i}`, fp: buildFingerprint(r) }));
117
+ }
118
+
119
+ function hashText(s: string): string {
120
+ return s.trim().replace(/\s+/g, " ").slice(0, 80);
121
+ }
122
+
123
+ function boxKey(box: { x: number; y: number; w: number; h: number }): string {
124
+ return `${Math.round(box.x)}:${Math.round(box.y)}:${Math.round(box.w)}:${Math.round(box.h)}`;
125
+ }
126
+
127
+ // Per-session registry: sessionId -> Map<ref, Fingerprint>
128
+ const registry = new Map<string, Map<string, Fingerprint>>();
129
+
130
+ export function registerFingerprints(
131
+ sessionId: string,
132
+ entries: Array<{ ref: string; fp: Fingerprint }>,
133
+ ): void {
134
+ let m = registry.get(sessionId);
135
+ if (!m) {
136
+ m = new Map();
137
+ registry.set(sessionId, m);
138
+ }
139
+ // Never recycle within session — but allow overwrite on fresh snapshot
140
+ for (const { ref, fp } of entries) m.set(ref, fp);
141
+ }
142
+
143
+ export function getFingerprint(
144
+ sessionId: string,
145
+ ref: string,
146
+ ): Fingerprint | undefined {
147
+ return registry.get(sessionId)?.get(ref);
148
+ }
149
+
150
+ export function clearRegistry(sessionId: string): void {
151
+ registry.delete(sessionId);
152
+ }
153
+
154
+ export type RebindCandidate = { index: number; fp: Fingerprint };
155
+
156
+ /**
157
+ * Conservative rebind ladder (P0 fix): pick an element only when the match is
158
+ * UNAMBIGUOUS — never guess among equals.
159
+ *
160
+ * 1. loose match (role + accessibleName + textHash) — unique → take it
161
+ * 2. multiple loose → narrow by identical pathHash; still >1 → give up
162
+ * 3. no loose → fuzzy (role + name/text contains) under the same rules
163
+ *
164
+ * Returns the chosen candidate index, or null when zero or more than one
165
+ * candidate survives — callers turn null into E_STALE (fail loud) instead of
166
+ * clicking the wrong element.
167
+ */
168
+ export function chooseRebindIndex(
169
+ expected: Fingerprint,
170
+ candidates: RebindCandidate[],
171
+ ): number | null {
172
+ const narrow = (pool: RebindCandidate[]): number | null => {
173
+ if (pool.length === 1) return pool[0].index;
174
+ if (pool.length > 1 && expected.pathHash) {
175
+ const byPath = pool.filter((c) => c.fp.pathHash === expected.pathHash);
176
+ if (byPath.length === 1) return byPath[0].index;
177
+ }
178
+ return null;
179
+ };
180
+ const loose = candidates.filter((c) => looseMatch(expected, c.fp));
181
+ if (loose.length > 0) return narrow(loose);
182
+ const fuzzy = candidates.filter((c) => fuzzyMatch(expected, c.fp));
183
+ return narrow(fuzzy);
184
+ }
185
+
186
+ export function fingerprintEquals(a: Fingerprint, b: Fingerprint): boolean {
187
+ return (
188
+ a.role === b.role &&
189
+ a.accessibleName === b.accessibleName &&
190
+ a.textHash === b.textHash &&
191
+ boxKey(a.box) === boxKey(b.box) &&
192
+ a.pathHash === b.pathHash
193
+ );
194
+ }
195
+
196
+ // Loose match for rebind: role + name + textHash (ignore box/path)
197
+ export function looseMatch(a: Fingerprint, b: Fingerprint): boolean {
198
+ return (
199
+ a.role === b.role &&
200
+ a.accessibleName === b.accessibleName &&
201
+ a.textHash === b.textHash
202
+ );
203
+ }
204
+
205
+ export function fuzzyMatch(a: Fingerprint, b: Fingerprint): boolean {
206
+ // fuzzy: role must match, and either name or textHash matches (case-insensitive contains)
207
+ if (a.role !== b.role) return false;
208
+ const an = a.accessibleName.toLowerCase();
209
+ const bn = b.accessibleName.toLowerCase();
210
+ const at = a.textHash.toLowerCase();
211
+ const bt = b.textHash.toLowerCase();
212
+ const nameMatch = an && bn && (an.includes(bn) || bn.includes(an));
213
+ const textMatch = at && bt && (at.includes(bt) || bt.includes(at));
214
+ return Boolean(nameMatch || textMatch);
215
+ }
216
+
217
+ // Build fingerprint from evaluated element data
218
+ export function buildFingerprint(data: {
219
+ role: string;
220
+ accessibleName: string;
221
+ text: string;
222
+ box: { x: number; y: number; w: number; h: number };
223
+ pathHash: string;
224
+ }): Fingerprint {
225
+ return {
226
+ role: data.role || "generic",
227
+ accessibleName: (data.accessibleName || "").trim().slice(0, 80),
228
+ textHash: hashText(data.text || ""),
229
+ box: {
230
+ x: Math.round(data.box.x),
231
+ y: Math.round(data.box.y),
232
+ w: Math.round(data.box.w),
233
+ h: Math.round(data.box.h),
234
+ },
235
+ pathHash: data.pathHash || "",
236
+ };
237
+ }
@@ -0,0 +1,118 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+ import type { RecordStep } from "./record.js";
5
+
6
+ const IRREVERSIBLE = /submit|send|buy|pay|delete|remove|publish|post|confirm/i;
7
+
8
+ export function isIrreversible(step: RecordStep): boolean {
9
+ const hay = [step.op, step.target ?? "", step.value ?? ""].join(" ");
10
+ return IRREVERSIBLE.test(hay);
11
+ }
12
+
13
+ function selectorFor(step: RecordStep): string {
14
+ if (step.resolvedSelector) return step.resolvedSelector;
15
+ if (step.target) return step.target;
16
+ return "";
17
+ }
18
+
19
+ function sanitizeMacroName(s: string): string {
20
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "macro";
21
+ }
22
+
23
+ export async function compileMacro(
24
+ config: Config,
25
+ name: string,
26
+ steps: RecordStep[],
27
+ ): Promise<{ tsPath: string; mdPath: string; warnings: string[] }> {
28
+ const safe = sanitizeMacroName(name);
29
+ const dir = join(config.outputDir, "macros");
30
+ await mkdir(dir, { recursive: true });
31
+
32
+ const warnings: string[] = [];
33
+ const irreversibleSteps = steps.filter(isIrreversible);
34
+ if (irreversibleSteps.length > 0) {
35
+ // Steps are human-facing here — 1-indexed, matching the .md step list
36
+ warnings.push(
37
+ `${irreversibleSteps.length} step(s) require approval: steps ${irreversibleSteps
38
+ .map((s) => s.seq + 1)
39
+ .join(", ")}`,
40
+ );
41
+ }
42
+
43
+ // TS macro
44
+ const tsLines: string[] = [
45
+ `// macro: ${name} — generated by effing-use compile`,
46
+ `// steps: ${steps.length}, requiresApproval: ${irreversibleSteps.length > 0}`,
47
+ `import type { Page } from "playwright";`,
48
+ ``,
49
+ `export async function run(page: Page) {`,
50
+ ];
51
+ for (const s of steps) {
52
+ const sel = selectorFor(s);
53
+ const val = (s.value ?? "").slice(0, 500);
54
+ const flag = isIrreversible(s) ? " // requiresApproval" : "";
55
+ // Recordings made before stable-selector resolution store session-scoped
56
+ // e-refs — flag them so nobody runs the .ts standalone expecting it to work.
57
+ const staleNote = /^e\d+$/.test(sel)
58
+ ? " /* session-scoped e-ref — not a standalone selector */"
59
+ : "";
60
+ switch (s.op) {
61
+ case "click":
62
+ tsLines.push(
63
+ ` await page.locator(${JSON.stringify(sel)}).click();${flag}${staleNote}`,
64
+ );
65
+ break;
66
+ case "fill":
67
+ tsLines.push(
68
+ ` await page.locator(${JSON.stringify(sel)}).fill(${JSON.stringify(val)});${flag}${staleNote}`,
69
+ );
70
+ break;
71
+ case "press":
72
+ tsLines.push(
73
+ ` await page.keyboard.press(${JSON.stringify(val || s.target || "Enter")});${flag}`,
74
+ );
75
+ break;
76
+ case "goto":
77
+ case "open":
78
+ tsLines.push(
79
+ ` await page.goto(${JSON.stringify(val || sel)});${flag}`,
80
+ );
81
+ break;
82
+ default:
83
+ tsLines.push(
84
+ ` // ${JSON.stringify(s.op)} ${JSON.stringify(sel)} ${JSON.stringify(val)}${flag}`,
85
+ );
86
+ break;
87
+ }
88
+ }
89
+ tsLines.push(`}`);
90
+ tsLines.push(``);
91
+
92
+ const tsPath = join(dir, `${safe}.ts`);
93
+ await writeFile(tsPath, tsLines.join("\n"), "utf-8");
94
+
95
+ // SKILL.md
96
+ const mdLines: string[] = [
97
+ `# Skill: ${safe}`,
98
+ ``,
99
+ `When to use: replay this ${steps.length}-step flow via \`browser_act action=replay value=${safe}\`.`,
100
+ ``,
101
+ `## Steps`,
102
+ ...steps.map((s) =>
103
+ `${s.seq + 1}. ${s.op} ${s.target ?? ""} ${s.value ?? ""}${isIrreversible(s) ? " (requires approval)" : ""}`.trim(),
104
+ ),
105
+ ``,
106
+ `## Verification`,
107
+ irreversibleSteps.length > 0
108
+ ? `This macro contains irreversible steps — replay will pause with E_APPROVAL_REQUIRED until approve:true.`
109
+ : `No approval-gated steps.`,
110
+ ``,
111
+ `## Inputs`,
112
+ `None in v1 — values are baked in. Parameterize in a future version if needed.`,
113
+ ];
114
+ const mdPath = join(dir, `${safe}.md`);
115
+ await writeFile(mdPath, mdLines.join("\n"), "utf-8");
116
+
117
+ return { tsPath, mdPath, warnings };
118
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Config } from "../config.js";
4
+ import type { Fingerprint } from "./identity.js";
5
+
6
+ export type RecordStep = {
7
+ seq: number;
8
+ op: string;
9
+ target?: string;
10
+ value?: string;
11
+ targetFingerprint?: Fingerprint;
12
+ resolvedSelector?: string;
13
+ expect?: string;
14
+ valueRedacted?: boolean;
15
+ };
16
+
17
+ type Recording = {
18
+ name: string;
19
+ createdAt: string;
20
+ steps: RecordStep[];
21
+ };
22
+
23
+ const activeRecordings = new Map<
24
+ string,
25
+ { name: string; steps: RecordStep[] }
26
+ >();
27
+
28
+ const SECRET_HINT = /password|otp|secret|token|pin|ssn/i;
29
+
30
+ function isSecretField(
31
+ fp?: Fingerprint,
32
+ target?: string,
33
+ value?: string,
34
+ ): boolean {
35
+ const hay = [fp?.accessibleName ?? "", fp?.role ?? "", target ?? ""].join(
36
+ " ",
37
+ );
38
+ if (SECRET_HINT.test(hay)) return true;
39
+ // also check input type=password via target string
40
+ if (/type\s*=\s*password/i.test(target ?? "")) return true;
41
+ return false;
42
+ }
43
+
44
+ export function isRecording(sessionId: string): boolean {
45
+ return activeRecordings.has(sessionId);
46
+ }
47
+
48
+ export function startRecording(sessionId: string, name: string): void {
49
+ activeRecordings.set(sessionId, { name, steps: [] });
50
+ }
51
+
52
+ export function stopRecording(
53
+ sessionId: string,
54
+ ): { name: string; steps: RecordStep[] } | null {
55
+ const r = activeRecordings.get(sessionId);
56
+ if (!r) return null;
57
+ activeRecordings.delete(sessionId);
58
+ return r;
59
+ }
60
+
61
+ export function captureStep(
62
+ sessionId: string,
63
+ step: Omit<RecordStep, "seq">,
64
+ config: Config,
65
+ ): void {
66
+ const rec = activeRecordings.get(sessionId);
67
+ if (!rec) return;
68
+ const seq = rec.steps.length;
69
+ let value = step.value;
70
+ let valueRedacted = false;
71
+ if (
72
+ config.recordRedact &&
73
+ value &&
74
+ isSecretField(step.targetFingerprint, step.target, value)
75
+ ) {
76
+ value = "«redacted»";
77
+ valueRedacted = true;
78
+ }
79
+ rec.steps.push({
80
+ seq,
81
+ ...step,
82
+ value,
83
+ ...(valueRedacted ? { valueRedacted: true } : {}),
84
+ });
85
+ }
86
+
87
+ function sanitizeName(s: string): string {
88
+ return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "recording";
89
+ }
90
+
91
+ export async function saveRecording(
92
+ config: Config,
93
+ name: string,
94
+ steps: RecordStep[],
95
+ ): Promise<string> {
96
+ const safe = sanitizeName(name);
97
+ const dir = join(config.outputDir, "recordings");
98
+ await mkdir(dir, { recursive: true });
99
+ const path = join(dir, `${safe}.json`);
100
+ const rec: Recording = {
101
+ name: safe,
102
+ createdAt: new Date().toISOString(),
103
+ steps,
104
+ };
105
+ await writeFile(path, JSON.stringify(rec, null, 2), "utf-8");
106
+ return path;
107
+ }
108
+
109
+ export async function loadRecording(
110
+ config: Config,
111
+ name: string,
112
+ ): Promise<Recording> {
113
+ const safe = sanitizeName(name);
114
+ const path = join(config.outputDir, "recordings", `${safe}.json`);
115
+ const raw = await readFile(path, "utf-8");
116
+ return JSON.parse(raw) as Recording;
117
+ }
118
+
119
+ export function getActiveName(sessionId: string): string | null {
120
+ return activeRecordings.get(sessionId)?.name ?? null;
121
+ }
122
+
123
+ // ---------------------------------------------------------------- replay cursor
124
+ // Plan §6.3: pause AT an approval-gated step and resume with approve:true —
125
+ // the cursor keeps the benign prefix from running twice (non-idempotent steps).
126
+ const replayCursors = new Map<string, number>();
127
+
128
+ function cursorKey(sessionId: string, name: string): string {
129
+ return `${sessionId}:${name}`;
130
+ }
131
+
132
+ export function getReplayCursor(
133
+ sessionId: string,
134
+ name: string,
135
+ ): number | undefined {
136
+ return replayCursors.get(cursorKey(sessionId, name));
137
+ }
138
+
139
+ export function setReplayCursor(
140
+ sessionId: string,
141
+ name: string,
142
+ step: number,
143
+ ): void {
144
+ replayCursors.set(cursorKey(sessionId, name), step);
145
+ }
146
+
147
+ export function clearReplayCursor(sessionId: string, name: string): void {
148
+ replayCursors.delete(cursorKey(sessionId, name));
149
+ }