@foldspace_npm/harness 0.1.31 → 0.1.33
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 +27 -8
- 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/bin/share.mjs +63 -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 +97 -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/share.mjs +284 -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/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 +271 -0
package/bin/record.mjs
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foldspace record - a short video of the agent working in the product, for
|
|
3
|
+
// the human to share: an opening card ("Meet the <product> Product Agent"),
|
|
4
|
+
// then one scene for each of the agent's key use cases, recorded live in the
|
|
5
|
+
// test window (the question typed key by key in a fresh conversation, the
|
|
6
|
+
// agent thinking, the answer arriving), a caption once each answer settles -
|
|
7
|
+
// the capabilities it shows, the question, what the user gets - an optional
|
|
8
|
+
// closing card, a small Foldspace logo over the scenes - in Foldspace's
|
|
9
|
+
// colours, not the app's.
|
|
10
|
+
//
|
|
11
|
+
// npx foldspace record [--scenes video/scenes.json] [--preset landscape|square]
|
|
12
|
+
// [--no-end-card] [--from <recordDir>]
|
|
13
|
+
//
|
|
14
|
+
// The video shows real data from the signed-in account: nothing is masked
|
|
15
|
+
// and nothing is uploaded. Whether to share it is the human's call.
|
|
16
|
+
// No test mode: a recording never arms it, and refuses to run while attach
|
|
17
|
+
// has it on (the panel would show its banner) - read from the daemon's log,
|
|
18
|
+
// or from the page, which attach marks either way.
|
|
19
|
+
//
|
|
20
|
+
// --from makes the video again from an earlier recording, with the
|
|
21
|
+
// capabilities and lines in the scenes file now - nothing is recorded,
|
|
22
|
+
// the window is not touched.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { ATTACH_DAEMON_LOG_FILE, attachTestModeOn, liveDaemon, pageTestModeOn } from "../src/attach-daemon.mjs";
|
|
28
|
+
import { withBadgeOff } from "../src/badge-auto.mjs";
|
|
29
|
+
import { CdpPage, parseArgs, printJson } from "../src/cdp-client.mjs";
|
|
30
|
+
import { findChrome } from "../src/chrome-binary.mjs";
|
|
31
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
32
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
33
|
+
import {
|
|
34
|
+
PRESETS,
|
|
35
|
+
VideoError,
|
|
36
|
+
buildPlan,
|
|
37
|
+
checkRecordingMatches,
|
|
38
|
+
closeAllHeadless,
|
|
39
|
+
loadRecording,
|
|
40
|
+
needsChrome,
|
|
41
|
+
probeEncoder,
|
|
42
|
+
renderInChrome,
|
|
43
|
+
} from "../src/video/compose.mjs";
|
|
44
|
+
import { pruneRecordings, recordScene } from "../src/video/recorder.mjs";
|
|
45
|
+
import { ensureVideoIgnored, validateScenes, videoBaseName } from "../src/video/scenes.mjs";
|
|
46
|
+
import { TIMING } from "../src/video/timeline.mjs";
|
|
47
|
+
|
|
48
|
+
const { flags } = parseArgs(process.argv.slice(2), ["scenes", "preset", "from"]);
|
|
49
|
+
const projectDir = path.resolve(resolveProjectDir());
|
|
50
|
+
const scenesFile = path.resolve(projectDir, String(flags.scenes || path.join("video", "scenes.json")));
|
|
51
|
+
const preset = String(flags.preset || "landscape");
|
|
52
|
+
const recordRoot = path.join(projectDir, ".foldspace-dev", "record");
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
|
|
55
|
+
let page = null;
|
|
56
|
+
let finished = false;
|
|
57
|
+
// Where the run is, for a stop half-way: what to put back, what is kept.
|
|
58
|
+
const run = { phase: "checking", recordDir: null, recordingComplete: false };
|
|
59
|
+
// What a stopped run must undo, newest last: the badge, the screencast.
|
|
60
|
+
const undo = new Set();
|
|
61
|
+
|
|
62
|
+
function finish(result, code) {
|
|
63
|
+
if (finished) return;
|
|
64
|
+
finished = true;
|
|
65
|
+
recordEvent(projectDir, "record", {
|
|
66
|
+
ok: Boolean(result.ok),
|
|
67
|
+
scenes: result.scenes?.length ?? null,
|
|
68
|
+
...(result.ok ? {} : { code: result.code || "failed" }),
|
|
69
|
+
});
|
|
70
|
+
printJson(result);
|
|
71
|
+
page?.close();
|
|
72
|
+
process.exit(code);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The main flow's way out, awaited so nothing after it runs. While a stop is
|
|
76
|
+
// being handled, the stop's own report is the one that counts: whatever fails
|
|
77
|
+
// here then (the hidden Chrome closing, say) fails because of the stop.
|
|
78
|
+
function end(result, code) {
|
|
79
|
+
if (!run.stopping) finish(result, code);
|
|
80
|
+
return new Promise(() => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const progress = (line) => process.stderr.write(`record: ${line}\n`);
|
|
84
|
+
const rel = (file) => path.relative(projectDir, file) || file;
|
|
85
|
+
const fromAgain = (dir) =>
|
|
86
|
+
`The recording is kept: \`npx foldspace record --from ${rel(dir)}\` makes the video again without asking anything.`;
|
|
87
|
+
|
|
88
|
+
// Stopped half-way - Ctrl-C, or the shell's timeout. A `finally` never runs
|
|
89
|
+
// when the process is killed, so without this the pill and corner tag would
|
|
90
|
+
// stay hidden for the rest of the session, and a hidden Chrome would keep
|
|
91
|
+
// running. Put things back (briefly: each step gives up after 2 s), then say
|
|
92
|
+
// what happened in the same JSON as always.
|
|
93
|
+
function stop(signal) {
|
|
94
|
+
if (finished || run.stopping) return;
|
|
95
|
+
run.stopping = true;
|
|
96
|
+
const exitCode = signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143;
|
|
97
|
+
progress(`stopped (${signal}): putting the window back`);
|
|
98
|
+
const hardStop = setTimeout(() => process.exit(exitCode), 6000);
|
|
99
|
+
Promise.allSettled([...undo].reverse().map((step) => step()))
|
|
100
|
+
.then(() => closeAllHeadless())
|
|
101
|
+
.finally(() => {
|
|
102
|
+
clearTimeout(hardStop);
|
|
103
|
+
const kept = run.recordingComplete && run.recordDir;
|
|
104
|
+
finish(
|
|
105
|
+
{
|
|
106
|
+
ok: false,
|
|
107
|
+
code: "interrupted",
|
|
108
|
+
reason:
|
|
109
|
+
run.phase === "recording"
|
|
110
|
+
? `Stopped (${signal}) while recording; the pill and corner tag are back. A recording takes a few minutes: run it with a 10-minute timeout.`
|
|
111
|
+
: run.phase === "composing" && kept
|
|
112
|
+
? `Stopped (${signal}) while making the video. ${fromAgain(run.recordDir)}`
|
|
113
|
+
: `Stopped (${signal}) before anything was recorded.`,
|
|
114
|
+
...(run.recordDir ? { recordDir: run.recordDir } : {}),
|
|
115
|
+
},
|
|
116
|
+
exitCode,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => stop(signal));
|
|
121
|
+
|
|
122
|
+
function readScenes() {
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = fs.readFileSync(scenesFile, "utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
finish(
|
|
128
|
+
{
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "no_scenes",
|
|
131
|
+
reason: `No scenes file at ${path.relative(projectDir, scenesFile) || scenesFile}. Agree 4-5 scenes with the human and write it first (the share-a-video skill shows the shape).`,
|
|
132
|
+
},
|
|
133
|
+
1,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
finish({ ok: false, code: "bad_scenes", reason: `${path.basename(scenesFile)} is not valid JSON: ${error.message}` }, 1);
|
|
141
|
+
}
|
|
142
|
+
const checked = validateScenes(parsed);
|
|
143
|
+
if (!checked.ok) {
|
|
144
|
+
finish({ ok: false, code: "bad_scenes", reason: `Fix ${path.basename(scenesFile)} first.`, errors: checked.errors }, 1);
|
|
145
|
+
}
|
|
146
|
+
return checked;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const askAgain = (scene) => `Prove the question with \`npx foldspace ask "${scene.question}"\`, then record again.`;
|
|
150
|
+
|
|
151
|
+
const SCENE_REASONS = {
|
|
152
|
+
// As `ask` says it: while attach runs, its log knows why the panel is missing.
|
|
153
|
+
no_panel: () =>
|
|
154
|
+
liveDaemon(projectDir)
|
|
155
|
+
? "No agent panel on this page while attach runs. Read the lines under the last [lifecycle] line in .foldspace-dev/attach-daemon.log, then record again."
|
|
156
|
+
: "No agent panel on this page. Run `npx foldspace attach --daemon`, follow what it says about the page, then record again.",
|
|
157
|
+
no_composer: () =>
|
|
158
|
+
"The agent panel is on the page but never showed a composer. Run `npx foldspace observe screenshot` and look at the page.",
|
|
159
|
+
not_sent: (scene) => `The question could not be typed into the panel's composer. ${askAgain(scene)}`,
|
|
160
|
+
no_reply: (scene) => `No reply within the 90 s a scene may take. ${askAgain(scene)}`,
|
|
161
|
+
not_settled: (scene) => `The answer did not finish within the 90 s a scene may take. ${askAgain(scene)}`,
|
|
162
|
+
no_frames: () =>
|
|
163
|
+
"Chrome sent almost no pictures of the page: the test window must stay open and in view - not minimized, not covered. Ask the human to leave it that way, then record again.",
|
|
164
|
+
hidden: () =>
|
|
165
|
+
"The test window was minimized or covered while recording, so part of the scene is missing. Ask the human to keep it where they can see it, then record again.",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const TEST_MODE_REASON =
|
|
169
|
+
"attach runs with --test-mode, so the panel shows its test-mode banner, and a video is recorded without test mode. " +
|
|
170
|
+
"The recording's conversations then appear in their Foldspace dashboard, next to their users' conversations: say so, and on their yes run `npx foldspace attach --stop`, " +
|
|
171
|
+
"then `npx foldspace attach --daemon` with the flags it ran with minus --test-mode, then record again. After the recording, attach again with --test-mode.";
|
|
172
|
+
|
|
173
|
+
/** attach's daemon log says "Test: on"; a foreground attach marks the page itself. */
|
|
174
|
+
async function testModeOn(page) {
|
|
175
|
+
const daemon = liveDaemon(projectDir);
|
|
176
|
+
if (daemon) {
|
|
177
|
+
try {
|
|
178
|
+
const log = fs.readFileSync(daemon.logPath || path.join(projectDir, ".foldspace-dev", ATTACH_DAEMON_LOG_FILE), "utf8");
|
|
179
|
+
if (attachTestModeOn(log)) return true;
|
|
180
|
+
} catch {
|
|
181
|
+
// no log to read: the page still says
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return pageTestModeOn(page);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeManifest(recordDir, manifest) {
|
|
188
|
+
fs.writeFileSync(path.join(recordDir, "record.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function record(config) {
|
|
192
|
+
page = await CdpPage.open({ projectDir, command: "record" });
|
|
193
|
+
if (await testModeOn(page)) await end({ ok: false, code: "test_mode", reason: TEST_MODE_REASON }, 1);
|
|
194
|
+
const agentApiName = page.info.target?.agentApiName;
|
|
195
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
196
|
+
const recordDir = path.join(recordRoot, stamp);
|
|
197
|
+
fs.mkdirSync(recordDir, { recursive: true });
|
|
198
|
+
run.recordDir = recordDir;
|
|
199
|
+
run.phase = "recording";
|
|
200
|
+
// `complete` only once every scene's answer settled: --from refuses the rest.
|
|
201
|
+
const manifest = {
|
|
202
|
+
version: 2,
|
|
203
|
+
createdAt: new Date().toISOString(),
|
|
204
|
+
complete: false,
|
|
205
|
+
scenes: config.scenes.map((scene, index) => ({ n: index + 1, dir: `scene-${index + 1}`, question: scene.question })),
|
|
206
|
+
};
|
|
207
|
+
writeManifest(recordDir, manifest);
|
|
208
|
+
|
|
209
|
+
const reports = [];
|
|
210
|
+
let failed = null;
|
|
211
|
+
// Nothing of the harness in the video: no pill, no corner tag. Focus
|
|
212
|
+
// emulation keeps the page painting and taking keys as if it were in front,
|
|
213
|
+
// without ever bringing the window forward.
|
|
214
|
+
const unfocus = () => page?.send("Emulation.setFocusEmulationEnabled", { enabled: false }, { timeoutMs: 2000 }).catch(() => {});
|
|
215
|
+
const stopCapture = () => page?.send("Page.stopScreencast", {}, { timeoutMs: 2000 }).catch(() => {});
|
|
216
|
+
await withBadgeOff(
|
|
217
|
+
page,
|
|
218
|
+
async () => {
|
|
219
|
+
await page.send("Emulation.setFocusEmulationEnabled", { enabled: true }).catch(() => {});
|
|
220
|
+
undo.add(unfocus);
|
|
221
|
+
undo.add(stopCapture);
|
|
222
|
+
try {
|
|
223
|
+
for (const [index, scene] of config.scenes.entries()) {
|
|
224
|
+
progress(`scene ${index + 1} of ${config.scenes.length}: "${scene.question}"`);
|
|
225
|
+
const report = await recordScene(page, {
|
|
226
|
+
question: scene.question,
|
|
227
|
+
dir: path.join(recordDir, `scene-${index + 1}`),
|
|
228
|
+
agentApiName,
|
|
229
|
+
});
|
|
230
|
+
reports.push({ n: index + 1, ...report });
|
|
231
|
+
Object.assign(manifest.scenes[index], { ok: Boolean(report.ok), ...(report.ok ? {} : { reason: report.reason }) });
|
|
232
|
+
writeManifest(recordDir, manifest);
|
|
233
|
+
if (!report.ok) {
|
|
234
|
+
failed = { n: index + 1, scene, report };
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
await unfocus();
|
|
240
|
+
undo.delete(unfocus);
|
|
241
|
+
undo.delete(stopCapture);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
// The badge's own restore, for a run stopped half-way.
|
|
245
|
+
{ register: (restore) => undo.add(() => restore({ timeoutMs: 2000 })) },
|
|
246
|
+
);
|
|
247
|
+
undo.clear();
|
|
248
|
+
page.close();
|
|
249
|
+
page = null;
|
|
250
|
+
if (!failed) {
|
|
251
|
+
manifest.complete = true;
|
|
252
|
+
writeManifest(recordDir, manifest);
|
|
253
|
+
run.recordingComplete = true;
|
|
254
|
+
}
|
|
255
|
+
pruneRecordings(recordRoot, 3);
|
|
256
|
+
|
|
257
|
+
if (failed) {
|
|
258
|
+
const explain = SCENE_REASONS[failed.report.reason] || (() => failed.report.reason);
|
|
259
|
+
await end(
|
|
260
|
+
{
|
|
261
|
+
ok: false,
|
|
262
|
+
code: failed.report.reason,
|
|
263
|
+
reason: `Scene ${failed.n}: ${explain(failed.scene)}`,
|
|
264
|
+
question: failed.scene.question,
|
|
265
|
+
recordDir,
|
|
266
|
+
scenes: reports.map(sceneSummary),
|
|
267
|
+
},
|
|
268
|
+
1,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return { recordDir, reports };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function sceneSummary(report) {
|
|
275
|
+
return {
|
|
276
|
+
n: report.n,
|
|
277
|
+
question: report.question,
|
|
278
|
+
ok: Boolean(report.ok),
|
|
279
|
+
...(report.reason ? { reason: report.reason } : {}),
|
|
280
|
+
newChat: report.newChat,
|
|
281
|
+
typedBy: report.typedBy,
|
|
282
|
+
recordedS: report.end != null ? Math.round(report.end / 100) / 10 : null,
|
|
283
|
+
frames: report.frames,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The last recorded picture of each scene: the answer as it ended. */
|
|
288
|
+
function lastFrames(recording) {
|
|
289
|
+
return recording.scenes.map((scene) => {
|
|
290
|
+
const last = scene.frames[scene.frames.length - 1];
|
|
291
|
+
return last ? rel(path.join(recording.dir, scene.dir, last.file)) : null;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function compose(config, recordDir, chromePath) {
|
|
296
|
+
const recording = loadRecording(recordDir);
|
|
297
|
+
const mismatch = checkRecordingMatches(recording, config);
|
|
298
|
+
if (mismatch) throw new VideoError("scenes_changed", `${mismatch} Run \`npx foldspace record\` without --from.`);
|
|
299
|
+
run.recordingComplete = true;
|
|
300
|
+
const endCard = config.endCard && !flags["no-end-card"];
|
|
301
|
+
const plan = buildPlan({ recording, config, preset, endCard });
|
|
302
|
+
let shown = -1;
|
|
303
|
+
const rendered = await renderInChrome({
|
|
304
|
+
plan,
|
|
305
|
+
recordDir,
|
|
306
|
+
chromePath,
|
|
307
|
+
onProgress: ({ frame, of }) => {
|
|
308
|
+
const percent = Math.floor((frame / of) * 10) * 10;
|
|
309
|
+
if (percent > shown) {
|
|
310
|
+
shown = percent;
|
|
311
|
+
progress(`making the video: ${percent}%`);
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const outDir = path.join(projectDir, "video");
|
|
316
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
317
|
+
// The video holds real data: never saved with the code, in any project.
|
|
318
|
+
ensureVideoIgnored(outDir);
|
|
319
|
+
const base = `${videoBaseName(config.product)}${preset === "square" ? "-square" : ""}`;
|
|
320
|
+
const video = path.join(outDir, `${base}.mp4`);
|
|
321
|
+
const poster = path.join(outDir, `${base}.jpg`);
|
|
322
|
+
fs.writeFileSync(video, rendered.mp4);
|
|
323
|
+
if (rendered.poster) fs.writeFileSync(poster, rendered.poster);
|
|
324
|
+
return {
|
|
325
|
+
video,
|
|
326
|
+
poster: rendered.poster ? poster : null,
|
|
327
|
+
durationS: Math.round(plan.layout.totalS * 10) / 10,
|
|
328
|
+
sizeMB: Math.round((rendered.mp4.length / 1024 / 1024) * 10) / 10,
|
|
329
|
+
width: plan.width,
|
|
330
|
+
height: plan.height,
|
|
331
|
+
preset,
|
|
332
|
+
codec: rendered.done?.codec || null,
|
|
333
|
+
endCard,
|
|
334
|
+
logoSpot: rendered.done?.logo || null,
|
|
335
|
+
...(plan.fit.overS > 0
|
|
336
|
+
? {
|
|
337
|
+
note: `${Math.round(plan.fit.totalS)} s, longer than the ${TIMING.maxTotalS} s it should be at most, with the waits already shortened: drop a scene, or pick questions with shorter answers.`,
|
|
338
|
+
}
|
|
339
|
+
: {}),
|
|
340
|
+
sceneSeconds: plan.clips.filter((clip) => clip.kind === "scene").map((clip) => Math.round(clip.durationS * 10) / 10),
|
|
341
|
+
lastFrames: lastFrames(recording),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function main() {
|
|
346
|
+
if (!PRESETS[preset]) {
|
|
347
|
+
await end({ ok: false, code: "bad_preset", reason: `Unknown --preset '${preset}': landscape or square.` }, 1);
|
|
348
|
+
}
|
|
349
|
+
const checked = readScenes();
|
|
350
|
+
const config = checked.value;
|
|
351
|
+
for (const warning of checked.warnings) progress(warning);
|
|
352
|
+
|
|
353
|
+
// Before minutes of recording: can this machine's Chrome make the video?
|
|
354
|
+
const chromePath = findChrome();
|
|
355
|
+
const probe = await probeEncoder({ chromePath });
|
|
356
|
+
if (!probe.ok) {
|
|
357
|
+
await end({ ok: false, code: probe.code, reason: probe.message || needsChrome(chromePath) }, 1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let recordDir;
|
|
361
|
+
let reports = null;
|
|
362
|
+
if (flags.from) {
|
|
363
|
+
// As printed in the summary (from the project), or from where this runs.
|
|
364
|
+
const fromProject = path.resolve(projectDir, String(flags.from));
|
|
365
|
+
const fromHere = path.resolve(String(flags.from));
|
|
366
|
+
recordDir = fs.existsSync(fromProject) || !fs.existsSync(fromHere) ? fromProject : fromHere;
|
|
367
|
+
run.recordDir = recordDir;
|
|
368
|
+
} else {
|
|
369
|
+
({ recordDir, reports } = await record(config));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
run.phase = "composing";
|
|
373
|
+
progress("making the video in a hidden Chrome");
|
|
374
|
+
let made;
|
|
375
|
+
try {
|
|
376
|
+
made = await compose(config, recordDir, chromePath);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const code = error instanceof VideoError ? error.code : "compose_failed";
|
|
379
|
+
// A finished recording is never lost to a failure here: say how to remake it.
|
|
380
|
+
const said = code === "no_h264" ? needsChrome(chromePath) : String(error?.message || error);
|
|
381
|
+
await end(
|
|
382
|
+
{
|
|
383
|
+
ok: false,
|
|
384
|
+
code,
|
|
385
|
+
reason: run.recordingComplete ? `${said} ${fromAgain(recordDir)}` : said,
|
|
386
|
+
recordDir,
|
|
387
|
+
},
|
|
388
|
+
1,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const scenes = (reports ? reports.map(sceneSummary) : config.scenes.map((scene, index) => ({ n: index + 1, question: scene.question }))).map(
|
|
392
|
+
(scene, index) => ({ ...scene, inVideoS: made.sceneSeconds[index], lastFrame: made.lastFrames[index] }),
|
|
393
|
+
);
|
|
394
|
+
const { sceneSeconds: _sceneSeconds, lastFrames: _lastFrames, ...summary } = made;
|
|
395
|
+
await end(
|
|
396
|
+
{
|
|
397
|
+
ok: true,
|
|
398
|
+
...summary,
|
|
399
|
+
scenes,
|
|
400
|
+
recordDir,
|
|
401
|
+
ms: Date.now() - startedAt,
|
|
402
|
+
next: [
|
|
403
|
+
"Read the poster and each scene's lastFrame: every answer complete, none an error or \"that action isn't available\". The human watches the video",
|
|
404
|
+
"Hand over the MP4 and the poster. Say plainly: it shows real data from their account, and whether to share it is their call",
|
|
405
|
+
`To change a scene's capabilities or line, or the end card: edit ${rel(scenesFile)}, then \`npx foldspace record --from ${rel(recordDir)}\``,
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
0,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((error) => end({ ok: false, code: "failed", reason: String(error?.message || error) }, 1));
|
package/bin/share.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']
|
|
3
|
+
//
|
|
4
|
+
// inspect - sizes and checks the MP4 and its poster here; uploads nothing.
|
|
5
|
+
// Its video.bytes and poster.bytes go into the MCP tool
|
|
6
|
+
// create_video_share.
|
|
7
|
+
// upload - sends both files to Foldspace's video storage with the one-time
|
|
8
|
+
// targets create_video_share returned (its upload command), then
|
|
9
|
+
// the agent calls publish_video_share for the link.
|
|
10
|
+
//
|
|
11
|
+
// The CLI holds no Foldspace sign-in: creating and publishing a share are MCP
|
|
12
|
+
// tools, only the bytes go through here. The targets are never printed.
|
|
13
|
+
// Sharing is always the human's call: run upload only after they said yes.
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { parseArgs, printJson } from "../src/cdp-client.mjs";
|
|
18
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
19
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
20
|
+
import { ShareError, defaultVideo, inspectForShare, parseTargets, posterFor, resumableUpload } from "../src/video/share.mjs";
|
|
21
|
+
|
|
22
|
+
const { flags, rest } = parseArgs(process.argv.slice(2), ["poster", "targets"]);
|
|
23
|
+
const [action, videoArg] = rest;
|
|
24
|
+
const projectDir = path.resolve(resolveProjectDir());
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (action !== "inspect" && action !== "upload") {
|
|
28
|
+
throw new ShareError("USAGE", "Usage: foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']");
|
|
29
|
+
}
|
|
30
|
+
const videoPath = videoArg ? path.resolve(projectDir, videoArg) : defaultVideo(projectDir);
|
|
31
|
+
const posterPath = flags.poster ? path.resolve(projectDir, String(flags.poster)) : posterFor(videoPath);
|
|
32
|
+
const files = inspectForShare({ videoPath, posterPath });
|
|
33
|
+
|
|
34
|
+
if (action === "inspect") {
|
|
35
|
+
printJson({
|
|
36
|
+
ok: true,
|
|
37
|
+
video: { path: path.relative(projectDir, files.video.path), bytes: files.video.bytes, durationS: round(files.video.durationS) },
|
|
38
|
+
poster: { path: path.relative(projectDir, files.poster.path), bytes: files.poster.bytes },
|
|
39
|
+
next: "Only if the human agreed to share it: call create_video_share with a title, video_bytes and poster_bytes, then run the upload command it returns.",
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
if (!flags.targets) throw new ShareError("BAD_TARGETS", "--targets is required: run the upload command create_video_share returned.");
|
|
43
|
+
const targets = parseTargets(flags.targets);
|
|
44
|
+
await resumableUpload(targets.video, files.video.path, { label: "video" });
|
|
45
|
+
await resumableUpload(targets.poster, files.poster.path, { label: "poster" });
|
|
46
|
+
recordEvent(projectDir, "share-upload", { videoBytes: files.video.bytes });
|
|
47
|
+
printJson({
|
|
48
|
+
ok: true,
|
|
49
|
+
status: "uploaded",
|
|
50
|
+
videoBytes: files.video.bytes,
|
|
51
|
+
posterBytes: files.poster.bytes,
|
|
52
|
+
next: "Call publish_video_share with the share_id from create_video_share; it returns the public link.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const code = error instanceof ShareError ? error.code : "UNEXPECTED";
|
|
57
|
+
printJson({ ok: false, code, message: error instanceof Error ? error.message : String(error) });
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function round(seconds) {
|
|
62
|
+
return Math.round(seconds * 10) / 10;
|
|
63
|
+
}
|
package/package.json
CHANGED
package/src/attach-daemon.mjs
CHANGED
|
@@ -151,6 +151,24 @@ export async function waitForRegistration({
|
|
|
151
151
|
);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** attach prints "Test: on" when it starts with --test-mode (and "Test: off - ..." without). */
|
|
155
|
+
export function attachTestModeOn(logText) {
|
|
156
|
+
return /^Test:\s+on\b/m.test(String(logText ?? ""));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// attach marks the page with the test mode it set, so a command can see it
|
|
160
|
+
// even when attach runs in the foreground and keeps no daemon log.
|
|
161
|
+
export const PAGE_TEST_MODE_KEY = "__FOLDSPACE_TEST_MODE__";
|
|
162
|
+
|
|
163
|
+
/** Whether attach set test mode on this page; false when it cannot tell. */
|
|
164
|
+
export async function pageTestModeOn(page) {
|
|
165
|
+
try {
|
|
166
|
+
return (await page.evaluate(`return window.${PAGE_TEST_MODE_KEY} === true;`, { timeoutMs: 5000 })) === true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
export function liveDaemon(root, isAlive = isProcessAlive) {
|
|
155
173
|
const state = readDaemonState(root);
|
|
156
174
|
if (!state?.pid || !isAlive(state.pid)) return null;
|
package/src/badge-auto.mjs
CHANGED
|
@@ -17,9 +17,10 @@ export async function readBadge(page) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function writeBadge(page, payload) {
|
|
20
|
+
export async function writeBadge(page, payload, { timeoutMs } = {}) {
|
|
21
21
|
await page.evaluate(
|
|
22
22
|
`sessionStorage.setItem(${JSON.stringify(BADGE_KEY)}, ${JSON.stringify(JSON.stringify(payload))}); return true;`,
|
|
23
|
+
timeoutMs ? { timeoutMs } : undefined,
|
|
23
24
|
);
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -40,3 +41,39 @@ export async function withWorkingBadge(page, action) {
|
|
|
40
41
|
if (raised) await writeBadge(page, before ?? { state: "label" }).catch(() => {});
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `action` with nothing drawn - no pill, no corner tag - then restore what
|
|
47
|
+
* was there. For a recording: the video shows the product, not the harness.
|
|
48
|
+
* attach's badge script redraws within a second, so the wait lets it go.
|
|
49
|
+
* `register` receives the restore function as soon as the badge is down, so a
|
|
50
|
+
* run that is stopped half-way (Ctrl-C, a timeout) can put it back too: a
|
|
51
|
+
* `finally` never runs when the process is killed.
|
|
52
|
+
*/
|
|
53
|
+
export async function withBadgeOff(
|
|
54
|
+
page,
|
|
55
|
+
action,
|
|
56
|
+
{ settleMs = 1200, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), register } = {},
|
|
57
|
+
) {
|
|
58
|
+
const before = await readBadge(page);
|
|
59
|
+
let restored = false;
|
|
60
|
+
const restore = async ({ timeoutMs } = {}) => {
|
|
61
|
+
if (restored) return;
|
|
62
|
+
restored = true;
|
|
63
|
+
await writeBadge(page, before ?? { state: "label" }, { timeoutMs }).catch(() => {});
|
|
64
|
+
};
|
|
65
|
+
let lowered = false;
|
|
66
|
+
try {
|
|
67
|
+
await writeBadge(page, { state: "off" });
|
|
68
|
+
lowered = true;
|
|
69
|
+
register?.(restore);
|
|
70
|
+
await wait(settleMs);
|
|
71
|
+
} catch {
|
|
72
|
+
// no page to write to: nothing is drawn either
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return await action();
|
|
76
|
+
} finally {
|
|
77
|
+
if (lowered) await restore();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/cdp-client.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// A small CDP page client for `observe`, `ask`, `badge` and
|
|
1
|
+
// A small CDP page client for `observe`, `ask`, `badge`, `published` and
|
|
2
|
+
// `record`.
|
|
2
3
|
//
|
|
3
4
|
// It is a second client on the test window and is safe to use while
|
|
4
5
|
// `foldspace attach` is running: attach's claim on the debug port is about
|
|
@@ -178,6 +179,11 @@ export class CdpPage {
|
|
|
178
179
|
this.#listeners.get(method).push(listener);
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
off(method, listener) {
|
|
183
|
+
const listeners = this.#listeners.get(method) || [];
|
|
184
|
+
this.#listeners.set(method, listeners.filter((item) => item !== listener));
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
/** Evaluate the body of an async function in the page; returns its JSON value. */
|
|
182
188
|
async evaluate(body, { timeoutMs = 30000 } = {}) {
|
|
183
189
|
const result = await this.send(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Which Chrome to launch: CHROME_PATH, else the usual install places. Shared
|
|
2
|
+
// by `inject` (the test window) and `record` (the hidden Chrome that makes the
|
|
3
|
+
// video), so both use the same browser.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export function chromeCandidates(platform = os.platform(), env = process.env) {
|
|
10
|
+
if (platform === "darwin") {
|
|
11
|
+
return [
|
|
12
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
13
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
14
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
if (platform === "win32") {
|
|
18
|
+
// Installed for everyone (Program Files), or for this user only (LocalAppData).
|
|
19
|
+
return [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((root) => path.win32.join(root, "Google", "Chrome", "Application", "chrome.exe"));
|
|
22
|
+
}
|
|
23
|
+
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findChrome({ env = process.env, platform = os.platform(), exists = fs.existsSync } = {}) {
|
|
27
|
+
return env.CHROME_PATH || chromeCandidates(platform, env).find((candidate) => exists(candidate)) || null;
|
|
28
|
+
}
|
package/src/cli-help.mjs
CHANGED