@foldspace_npm/harness 0.1.30 → 0.1.32
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/CLAUDE.md +42 -11
- package/README.md +25 -2
- package/bin/ask.mjs +16 -145
- package/bin/attach.mjs +2 -0
- package/bin/cli.mjs +22 -0
- package/bin/inject.mjs +2 -14
- package/bin/record.mjs +412 -0
- package/package.json +1 -1
- package/src/attach-daemon.mjs +18 -0
- package/src/badge-auto.mjs +38 -1
- package/src/cdp-client.mjs +7 -1
- package/src/chrome-binary.mjs +28 -0
- package/src/cli-help.mjs +1 -0
- package/src/cli-registry.mjs +53 -3
- package/src/init.mjs +170 -16
- package/src/panel-driver.mjs +283 -0
- package/src/session-events.mjs +1 -0
- package/src/upgrade.mjs +87 -1
- package/src/video/README.md +29 -0
- package/src/video/assets/foldspace-mark.svg +27 -0
- package/src/video/assets/logo-dark.svg +68 -0
- package/src/video/assets/logo-light.svg +56 -0
- package/src/video/brand.mjs +46 -0
- package/src/video/compose.mjs +476 -0
- package/src/video/composer/composer.mjs +554 -0
- package/src/video/composer/index.html +13 -0
- package/src/video/fonts/OFL.txt +93 -0
- package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
- package/src/video/fonts/Poppins-Regular.woff2 +0 -0
- package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
- package/src/video/recorder.mjs +331 -0
- package/src/video/scenes.mjs +253 -0
- package/src/video/timeline.mjs +726 -0
- package/src/video/vendor/mp4-muxer.LICENSE +21 -0
- package/src/video/vendor/mp4-muxer.min.mjs +5 -0
- package/templates/agent-starter/AGENTS.md +24 -0
- package/templates/agent-starter/README.md +5 -0
- package/templates/agent-starter/claude-settings.json +1 -0
- package/templates/agent-starter/gitignore +3 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +235 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Recording one scene of the share video: a fresh conversation, the question
|
|
2
|
+
// typed key by key, the agent thinking and answering, a few seconds of the
|
|
3
|
+
// settled answer - captured with Chrome's own screencast.
|
|
4
|
+
//
|
|
5
|
+
// Page.startScreencast runs inside Chrome, so it needs no macOS Screen
|
|
6
|
+
// Recording permission and captures only the page, never the desktop. Chrome
|
|
7
|
+
// sends a picture when the page repaints; each one is written to disk as it
|
|
8
|
+
// arrives and acknowledged at once, or Chrome stops sending.
|
|
9
|
+
// Never Page.bringToFront: the human may be working in another app.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { createSettleTracker, looksPending, squash } from "../ask-core.mjs";
|
|
15
|
+
import { sleep } from "../cdp-client.mjs";
|
|
16
|
+
import {
|
|
17
|
+
clickNewChat,
|
|
18
|
+
messageInNewChat,
|
|
19
|
+
openComposer,
|
|
20
|
+
panelRect,
|
|
21
|
+
readPanel,
|
|
22
|
+
typeKeyByKey,
|
|
23
|
+
waitForReply,
|
|
24
|
+
} from "../panel-driver.mjs";
|
|
25
|
+
|
|
26
|
+
export const RECORD_DEFAULTS = Object.freeze({
|
|
27
|
+
capS: 90, // the most one scene records
|
|
28
|
+
keepS: 3, // of the settled answer
|
|
29
|
+
pollMs: 500,
|
|
30
|
+
stableS: 3, // a reply unchanged this long has settled (as ask: 2 polls of 1.5 s)
|
|
31
|
+
leadMs: 700, // the still panel before the first key
|
|
32
|
+
minFrames: 3,
|
|
33
|
+
// Chrome sends up to ~60 pictures a second while the app animates; the video
|
|
34
|
+
// is 30 fps, so pictures closer together than this are not kept (about half
|
|
35
|
+
// the disk). The last one of a burst always is.
|
|
36
|
+
minFrameGapMs: 25,
|
|
37
|
+
// Every key repaints the composer, so while the question is typed a picture
|
|
38
|
+
// comes at least this often - unless the window is minimized or covered.
|
|
39
|
+
typingGapMaxMs: 1000,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Pause between keys: a question types in about 1.6 s, never faster than a quick typist. */
|
|
43
|
+
export function keyDelayMs(question) {
|
|
44
|
+
const length = [...String(question || "")].length || 1;
|
|
45
|
+
return Math.round(Math.min(75, Math.max(28, 1600 / length)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Times from the polls: when a reply first showed (text that is not a
|
|
50
|
+
* progress phrase, or a card), and when what the panel showed last changed.
|
|
51
|
+
*/
|
|
52
|
+
export function createReplyClock() {
|
|
53
|
+
let firstReply = null;
|
|
54
|
+
let lastChange = null;
|
|
55
|
+
let last = null;
|
|
56
|
+
return {
|
|
57
|
+
observe({ at, reply = "", cards = [], newCardFrames = 0 }) {
|
|
58
|
+
const answered = (reply.length >= 2 && !looksPending(reply)) || cards.some(Boolean) || newCardFrames > 0;
|
|
59
|
+
if (answered && firstReply === null) firstReply = at;
|
|
60
|
+
const snapshot = `${reply}|${cards.join("|")}|${newCardFrames}`;
|
|
61
|
+
if (snapshot !== last) lastChange = at;
|
|
62
|
+
last = snapshot;
|
|
63
|
+
},
|
|
64
|
+
get firstReply() {
|
|
65
|
+
return firstReply;
|
|
66
|
+
},
|
|
67
|
+
get lastChange() {
|
|
68
|
+
return lastChange;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Which pictures to keep, by their times in ms. Times only go forward: Chrome's
|
|
75
|
+
* own stamp can run up to a few tens of ms behind the one before, and the
|
|
76
|
+
* video looks pictures up by time. A picture closer than `minGapMs` to the
|
|
77
|
+
* last one kept is held back rather than dropped: if nothing follows it (the
|
|
78
|
+
* answer's last word, then stillness), it is kept when the recording stops.
|
|
79
|
+
* offer(t) -> { t, keep, flush }: flush is a held picture to write first.
|
|
80
|
+
*/
|
|
81
|
+
export function createFrameGate({ minGapMs = RECORD_DEFAULTS.minFrameGapMs } = {}) {
|
|
82
|
+
let lastT = -Infinity;
|
|
83
|
+
let keptT = -Infinity;
|
|
84
|
+
let held = null;
|
|
85
|
+
return {
|
|
86
|
+
offer(rawT, item) {
|
|
87
|
+
const t = Math.max(rawT, lastT);
|
|
88
|
+
lastT = t;
|
|
89
|
+
if (t - keptT < minGapMs) {
|
|
90
|
+
held = { t, item };
|
|
91
|
+
return { t, keep: false };
|
|
92
|
+
}
|
|
93
|
+
held = null;
|
|
94
|
+
keptT = t;
|
|
95
|
+
return { t, keep: true };
|
|
96
|
+
},
|
|
97
|
+
/** The picture still held back, once nothing more will come. */
|
|
98
|
+
drain() {
|
|
99
|
+
const last = held;
|
|
100
|
+
held = null;
|
|
101
|
+
if (last) keptT = last.t;
|
|
102
|
+
return last;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The longest stretch without a picture in [from, to] (ms), counting from each end. */
|
|
108
|
+
export function longestGap(times, from, to) {
|
|
109
|
+
let longest = 0;
|
|
110
|
+
let previous = from;
|
|
111
|
+
for (const t of times) {
|
|
112
|
+
if (t < from) continue;
|
|
113
|
+
if (t > to) break;
|
|
114
|
+
longest = Math.max(longest, t - previous);
|
|
115
|
+
previous = t;
|
|
116
|
+
}
|
|
117
|
+
return Math.max(longest, to - previous);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function screencastSink(page, sceneDir, { minGapMs }) {
|
|
121
|
+
fs.mkdirSync(path.join(sceneDir, "frames"), { recursive: true });
|
|
122
|
+
const frames = [];
|
|
123
|
+
const gate = createFrameGate({ minGapMs });
|
|
124
|
+
let viewport = null;
|
|
125
|
+
let hiddenAt = null;
|
|
126
|
+
const write = (data, at) => {
|
|
127
|
+
const file = `frames/${String(frames.length + 1).padStart(6, "0")}.jpg`;
|
|
128
|
+
try {
|
|
129
|
+
fs.writeFileSync(path.join(sceneDir, file), Buffer.from(data, "base64"));
|
|
130
|
+
frames.push({ file, at });
|
|
131
|
+
} catch {
|
|
132
|
+
// a picture that could not be written is a picture less, not a failed scene
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const onFrame = (params) => {
|
|
136
|
+
// Acknowledge first: Chrome sends the next picture only after the ack.
|
|
137
|
+
page.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => {});
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const stamp = Number(params.metadata?.timestamp) * 1000;
|
|
140
|
+
// Chrome's own time of the picture when it is sane, else when it arrived.
|
|
141
|
+
const raw = Number.isFinite(stamp) && Math.abs(stamp - now) < 5000 ? stamp : now;
|
|
142
|
+
if (!viewport && params.metadata?.deviceWidth) {
|
|
143
|
+
viewport = { width: params.metadata.deviceWidth, height: params.metadata.deviceHeight };
|
|
144
|
+
}
|
|
145
|
+
const { t, keep } = gate.offer(raw, params.data);
|
|
146
|
+
if (keep) write(params.data, t);
|
|
147
|
+
};
|
|
148
|
+
// Chrome stops painting a window that is minimized or covered: the
|
|
149
|
+
// recording would jump from the question to the answer.
|
|
150
|
+
const onVisibility = (params) => {
|
|
151
|
+
if (params?.visible === false && hiddenAt === null) hiddenAt = Date.now();
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
frames,
|
|
155
|
+
get viewport() {
|
|
156
|
+
return viewport;
|
|
157
|
+
},
|
|
158
|
+
get hiddenAt() {
|
|
159
|
+
return hiddenAt;
|
|
160
|
+
},
|
|
161
|
+
async start() {
|
|
162
|
+
page.on("Page.screencastFrame", onFrame);
|
|
163
|
+
page.on("Page.screencastVisibilityChanged", onVisibility);
|
|
164
|
+
await page.send("Page.startScreencast", {
|
|
165
|
+
format: "jpeg",
|
|
166
|
+
quality: 85,
|
|
167
|
+
maxWidth: 1920,
|
|
168
|
+
maxHeight: 1200,
|
|
169
|
+
everyNthFrame: 1,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
async stop() {
|
|
173
|
+
await page.send("Page.stopScreencast", {}, { timeoutMs: 5000 }).catch(() => {});
|
|
174
|
+
// A picture already on its way still lands.
|
|
175
|
+
await sleep(150);
|
|
176
|
+
page.off("Page.screencastFrame", onFrame);
|
|
177
|
+
page.off("Page.screencastVisibilityChanged", onVisibility);
|
|
178
|
+
const held = gate.drain();
|
|
179
|
+
if (held) write(held.item, held.t);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Record one scene into `dir`: frames/*.jpg, frames.json and events.json.
|
|
186
|
+
* Returns the scene's report; throws only when the page cannot be driven.
|
|
187
|
+
*/
|
|
188
|
+
export async function recordScene(page, { question, dir, agentApiName, options = {} }) {
|
|
189
|
+
const opts = { ...RECORD_DEFAULTS, ...options };
|
|
190
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
191
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
192
|
+
|
|
193
|
+
const { state } = await openComposer(page, agentApiName);
|
|
194
|
+
if (!state.composer) {
|
|
195
|
+
return { ok: false, reason: state.frame ? "no_composer" : "no_panel" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A fresh conversation for every scene: the panel's New Chat button, as a
|
|
199
|
+
// person would. Without one, the SDK's message(..., { newChat: true })
|
|
200
|
+
// starts it and sends the question in one go - then nothing is typed.
|
|
201
|
+
const newChat = (await clickNewChat(page).catch(() => false)) ? "button" : "sdk";
|
|
202
|
+
await sleep(900);
|
|
203
|
+
|
|
204
|
+
const sink = screencastSink(page, dir, { minGapMs: opts.minFrameGapMs });
|
|
205
|
+
const panel = [];
|
|
206
|
+
const sample = async (at) => {
|
|
207
|
+
const rect = await panelRect(page).catch(() => null);
|
|
208
|
+
if (rect) panel.push({ at, ...rect });
|
|
209
|
+
};
|
|
210
|
+
const events = { newChat };
|
|
211
|
+
const clock = createReplyClock();
|
|
212
|
+
let result = { settled: false, reply: "" };
|
|
213
|
+
const startedAt = Date.now();
|
|
214
|
+
await sink.start();
|
|
215
|
+
try {
|
|
216
|
+
await sample(startedAt);
|
|
217
|
+
await sleep(opts.leadMs);
|
|
218
|
+
|
|
219
|
+
events.typingStart = Date.now();
|
|
220
|
+
let sent;
|
|
221
|
+
let typedBy;
|
|
222
|
+
let enteredAt = null;
|
|
223
|
+
if (newChat === "button") {
|
|
224
|
+
({ sent, typedBy, enteredAt } = await typeKeyByKey(page, question, { delayMs: keyDelayMs(question) }));
|
|
225
|
+
} else {
|
|
226
|
+
sent = await messageInNewChat(page, agentApiName, question).catch(() => false);
|
|
227
|
+
typedBy = "sdk";
|
|
228
|
+
}
|
|
229
|
+
// When Enter landed, not when typing returned: it waits after Enter to
|
|
230
|
+
// check the composer emptied, and the question bubble is already showing.
|
|
231
|
+
events.sent = enteredAt ?? Date.now();
|
|
232
|
+
events.typedBy = typedBy;
|
|
233
|
+
if (!sent) return { ok: false, reason: "not_sent", typedBy, newChat };
|
|
234
|
+
|
|
235
|
+
const capAt = startedAt + opts.capS * 1000;
|
|
236
|
+
const timeoutS = Math.max(1, (capAt - Date.now()) / 1000);
|
|
237
|
+
result = await waitForReply(page, question, {
|
|
238
|
+
timeoutS,
|
|
239
|
+
pollMs: opts.pollMs,
|
|
240
|
+
tracker: createSettleTracker({ stableNeeded: Math.ceil((opts.stableS * 1000) / opts.pollMs) }),
|
|
241
|
+
onPoll: ({ at, reply, seen }) => clock.observe({ at, reply, cards: seen.cards, newCardFrames: seen.newCardFrames }),
|
|
242
|
+
});
|
|
243
|
+
events.firstReply = clock.firstReply;
|
|
244
|
+
// The answer settled when the panel last changed, not when the stability
|
|
245
|
+
// check confirmed it; the poll saw the change up to half a poll late.
|
|
246
|
+
events.settled = result.settled && clock.lastChange ? clock.lastChange - opts.pollMs / 2 : null;
|
|
247
|
+
events.settledConfirmed = Date.now();
|
|
248
|
+
await sample(Date.now());
|
|
249
|
+
if (result.settled) {
|
|
250
|
+
const keepUntil = events.settled + opts.keepS * 1000;
|
|
251
|
+
if (Date.now() < keepUntil) await sleep(Math.min(keepUntil - Date.now(), capAt - Date.now()));
|
|
252
|
+
await sleep(300);
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
await sink.stop();
|
|
256
|
+
}
|
|
257
|
+
events.end = Date.now();
|
|
258
|
+
|
|
259
|
+
const rel = (at) => (at == null ? null : Math.round(at - startedAt));
|
|
260
|
+
const viewport = sink.viewport || (panel[0] ? { width: panel[0].viewportWidth, height: panel[0].viewportHeight } : null);
|
|
261
|
+
const record = {
|
|
262
|
+
question,
|
|
263
|
+
newChat,
|
|
264
|
+
typedBy: events.typedBy,
|
|
265
|
+
settled: Boolean(result.settled),
|
|
266
|
+
// Milliseconds since this scene's recording started.
|
|
267
|
+
typingStart: rel(events.typingStart),
|
|
268
|
+
sent: rel(events.sent),
|
|
269
|
+
firstReply: rel(events.firstReply),
|
|
270
|
+
settledAt: rel(events.settled),
|
|
271
|
+
settledConfirmed: rel(events.settledConfirmed),
|
|
272
|
+
end: rel(events.end),
|
|
273
|
+
viewport,
|
|
274
|
+
panel: panel.map(({ at, ...rect }) => ({ t: rel(at), ...rect })),
|
|
275
|
+
frames: sink.frames.length,
|
|
276
|
+
};
|
|
277
|
+
fs.writeFileSync(
|
|
278
|
+
path.join(dir, "frames.json"),
|
|
279
|
+
`${JSON.stringify(sink.frames.map((frame) => ({ file: frame.file, t: rel(frame.at) })))}\n`,
|
|
280
|
+
);
|
|
281
|
+
fs.writeFileSync(path.join(dir, "events.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
282
|
+
|
|
283
|
+
if (sink.frames.length < opts.minFrames) return { ok: false, reason: "no_frames", ...record };
|
|
284
|
+
// Hidden while it recorded: Chrome said so, or the typing - a repaint every
|
|
285
|
+
// key - went a second without a picture. Either way part of it is missing.
|
|
286
|
+
const typingGap = longestGap(
|
|
287
|
+
sink.frames.map((frame) => frame.at),
|
|
288
|
+
events.typingStart,
|
|
289
|
+
events.sent,
|
|
290
|
+
);
|
|
291
|
+
if (sink.hiddenAt !== null || typingGap > opts.typingGapMaxMs) {
|
|
292
|
+
return { ok: false, reason: "hidden", typingGapMs: Math.round(typingGap), ...record };
|
|
293
|
+
}
|
|
294
|
+
if (!result.settled) {
|
|
295
|
+
return { ok: false, reason: squash(result.reply) ? "not_settled" : "no_reply", ...record };
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, ...record };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Whether a recording folder finished: every scene's answer settled. */
|
|
301
|
+
export function recordingComplete(dir) {
|
|
302
|
+
try {
|
|
303
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "record.json"), "utf8")).complete === true;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Keep the `keep` newest finished recordings in `root`, and the newest one of
|
|
311
|
+
* all (the run that just ended, finished or not); delete the rest. A failed
|
|
312
|
+
* try never pushes out a good recording that `--from` still needs. Each is
|
|
313
|
+
* one folder named by its time.
|
|
314
|
+
*/
|
|
315
|
+
export function pruneRecordings(root, keep = 3) {
|
|
316
|
+
let names = [];
|
|
317
|
+
try {
|
|
318
|
+
names = fs
|
|
319
|
+
.readdirSync(root, { withFileTypes: true })
|
|
320
|
+
.filter((entry) => entry.isDirectory() && /^\d{4}-\d\d-\d\dT/.test(entry.name))
|
|
321
|
+
.map((entry) => entry.name)
|
|
322
|
+
.sort();
|
|
323
|
+
} catch {
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
const kept = new Set(names.filter((name) => recordingComplete(path.join(root, name))).slice(-keep));
|
|
327
|
+
if (names.length) kept.add(names[names.length - 1]);
|
|
328
|
+
const removed = names.filter((name) => !kept.has(name));
|
|
329
|
+
for (const name of removed) fs.rmSync(path.join(root, name), { recursive: true, force: true });
|
|
330
|
+
return removed;
|
|
331
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// video/scenes.json: what `foldspace record` asks and what each caption says.
|
|
2
|
+
//
|
|
3
|
+
// {
|
|
4
|
+
// "product": "Northwind CRM",
|
|
5
|
+
// "endCard": true, optional, default true
|
|
6
|
+
// "scenes": [
|
|
7
|
+
// { "question": "Which deals are at risk this week?",
|
|
8
|
+
// "capabilities": ["Action", "Agentic UI"],
|
|
9
|
+
// "line": "The deals at risk, *ranked,*\nstraight from your CRM." }
|
|
10
|
+
// ]
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// Each scene is one of the agent's key use cases. Its caption shows the
|
|
14
|
+
// capabilities it uses as pills and where the video is ("2 of 4"), the
|
|
15
|
+
// question small, and the scene's line large: what the user gets, with the
|
|
16
|
+
// payoff on a blue highlight (*like this*). The capabilities are the names of
|
|
17
|
+
// Foldspace's features; a plain answer has none.
|
|
18
|
+
//
|
|
19
|
+
// Checked up front with errors that name the field, so a typo costs nothing
|
|
20
|
+
// and never a half-made recording.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { parseMarked, visibleText } from "./timeline.mjs";
|
|
26
|
+
|
|
27
|
+
export const SCENE_LIMITS = Object.freeze({
|
|
28
|
+
minScenes: 1,
|
|
29
|
+
maxScenes: 6,
|
|
30
|
+
product: 40,
|
|
31
|
+
question: 120,
|
|
32
|
+
line: 70, // visible characters, without the asterisks
|
|
33
|
+
lineBreaks: 1, // a "\n" makes two lines; no more
|
|
34
|
+
lineMarks: 2,
|
|
35
|
+
// A line broken by hand longer than this is drawn smaller to fit.
|
|
36
|
+
lineHalf: 38,
|
|
37
|
+
maxCapabilities: 3,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The pills, in this spelling; a file may use any case. File upload is not
|
|
41
|
+
// here on purpose: a recording never uploads. Voice is: the recording types,
|
|
42
|
+
// but the pill says the agent takes speech too, when voice is on for it.
|
|
43
|
+
export const CAPABILITIES = Object.freeze(["Action", "Agentic UI", "Navigation", "Tandem", "Human in the loop", "Voice"]);
|
|
44
|
+
|
|
45
|
+
// Names an earlier version used, and what took their place.
|
|
46
|
+
const RETIRED = Object.freeze({
|
|
47
|
+
"generative ui": "Agentic UI",
|
|
48
|
+
"your backend": "Action",
|
|
49
|
+
"chat & voice": "Voice",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const TOP_KEYS = ["product", "endCard", "scenes"];
|
|
53
|
+
const SCENE_KEYS = ["question", "capabilities", "line"];
|
|
54
|
+
const ALLOWED = `up to ${SCENE_LIMITS.maxCapabilities} of: ${CAPABILITIES.join(", ")}`;
|
|
55
|
+
|
|
56
|
+
/** A required line of text, at most `max` characters. */
|
|
57
|
+
function text(value, where, errors, { max }) {
|
|
58
|
+
if (value === undefined || value === null || (typeof value === "string" && !value.trim())) {
|
|
59
|
+
errors.push(`${where}: missing`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (typeof value !== "string") {
|
|
63
|
+
errors.push(`${where}: must be text`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const clean = value.replace(/\r/g, "").trim();
|
|
67
|
+
if (clean.includes("\n")) errors.push(`${where}: one line only`);
|
|
68
|
+
if (max && clean.length > max) errors.push(`${where}: ${clean.length} characters, keep it to ${max}`);
|
|
69
|
+
return clean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The scene's line: at most two lines (one "\n"), at most 70 characters
|
|
74
|
+
* without the asterisks, at most two *highlights*, each closed and not empty.
|
|
75
|
+
*/
|
|
76
|
+
function line(value, where, errors, warnings) {
|
|
77
|
+
if (value === undefined || value === null || (typeof value === "string" && !value.trim())) {
|
|
78
|
+
errors.push(`${where}: missing - one short line on what the user gets, e.g. "The deals at risk, *ranked,*\\nstraight from your CRM."`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value !== "string") {
|
|
82
|
+
errors.push(`${where}: must be text`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const clean = value
|
|
86
|
+
.replace(/\r/g, "")
|
|
87
|
+
.split("\n")
|
|
88
|
+
.map((part) => part.replace(/[ \t]+/g, " ").trim())
|
|
89
|
+
.join("\n")
|
|
90
|
+
.trim();
|
|
91
|
+
const breaks = (clean.match(/\n/g) || []).length;
|
|
92
|
+
if (breaks > SCENE_LIMITS.lineBreaks) errors.push(`${where}: ${breaks + 1} lines - at most 2 (one "\\n")`);
|
|
93
|
+
const visible = visibleText(clean).length;
|
|
94
|
+
if (visible > SCENE_LIMITS.line) {
|
|
95
|
+
errors.push(`${where}: ${visible} characters without the asterisks, keep it to ${SCENE_LIMITS.line}`);
|
|
96
|
+
}
|
|
97
|
+
const marked = parseMarked(clean);
|
|
98
|
+
if (marked.problem === "unbalanced") errors.push(`${where}: an asterisk without its pair - *highlight* takes one on each side`);
|
|
99
|
+
else if (marked.problem === "across") errors.push(`${where}: a highlight runs over the "\\n" - close it before the break`);
|
|
100
|
+
else if (marked.problem === "empty") errors.push(`${where}: an empty highlight - put a word or two between the asterisks`);
|
|
101
|
+
else if (marked.marks > SCENE_LIMITS.lineMarks) {
|
|
102
|
+
errors.push(`${where}: ${marked.marks} highlights - at most ${SCENE_LIMITS.lineMarks}; one is best, on the payoff`);
|
|
103
|
+
}
|
|
104
|
+
if (breaks === 1) {
|
|
105
|
+
const long = clean.split("\n").map(visibleText).find((part) => part.length > SCENE_LIMITS.lineHalf);
|
|
106
|
+
if (long) warnings.push(`${where}: "${long}" is long for one line, so the line is drawn smaller - move the "\\n" or shorten it`);
|
|
107
|
+
}
|
|
108
|
+
return clean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function unknownKeys(object, allowed, where, errors) {
|
|
112
|
+
for (const key of Object.keys(object)) {
|
|
113
|
+
if (allowed.includes(key)) continue;
|
|
114
|
+
const hint = key === "summary" ? ` - the caption's words are now "line"` : "";
|
|
115
|
+
errors.push(`${where ? `${where}.` : ""}${key}: not a field here (fields: ${allowed.join(", ")})${hint}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const capabilityKey = (name) => String(name).trim().replace(/\s+/g, " ").toLowerCase();
|
|
120
|
+
|
|
121
|
+
/** None to three of CAPABILITIES, in the order given and spelled as the list spells them. */
|
|
122
|
+
function capabilities(value, where, errors) {
|
|
123
|
+
if (value === undefined || value === null) {
|
|
124
|
+
errors.push(`${where}: missing - ${ALLOWED}; [] for a plain answer`);
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(value)) {
|
|
128
|
+
errors.push(`${where}: a list - ${ALLOWED}, e.g. ["Action", "Agentic UI"]`);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
if (value.length > SCENE_LIMITS.maxCapabilities) {
|
|
132
|
+
errors.push(`${where}: ${value.length} - at most ${SCENE_LIMITS.maxCapabilities}, the ones this scene shows best (${CAPABILITIES.join(", ")})`);
|
|
133
|
+
}
|
|
134
|
+
const names = [];
|
|
135
|
+
value.forEach((name, index) => {
|
|
136
|
+
const key = typeof name === "string" ? capabilityKey(name) : null;
|
|
137
|
+
const known = key === null ? null : CAPABILITIES.find((one) => capabilityKey(one) === key);
|
|
138
|
+
if (!known && key !== null && key in RETIRED) {
|
|
139
|
+
const now = RETIRED[key];
|
|
140
|
+
errors.push(
|
|
141
|
+
now
|
|
142
|
+
? `${where}[${index}]: ${JSON.stringify(name)} is now "${now}" - ${ALLOWED}`
|
|
143
|
+
: `${where}[${index}]: ${JSON.stringify(name)} is no longer a pill - leave it out (a plain answer has none); ${ALLOWED}`,
|
|
144
|
+
);
|
|
145
|
+
} else if (!known) {
|
|
146
|
+
errors.push(`${where}[${index}]: ${JSON.stringify(name)} is not a capability - ${ALLOWED}`);
|
|
147
|
+
} else if (names.includes(known)) {
|
|
148
|
+
errors.push(`${where}[${index}]: "${known}" twice - name each capability once`);
|
|
149
|
+
} else {
|
|
150
|
+
names.push(known);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return names;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check a parsed scenes file. Returns { ok, errors, warnings, value } where
|
|
158
|
+
* value has the defaults filled in and the capabilities spelled as CAPABILITIES.
|
|
159
|
+
*/
|
|
160
|
+
export function validateScenes(input) {
|
|
161
|
+
const errors = [];
|
|
162
|
+
const warnings = [];
|
|
163
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
164
|
+
return { ok: false, errors: ["the file must hold one JSON object: { product, scenes: [...] }"], warnings, value: null };
|
|
165
|
+
}
|
|
166
|
+
unknownKeys(input, TOP_KEYS, "", errors);
|
|
167
|
+
const product = text(input.product, "product", errors, { max: SCENE_LIMITS.product });
|
|
168
|
+
if (input.endCard !== undefined && typeof input.endCard !== "boolean") {
|
|
169
|
+
errors.push("endCard: true or false");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const scenes = [];
|
|
173
|
+
if (!Array.isArray(input.scenes)) {
|
|
174
|
+
errors.push("scenes: missing - a list of { question, capabilities, line }");
|
|
175
|
+
} else {
|
|
176
|
+
if (input.scenes.length < SCENE_LIMITS.minScenes) errors.push("scenes: empty - add 4 or 5");
|
|
177
|
+
if (input.scenes.length > SCENE_LIMITS.maxScenes) {
|
|
178
|
+
errors.push(`scenes: ${input.scenes.length} - at most ${SCENE_LIMITS.maxScenes}; 4 or 5 keep the video at about 40 s`);
|
|
179
|
+
} else if (input.scenes.length && (input.scenes.length < 4 || input.scenes.length > 5)) {
|
|
180
|
+
warnings.push(`${input.scenes.length} scene${input.scenes.length === 1 ? "" : "s"}: 4 or 5 make a video of about 40 s`);
|
|
181
|
+
}
|
|
182
|
+
input.scenes.forEach((scene, index) => {
|
|
183
|
+
const where = `scenes[${index}]`;
|
|
184
|
+
if (!scene || typeof scene !== "object" || Array.isArray(scene)) {
|
|
185
|
+
errors.push(`${where}: must be { question, capabilities, line }`);
|
|
186
|
+
scenes.push({ question: null, capabilities: null, line: null });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
unknownKeys(scene, SCENE_KEYS, where, errors);
|
|
190
|
+
scenes.push({
|
|
191
|
+
question: text(scene.question, `${where}.question`, errors, { max: SCENE_LIMITS.question }),
|
|
192
|
+
capabilities: capabilities(scene.capabilities, `${where}.capabilities`, errors),
|
|
193
|
+
line: line(scene.line, `${where}.line`, errors, warnings),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
const asked = scenes.map((scene) => scene.question);
|
|
197
|
+
asked.forEach((question, index) => {
|
|
198
|
+
if (question && asked.indexOf(question) !== index) errors.push(`scenes[${index}].question: the same question twice`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (errors.length) return { ok: false, errors, warnings, value: null };
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
errors,
|
|
206
|
+
warnings,
|
|
207
|
+
value: {
|
|
208
|
+
product,
|
|
209
|
+
endCard: input.endCard !== false,
|
|
210
|
+
scenes,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Where a caption says the video is: "2 of 4" (drawn in capitals). */
|
|
216
|
+
export function scenePlace(n, total) {
|
|
217
|
+
return `${n} of ${total}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A file name from a product name: "Northwind CRM" -> "northwind-crm". */
|
|
221
|
+
export function productSlug(product) {
|
|
222
|
+
const slug = String(product || "")
|
|
223
|
+
.normalize("NFKD")
|
|
224
|
+
.replace(/[̀-ͯ]/g, "")
|
|
225
|
+
.toLowerCase()
|
|
226
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
227
|
+
.replace(/^-+|-+$/g, "")
|
|
228
|
+
.slice(0, 40);
|
|
229
|
+
return slug || "agent";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** video/<product>-<yyyymmdd-hhmm>.mp4, in local time. */
|
|
233
|
+
export function videoBaseName(product, date = new Date()) {
|
|
234
|
+
const two = (value) => String(value).padStart(2, "0");
|
|
235
|
+
const stamp =
|
|
236
|
+
`${date.getFullYear()}${two(date.getMonth() + 1)}${two(date.getDate())}` +
|
|
237
|
+
`-${two(date.getHours())}${two(date.getMinutes())}`;
|
|
238
|
+
return `${productSlug(product)}-${stamp}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The videos hold real data from the account: they stay out of git in every
|
|
242
|
+
// project, old ones too (an upgrade never touches .gitignore). scenes.json
|
|
243
|
+
// stays tracked.
|
|
244
|
+
export const VIDEO_GITIGNORE = "# The videos show real data from the account: never saved with the code.\n*.mp4\n*.jpg\n";
|
|
245
|
+
|
|
246
|
+
/** Write <videoDir>/.gitignore when it is missing; returns whether it wrote one. */
|
|
247
|
+
export function ensureVideoIgnored(videoDir) {
|
|
248
|
+
const file = path.join(videoDir, ".gitignore");
|
|
249
|
+
if (fs.existsSync(file)) return false;
|
|
250
|
+
fs.mkdirSync(videoDir, { recursive: true });
|
|
251
|
+
fs.writeFileSync(file, VIDEO_GITIGNORE);
|
|
252
|
+
return true;
|
|
253
|
+
}
|