takomi 2.1.37 → 2.1.39
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/.agents/plugins/marketplace.json +32 -0
- package/.pi/agents/architect.md +4 -2
- package/.pi/agents/designer.md +2 -0
- package/.pi/agents/orchestrator.md +4 -0
- package/.pi/prompts/design-prompt.md +3 -1
- package/.pi/prompts/genesis-prompt.md +2 -0
- package/.pi/prompts/takomi-prompt.md +6 -3
- package/.pi/takomi/policies/subagent-routing.md +9 -1
- package/.pi/takomi/policies/takomi-lifecycle-routing.md +3 -1
- package/README.md +27 -5
- package/assets/.agent/skills/takomi-flow/SKILL.md +235 -0
- package/assets/.agent/workflows/README.md +3 -3
- package/assets/.agent/workflows/mode-architect.md +7 -5
- package/assets/.agent/workflows/vibe-design.md +3 -1
- package/assets/.agent/workflows/vibe-genesis.md +2 -0
- package/assets/Takomi-Agents/custom_modes.yaml +1253 -1252
- package/assets/Takomi-Agents/vibe-architect.yaml +11 -6
- package/assets/Takomi-Agents/vibe-orchestrator.yaml +2 -2
- package/docs/features/TakomiFlow_Portable_Plugin.md +78 -0
- package/docs/takomi-flow-onboarding.md +76 -0
- package/docs/takomi-flow-public-distribution.md +94 -0
- package/package.json +19 -2
- package/plugins/takomi-codex/.codex-plugin/plugin.json +37 -0
- package/plugins/takomi-codex/assets/composer-icon.png +0 -0
- package/plugins/takomi-codex/assets/logo-dark.png +0 -0
- package/plugins/takomi-codex/assets/logo.png +0 -0
- package/plugins/takomi-codex/scripts/takomi-board.ps1 +197 -0
- package/plugins/takomi-codex/scripts/takomi-detect.ps1 +125 -0
- package/plugins/takomi-codex/scripts/takomi-doctor.ps1 +88 -0
- package/plugins/takomi-codex/scripts/takomi-harness.ps1 +52 -0
- package/plugins/takomi-codex/scripts/takomi-pi-dispatch.ps1 +93 -0
- package/plugins/takomi-codex/scripts/takomi-policy.ps1 +108 -0
- package/plugins/takomi-codex/skills/takomi-codex/SKILL.md +194 -0
- package/plugins/takomi-flow/.codex-plugin/plugin.json +36 -0
- package/plugins/takomi-flow/.mcp.json +11 -0
- package/plugins/takomi-flow/assets/capabilities.json +171 -0
- package/plugins/takomi-flow/assets/collection.schema.json +29 -0
- package/plugins/takomi-flow/assets/composer-icon.png +0 -0
- package/plugins/takomi-flow/assets/examples.json +94 -0
- package/plugins/takomi-flow/assets/logo-dark.png +0 -0
- package/plugins/takomi-flow/assets/logo.png +0 -0
- package/plugins/takomi-flow/assets/request.schema.json +67 -0
- package/plugins/takomi-flow/assets/result.schema.json +45 -0
- package/plugins/takomi-flow/assets/templates/image-request.json +13 -0
- package/plugins/takomi-flow/assets/templates/video-request.json +15 -0
- package/plugins/takomi-flow/package.json +38 -0
- package/plugins/takomi-flow/pnpm-lock.yaml +813 -0
- package/plugins/takomi-flow/references/flow-provider-contract.md +326 -0
- package/plugins/takomi-flow/scripts/lib/agent-plan.mjs +130 -0
- package/plugins/takomi-flow/scripts/lib/api.mjs +113 -0
- package/plugins/takomi-flow/scripts/lib/args.mjs +57 -0
- package/plugins/takomi-flow/scripts/lib/audit.mjs +65 -0
- package/plugins/takomi-flow/scripts/lib/browser.mjs +39 -0
- package/plugins/takomi-flow/scripts/lib/capabilities.mjs +11 -0
- package/plugins/takomi-flow/scripts/lib/collect.mjs +53 -0
- package/plugins/takomi-flow/scripts/lib/commands.mjs +175 -0
- package/plugins/takomi-flow/scripts/lib/doctor.mjs +77 -0
- package/plugins/takomi-flow/scripts/lib/examples.mjs +17 -0
- package/plugins/takomi-flow/scripts/lib/flow-outcome.mjs +90 -0
- package/plugins/takomi-flow/scripts/lib/flow-ui.mjs +165 -0
- package/plugins/takomi-flow/scripts/lib/generation.mjs +90 -0
- package/plugins/takomi-flow/scripts/lib/inspect.mjs +81 -0
- package/plugins/takomi-flow/scripts/lib/mcp-prompts.mjs +39 -0
- package/plugins/takomi-flow/scripts/lib/mcp-resources.mjs +16 -0
- package/plugins/takomi-flow/scripts/lib/mcp-tools.mjs +150 -0
- package/plugins/takomi-flow/scripts/lib/media.mjs +78 -0
- package/plugins/takomi-flow/scripts/lib/observe.mjs +54 -0
- package/plugins/takomi-flow/scripts/lib/paths.mjs +37 -0
- package/plugins/takomi-flow/scripts/lib/playwright-loader.mjs +23 -0
- package/plugins/takomi-flow/scripts/lib/prompts.mjs +72 -0
- package/plugins/takomi-flow/scripts/lib/report.mjs +141 -0
- package/plugins/takomi-flow/scripts/lib/request-validator.mjs +58 -0
- package/plugins/takomi-flow/scripts/lib/request.mjs +55 -0
- package/plugins/takomi-flow/scripts/lib/resource-files.mjs +69 -0
- package/plugins/takomi-flow/scripts/lib/result.mjs +31 -0
- package/plugins/takomi-flow/scripts/lib/review.mjs +44 -0
- package/plugins/takomi-flow/scripts/lib/selftest.mjs +153 -0
- package/plugins/takomi-flow/scripts/lib/settings-plan.mjs +39 -0
- package/plugins/takomi-flow/scripts/lib/templates.mjs +37 -0
- package/plugins/takomi-flow/scripts/lib/trusted-chrome.mjs +67 -0
- package/plugins/takomi-flow/scripts/lib/workflow.mjs +56 -0
- package/plugins/takomi-flow/scripts/mcp-server.mjs +18 -0
- package/plugins/takomi-flow/scripts/mcp-smoke.mjs +142 -0
- package/plugins/takomi-flow/scripts/takomi-flow.mjs +17 -0
- package/plugins/takomi-flow/skills/takomi-flow/SKILL.md +235 -0
- package/plugins/takomi-flow/skills/takomi-flow/agents/openai.yaml +7 -0
- package/scripts/install-takomi-flow.ps1 +85 -0
- package/src/pi-takomi-core/orchestration.ts +1 -1
- package/src/pi-takomi-core/workflows.ts +4 -4
- package/src/skills-catalog.js +11 -7
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ensureDir } from './paths.mjs';
|
|
3
|
+
import { loadPlaywright } from './playwright-loader.mjs';
|
|
4
|
+
|
|
5
|
+
export async function launchFlowBrowser(options = {}) {
|
|
6
|
+
const { chromium } = await loadPlaywright();
|
|
7
|
+
const profileDir = ensureDir(path.resolve(options.profileDir));
|
|
8
|
+
const downloadsDir = ensureDir(path.resolve(options.downloadsDir));
|
|
9
|
+
const headless = options.headless === true;
|
|
10
|
+
if (options.cdpUrl) {
|
|
11
|
+
const browser = await chromium.connectOverCDP(options.cdpUrl);
|
|
12
|
+
const context = browser.contexts()[0] || await browser.newContext({ acceptDownloads: true });
|
|
13
|
+
const page = context.pages()[0] || await context.newPage();
|
|
14
|
+
return { browser, context, page, profileDir, downloadsDir, cdpUrl: options.cdpUrl, attached: true };
|
|
15
|
+
}
|
|
16
|
+
const channel = options.browserChannel || process.env.TAKOMI_FLOW_BROWSER_CHANNEL || defaultBrowserChannel();
|
|
17
|
+
const context = await chromium.launchPersistentContext(profileDir, {
|
|
18
|
+
headless,
|
|
19
|
+
...(channel ? { channel } : {}),
|
|
20
|
+
acceptDownloads: true,
|
|
21
|
+
downloadsPath: downloadsDir,
|
|
22
|
+
viewport: { width: 1440, height: 960 },
|
|
23
|
+
});
|
|
24
|
+
const page = context.pages()[0] || await context.newPage();
|
|
25
|
+
return { context, page, profileDir, downloadsDir, browserChannel: channel };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function closeFlowBrowser(browser) {
|
|
29
|
+
if (browser.attached) {
|
|
30
|
+
await browser.browser?.close?.().catch(() => {});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await browser.context.close().catch(() => {});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultBrowserChannel() {
|
|
37
|
+
if (process.platform === 'win32' || process.platform === 'darwin') return 'chrome';
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PLUGIN_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
7
|
+
const CAPABILITIES_PATH = path.join(PLUGIN_ROOT, 'assets', 'capabilities.json');
|
|
8
|
+
|
|
9
|
+
export function loadCapabilities() {
|
|
10
|
+
return JSON.parse(fs.readFileSync(CAPABILITIES_PATH, 'utf8'));
|
|
11
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ensureDir, writeJson } from './paths.mjs';
|
|
4
|
+
import { reviewRun } from './review.mjs';
|
|
5
|
+
|
|
6
|
+
export async function collectRun(args = {}) {
|
|
7
|
+
if (!args.run) throw new Error('collect requires --run <run.json|run-dir>');
|
|
8
|
+
if (!args.targetDir) throw new Error('collect requires --target-dir <path>');
|
|
9
|
+
const targetDir = ensureDir(path.resolve(args.targetDir));
|
|
10
|
+
const review = await reviewRun({
|
|
11
|
+
run: args.run,
|
|
12
|
+
frames: args.frames || 0,
|
|
13
|
+
reportPath: args.reportPath,
|
|
14
|
+
});
|
|
15
|
+
const copied = [];
|
|
16
|
+
for (const asset of review.catalog.assets || []) {
|
|
17
|
+
if (asset.exists) copied.push(copyFile(asset.path, path.join(targetDir, 'assets')));
|
|
18
|
+
if (args.includeFrames) {
|
|
19
|
+
for (const frame of asset.frames || []) copied.push(copyFile(frame, path.join(targetDir, 'frames')));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (review.reportPath && fs.existsSync(review.reportPath)) {
|
|
23
|
+
copied.push(copyFile(review.reportPath, targetDir, 'report.md'));
|
|
24
|
+
}
|
|
25
|
+
const manifest = {
|
|
26
|
+
schemaVersion: 1,
|
|
27
|
+
status: copied.length ? 'collected' : 'empty',
|
|
28
|
+
runId: review.runId,
|
|
29
|
+
sourceRunDir: review.runDir,
|
|
30
|
+
targetDir,
|
|
31
|
+
copied,
|
|
32
|
+
review,
|
|
33
|
+
};
|
|
34
|
+
manifest.manifestPath = writeJson(path.join(targetDir, 'takomi-flow-collection.json'), manifest);
|
|
35
|
+
return manifest;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyFile(source, targetDir, name = path.basename(source)) {
|
|
39
|
+
ensureDir(targetDir);
|
|
40
|
+
const target = uniquePath(path.join(targetDir, name));
|
|
41
|
+
fs.copyFileSync(source, target);
|
|
42
|
+
return { source, target };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uniquePath(filePath) {
|
|
46
|
+
if (!fs.existsSync(filePath)) return filePath;
|
|
47
|
+
const parsed = path.parse(filePath);
|
|
48
|
+
for (let i = 1; i < 1000; i += 1) {
|
|
49
|
+
const candidate = path.join(parsed.dir, `${parsed.name}-${i}${parsed.ext}`);
|
|
50
|
+
if (!fs.existsSync(candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`Could not create unique path for ${filePath}`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { defaultProfileDir, defaultRunsDir, FLOW_URL, resolvePath } from './paths.mjs';
|
|
2
|
+
import { closeFlowBrowser, launchFlowBrowser } from './browser.mjs';
|
|
3
|
+
import { createRun, baseResult, saveResult } from './result.mjs';
|
|
4
|
+
import { capture, inspectFlowState, openFlow } from './flow-ui.mjs';
|
|
5
|
+
import { launchTrustedChrome } from './trusted-chrome.mjs';
|
|
6
|
+
import * as api from './api.mjs';
|
|
7
|
+
|
|
8
|
+
export async function handleCommand(command, args) {
|
|
9
|
+
if (command === 'prepare') return prepare(args);
|
|
10
|
+
if (command === 'workflow') return printJson(await api.workflow({
|
|
11
|
+
...args,
|
|
12
|
+
outputDir: args['output-dir'],
|
|
13
|
+
allowSpend: Boolean(args['allow-spend']),
|
|
14
|
+
extractFrames: args['extract-frames'],
|
|
15
|
+
sourceAssets: args.assets,
|
|
16
|
+
allowBrowser: Boolean(args['allow-browser']),
|
|
17
|
+
profileDir: args['profile-dir'],
|
|
18
|
+
browserChannel: args['browser-channel'],
|
|
19
|
+
cdpUrl: args['cdp-url'],
|
|
20
|
+
submit: Boolean(args.submit),
|
|
21
|
+
}));
|
|
22
|
+
if (command === 'template') return template(args);
|
|
23
|
+
if (command === 'examples') return printJson(api.examples({ name: args.name }));
|
|
24
|
+
if (command === 'trusted-chrome') return printJson(launchTrustedChrome({
|
|
25
|
+
profileDir: args['profile-dir'],
|
|
26
|
+
chromePath: args['chrome-path'],
|
|
27
|
+
port: args.port,
|
|
28
|
+
url: args.url,
|
|
29
|
+
printCommand: Boolean(args['print-command']),
|
|
30
|
+
}));
|
|
31
|
+
if (command === 'validate') return printJson(api.validate({ request: args.request }));
|
|
32
|
+
if (command === 'bootstrap') return bootstrap(args);
|
|
33
|
+
if (command === 'doctor') return doctor(args);
|
|
34
|
+
if (command === 'audit') return printJson(await api.audit({
|
|
35
|
+
profileDir: args['profile-dir'],
|
|
36
|
+
outputDir: args['output-dir'],
|
|
37
|
+
limit: args.limit,
|
|
38
|
+
}));
|
|
39
|
+
if (command === 'capabilities') return printJson(api.capabilities());
|
|
40
|
+
if (command === 'plan') return printJson(api.plan({
|
|
41
|
+
...args,
|
|
42
|
+
outputDir: args['output-dir'],
|
|
43
|
+
allowSpend: Boolean(args['allow-spend']),
|
|
44
|
+
extractFrames: args['extract-frames'],
|
|
45
|
+
sourceAssets: args.assets,
|
|
46
|
+
submit: Boolean(args.submit),
|
|
47
|
+
targetDir: args['target-dir'],
|
|
48
|
+
}));
|
|
49
|
+
if (command === 'observe') return observe(args);
|
|
50
|
+
if (command === 'smoke') return smoke(args);
|
|
51
|
+
if (command === 'generate') return printJson(await api.generate({
|
|
52
|
+
request: args.request,
|
|
53
|
+
profileDir: args['profile-dir'],
|
|
54
|
+
browserChannel: args['browser-channel'],
|
|
55
|
+
cdpUrl: args['cdp-url'],
|
|
56
|
+
headless: Boolean(args.headless),
|
|
57
|
+
}));
|
|
58
|
+
if (command === 'selftest') return printJson(await api.selftest({ outputDir: args['output-dir'] }));
|
|
59
|
+
if (command === 'inspect') return printJson(api.inspect({ run: args.run }));
|
|
60
|
+
if (command === 'latest') return printJson(api.latest({ outputDir: args['output-dir'] }));
|
|
61
|
+
if (command === 'runs') return printJson(api.runs({ outputDir: args['output-dir'], limit: args.limit }));
|
|
62
|
+
if (command === 'assets') return assets(args);
|
|
63
|
+
if (command === 'review') return printJson(await api.review({
|
|
64
|
+
run: args.run,
|
|
65
|
+
frames: args.frames || 0,
|
|
66
|
+
reportPath: args['report-path'],
|
|
67
|
+
}));
|
|
68
|
+
if (command === 'collect') return printJson(await api.collect({
|
|
69
|
+
run: args.run,
|
|
70
|
+
targetDir: args['target-dir'],
|
|
71
|
+
frames: args.frames || 0,
|
|
72
|
+
includeFrames: Boolean(args['include-frames']),
|
|
73
|
+
reportPath: args['report-path'],
|
|
74
|
+
}));
|
|
75
|
+
if (command === 'report') return printJson(api.report({
|
|
76
|
+
run: args.run,
|
|
77
|
+
outputDir: args['output-dir'],
|
|
78
|
+
limit: args.limit,
|
|
79
|
+
reportPath: args['report-path'],
|
|
80
|
+
}));
|
|
81
|
+
throw new Error(`Unknown command: ${command}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function observe(cliArgs) {
|
|
85
|
+
printJson(await api.observe({
|
|
86
|
+
profileDir: cliArgs['profile-dir'],
|
|
87
|
+
outputDir: cliArgs['output-dir'],
|
|
88
|
+
browserChannel: cliArgs['browser-channel'],
|
|
89
|
+
cdpUrl: cliArgs['cdp-url'],
|
|
90
|
+
headless: Boolean(cliArgs.headless),
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function doctor(cliArgs) {
|
|
95
|
+
const report = await api.doctor({
|
|
96
|
+
profileDir: cliArgs['profile-dir'],
|
|
97
|
+
outputDir: cliArgs['output-dir'],
|
|
98
|
+
});
|
|
99
|
+
printJson(report);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function assets(cliArgs) {
|
|
103
|
+
printJson(await api.assets({ run: cliArgs.run, frames: cliArgs.frames || 0 }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function prepare(cliArgs) {
|
|
107
|
+
printJson(api.prepare(cliArgs));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function template(cliArgs) {
|
|
111
|
+
printJson(api.template({ kind: cliArgs.kind, outputDir: cliArgs['output-dir'] }));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function bootstrap(cliArgs) {
|
|
115
|
+
const outputDir = resolvePath(cliArgs['output-dir'], defaultRunsDir());
|
|
116
|
+
const run = createRun({ outputDir }, 'bootstrap');
|
|
117
|
+
const profileDir = resolvePath(cliArgs['profile-dir'], defaultProfileDir());
|
|
118
|
+
const result = baseResult('manual_action_required', run, {
|
|
119
|
+
command: 'bootstrap',
|
|
120
|
+
flowUrl: FLOW_URL,
|
|
121
|
+
profileDir,
|
|
122
|
+
manualActions: ['Log into Google Flow in the opened browser, then close it when finished.'],
|
|
123
|
+
});
|
|
124
|
+
const browser = await launchFlowBrowser({
|
|
125
|
+
profileDir,
|
|
126
|
+
downloadsDir: run.downloadsDir,
|
|
127
|
+
browserChannel: cliArgs['browser-channel'],
|
|
128
|
+
cdpUrl: cliArgs['cdp-url'],
|
|
129
|
+
headless: Boolean(cliArgs.headless),
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
await openFlow(browser.page);
|
|
133
|
+
result.screenshots.push(await capture(browser.page, run, 'bootstrap-opened'));
|
|
134
|
+
Object.assign(result, await inspectFlowState(browser.page));
|
|
135
|
+
console.log(`[TakomiFlow] Browser opened for login/profile setup: ${FLOW_URL}`);
|
|
136
|
+
console.log('[TakomiFlow] Close the browser window when login/setup is complete.');
|
|
137
|
+
if (!browser.attached) await browser.context.waitForEvent('close', { timeout: 30 * 60 * 1000 }).catch(() => {});
|
|
138
|
+
} finally {
|
|
139
|
+
await closeFlowBrowser(browser);
|
|
140
|
+
saveResult(run, result);
|
|
141
|
+
console.log(`[TakomiFlow] Result: ${result.metadataPath}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function smoke(cliArgs) {
|
|
146
|
+
const outputDir = resolvePath(cliArgs['output-dir'], defaultRunsDir());
|
|
147
|
+
const run = createRun({ outputDir }, 'smoke');
|
|
148
|
+
const profileDir = resolvePath(cliArgs['profile-dir'], defaultProfileDir());
|
|
149
|
+
const browser = await launchFlowBrowser({
|
|
150
|
+
profileDir,
|
|
151
|
+
downloadsDir: run.downloadsDir,
|
|
152
|
+
browserChannel: cliArgs['browser-channel'],
|
|
153
|
+
cdpUrl: cliArgs['cdp-url'],
|
|
154
|
+
headless: Boolean(cliArgs.headless),
|
|
155
|
+
});
|
|
156
|
+
const result = baseResult('ok', run, { command: 'smoke', flowUrl: FLOW_URL, profileDir });
|
|
157
|
+
try {
|
|
158
|
+
await openFlow(browser.page);
|
|
159
|
+
result.screenshots.push(await capture(browser.page, run, 'smoke-flow'));
|
|
160
|
+
const state = await inspectFlowState(browser.page);
|
|
161
|
+
Object.assign(result, state);
|
|
162
|
+
if (state.manualActions.length) result.status = 'manual_action_required';
|
|
163
|
+
} catch (error) {
|
|
164
|
+
result.status = 'failed';
|
|
165
|
+
result.errors.push(error.message);
|
|
166
|
+
} finally {
|
|
167
|
+
await closeFlowBrowser(browser);
|
|
168
|
+
saveResult(run, result);
|
|
169
|
+
printJson(result);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function printJson(value) {
|
|
174
|
+
console.log(JSON.stringify(value, null, 2));
|
|
175
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { defaultProfileDir, defaultRunsDir, ensureDir, FLOW_URL } from './paths.mjs';
|
|
6
|
+
import { loadPlaywright } from './playwright-loader.mjs';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export async function runDoctor(options = {}) {
|
|
11
|
+
const profileDir = path.resolve(options.profileDir || defaultProfileDir());
|
|
12
|
+
const outputDir = path.resolve(options.outputDir || defaultRunsDir());
|
|
13
|
+
const checks = [];
|
|
14
|
+
checks.push({ name: 'node', status: 'ok', version: process.version });
|
|
15
|
+
checks.push(await checkPlaywright());
|
|
16
|
+
checks.push(await checkExecutable('ffmpeg', ['-version']));
|
|
17
|
+
checks.push(await checkExecutable('ffprobe', ['-version']));
|
|
18
|
+
checks.push(checkPath('profileDir', profileDir, false));
|
|
19
|
+
checks.push(checkPath('outputDir', outputDir, true));
|
|
20
|
+
const failed = checks.some(check => check.status === 'failed');
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
status: failed ? 'failed' : 'ok',
|
|
24
|
+
flowUrl: FLOW_URL,
|
|
25
|
+
profileDir,
|
|
26
|
+
outputDir,
|
|
27
|
+
checks,
|
|
28
|
+
recommendations: recommendations(checks),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function checkPlaywright() {
|
|
33
|
+
try {
|
|
34
|
+
await loadPlaywright();
|
|
35
|
+
return { name: 'playwright', status: 'ok' };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { name: 'playwright', status: 'failed', message: error.message };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function checkExecutable(name, args) {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout, stderr } = await execFileAsync(name, args, { timeout: 10000 });
|
|
44
|
+
const version = `${stdout || stderr}`.split(/\r?\n/).find(Boolean) || '';
|
|
45
|
+
return { name, status: 'ok', version };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { name, status: 'warning', message: `${name} is unavailable: ${error.message}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkPath(name, targetPath, createIfMissing) {
|
|
52
|
+
if (fs.existsSync(targetPath)) {
|
|
53
|
+
return { name, status: 'ok', path: targetPath, exists: true };
|
|
54
|
+
}
|
|
55
|
+
if (createIfMissing) {
|
|
56
|
+
ensureDir(targetPath);
|
|
57
|
+
return { name, status: 'ok', path: targetPath, exists: true, created: true };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name,
|
|
61
|
+
status: 'warning',
|
|
62
|
+
path: targetPath,
|
|
63
|
+
exists: false,
|
|
64
|
+
message: 'Profile does not exist yet. Run bootstrap to create it and log into Flow.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recommendations(checks) {
|
|
69
|
+
return checks
|
|
70
|
+
.filter(check => check.status !== 'ok')
|
|
71
|
+
.map(check => {
|
|
72
|
+
if (check.name === 'playwright') return 'Run pnpm install in the TakomiFlow plugin folder.';
|
|
73
|
+
if (check.name === 'ffmpeg' || check.name === 'ffprobe') return 'Install FFmpeg if media metadata or frame extraction is needed.';
|
|
74
|
+
if (check.name === 'profileDir') return 'Run bootstrap in headed mode and log into Google Flow manually.';
|
|
75
|
+
return check.message || `Review ${check.name}.`;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PLUGIN_ROOT = path.resolve(MODULE_DIR, '..', '..');
|
|
7
|
+
const EXAMPLES_PATH = path.join(PLUGIN_ROOT, 'assets', 'examples.json');
|
|
8
|
+
|
|
9
|
+
export function loadExamples(name = undefined) {
|
|
10
|
+
const data = JSON.parse(fs.readFileSync(EXAMPLES_PATH, 'utf8'));
|
|
11
|
+
if (!name) return data;
|
|
12
|
+
const example = data.examples.find(item => item.name === name);
|
|
13
|
+
if (!example) {
|
|
14
|
+
throw new Error(`Unknown example "${name}". Available: ${data.examples.map(item => item.name).join(', ')}`);
|
|
15
|
+
}
|
|
16
|
+
return { schemaVersion: data.schemaVersion, example };
|
|
17
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { hasText, openGeneratedMedia } from './flow-ui.mjs';
|
|
2
|
+
|
|
3
|
+
export async function handleGenerationFollowups(page, request) {
|
|
4
|
+
if (await hasText(page, /which of these durations would you prefer/i)) {
|
|
5
|
+
await chooseDuration(page, request.durationSeconds || 4);
|
|
6
|
+
await page.waitForTimeout(2000);
|
|
7
|
+
}
|
|
8
|
+
if (await hasText(page, /costing\s+\d+\s+credits/i)) {
|
|
9
|
+
return clickApprove(page);
|
|
10
|
+
}
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function waitForGenerationOutcome(page, request, options = {}) {
|
|
15
|
+
const timeoutMs = Number(options.timeoutMs || process.env.TAKOMI_FLOW_WAIT_MS || 300000);
|
|
16
|
+
const intervalMs = Number(options.intervalMs || process.env.TAKOMI_FLOW_POLL_MS || 5000);
|
|
17
|
+
const started = Date.now();
|
|
18
|
+
let approved = false;
|
|
19
|
+
while (Date.now() - started < timeoutMs) {
|
|
20
|
+
if (await hasText(page, /which of these durations would you prefer/i)) {
|
|
21
|
+
await chooseDuration(page, request.durationSeconds || 4);
|
|
22
|
+
}
|
|
23
|
+
if (await hasText(page, /costing\s+\d+\s+credits/i)) {
|
|
24
|
+
approved = await clickApprove(page) || approved;
|
|
25
|
+
}
|
|
26
|
+
if (await page.locator('button').filter({ hasText: /download\s*Download/i }).count().catch(() => 0)) {
|
|
27
|
+
return { status: 'download_ready', approved };
|
|
28
|
+
}
|
|
29
|
+
if (await page.locator('img[src*="media.getMediaUrlRedirect"], video').count().catch(() => 0)) {
|
|
30
|
+
await openGeneratedMedia(page);
|
|
31
|
+
return { status: 'media_ready', approved };
|
|
32
|
+
}
|
|
33
|
+
if (await hasVisibleText(page, /\b\d{1,3}%\b|generating|waiting in the queue|been scheduled/i)) {
|
|
34
|
+
await page.waitForTimeout(intervalMs);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (await hasVisibleText(page, /failed|oops, something went wrong/i)) {
|
|
38
|
+
return { status: 'failed', approved };
|
|
39
|
+
}
|
|
40
|
+
await page.waitForTimeout(intervalMs);
|
|
41
|
+
}
|
|
42
|
+
return { status: 'timeout', approved };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function hasVisibleText(page, pattern) {
|
|
46
|
+
return page.locator('body').evaluate((body, source) => {
|
|
47
|
+
const regex = new RegExp(source, 'i');
|
|
48
|
+
const isVisible = element => {
|
|
49
|
+
const style = window.getComputedStyle(element);
|
|
50
|
+
const rect = element.getBoundingClientRect();
|
|
51
|
+
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
|
52
|
+
};
|
|
53
|
+
const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT);
|
|
54
|
+
let node = walker.nextNode();
|
|
55
|
+
while (node) {
|
|
56
|
+
const visibleChildCount = [...node.children].filter(isVisible).length;
|
|
57
|
+
if (visibleChildCount === 0 && isVisible(node) && regex.test(node.innerText || node.textContent || '')) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
node = walker.nextNode();
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}, pattern.source).catch(() => false);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function chooseDuration(page, requested) {
|
|
67
|
+
const options = [4, 6, 8, 10];
|
|
68
|
+
const closest = options.reduce((best, item) => (
|
|
69
|
+
Math.abs(item - requested) < Math.abs(best - requested) ? item : best
|
|
70
|
+
), options[0]);
|
|
71
|
+
const option = page.getByText(new RegExp(`^${closest} seconds$`, 'i')).first();
|
|
72
|
+
if (await option.count().catch(() => 0)) await option.click({ timeout: 10000 }).catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function clickApprove(page) {
|
|
76
|
+
const candidates = [
|
|
77
|
+
page.getByText('Approve', { exact: true }).first(),
|
|
78
|
+
page.locator('div,button,[role="button"]').filter({ hasText: /^\s*check\s*Approve\s*$/i }).first(),
|
|
79
|
+
page.locator('text=Approve').first(),
|
|
80
|
+
];
|
|
81
|
+
for (const locator of candidates) {
|
|
82
|
+
if (!(await locator.count().catch(() => 0))) continue;
|
|
83
|
+
try {
|
|
84
|
+
await locator.click({ timeout: 10000, force: true });
|
|
85
|
+
await page.waitForTimeout(1000);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { FLOW_URL } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
export async function openFlow(page) {
|
|
6
|
+
await page.goto(FLOW_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
|
7
|
+
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function capture(page, run, name) {
|
|
11
|
+
const filePath = path.join(run.screenshotsDir, `${name}.png`);
|
|
12
|
+
await page.screenshot({ path: filePath, fullPage: true }).catch(() => null);
|
|
13
|
+
return filePath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function inspectFlowState(page) {
|
|
17
|
+
const title = await page.title().catch(() => '');
|
|
18
|
+
const url = page.url();
|
|
19
|
+
const text = (await page.locator('body').innerText({ timeout: 5000 }).catch(() => '')).slice(0, 5000);
|
|
20
|
+
const lower = text.toLowerCase();
|
|
21
|
+
const manualActions = [];
|
|
22
|
+
if (lower.includes('sign in') || lower.includes('log in')) {
|
|
23
|
+
manualActions.push('Sign into Google/Flow in the opened browser.');
|
|
24
|
+
}
|
|
25
|
+
if (lower.includes('captcha') || lower.includes('verify')) {
|
|
26
|
+
manualActions.push('Complete the verification challenge in the browser.');
|
|
27
|
+
}
|
|
28
|
+
if (lower.includes('quota') || lower.includes('limit') || lower.includes('credits')) {
|
|
29
|
+
manualActions.push('Review Flow quota or credits message in the browser.');
|
|
30
|
+
}
|
|
31
|
+
return { title, url, manualActions, textSample: text };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function ensureProjectEditor(page) {
|
|
35
|
+
if (page.url().includes('/project/')) return waitForProjectEditor(page);
|
|
36
|
+
const candidates = [
|
|
37
|
+
page.getByRole('button', { name: /new project/i }).first(),
|
|
38
|
+
page.locator('button').filter({ hasText: /new project/i }).first(),
|
|
39
|
+
page.getByText('New project').first(),
|
|
40
|
+
];
|
|
41
|
+
for (const locator of candidates) {
|
|
42
|
+
if (!(await locator.count().catch(() => 0))) continue;
|
|
43
|
+
try {
|
|
44
|
+
await locator.click({ timeout: 10000 });
|
|
45
|
+
await page.waitForURL(/\/project\//, { timeout: 30000 }).catch(() => {});
|
|
46
|
+
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
47
|
+
return page.url().includes('/project/') && await waitForProjectEditor(page);
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function waitForProjectEditor(page) {
|
|
54
|
+
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
55
|
+
const ready = page.locator('body').filter({ hasText: /what do you want to create|start creating|add media/i });
|
|
56
|
+
await ready.waitFor({ state: 'visible', timeout: 60000 }).catch(() => {});
|
|
57
|
+
return (await page.locator('div[role="textbox"], textarea:not(.g-recaptcha-response)').count().catch(() => 0)) > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fillPrompt(page, prompt) {
|
|
61
|
+
const candidates = [
|
|
62
|
+
page.locator('div[role="textbox"]').filter({ hasText: /what do you want to create/i }).last(),
|
|
63
|
+
page.locator('div[role="textbox"]').last(),
|
|
64
|
+
page.locator('textarea:not(.g-recaptcha-response):not([name*="recaptcha"])').last(),
|
|
65
|
+
];
|
|
66
|
+
for (const locator of candidates) {
|
|
67
|
+
if (!(await locator.count().catch(() => 0))) continue;
|
|
68
|
+
try {
|
|
69
|
+
await locator.click({ timeout: 8000 });
|
|
70
|
+
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A').catch(() => {});
|
|
71
|
+
await page.keyboard.type(prompt, { delay: 2 });
|
|
72
|
+
const matched = await page.locator('body').filter({ hasText: prompt.slice(0, 40) }).count().catch(() => 0);
|
|
73
|
+
if (matched) return true;
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function submitGeneration(page) {
|
|
80
|
+
const preferred = [
|
|
81
|
+
page.locator('button').filter({ hasText: /arrow_forward\s*Create/i }).last(),
|
|
82
|
+
page.getByRole('button', { name: /^Create$/i }).last(),
|
|
83
|
+
page.locator('button').filter({ hasText: /^Create$/i }).last(),
|
|
84
|
+
];
|
|
85
|
+
for (const button of preferred) {
|
|
86
|
+
if (!(await button.count().catch(() => 0))) continue;
|
|
87
|
+
try {
|
|
88
|
+
await button.click({ timeout: 8000 });
|
|
89
|
+
return true;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
const labels = /submit|start/i;
|
|
93
|
+
const buttons = [
|
|
94
|
+
page.getByRole('button', { name: labels }).first(),
|
|
95
|
+
page.locator('button').filter({ hasText: labels }).first(),
|
|
96
|
+
];
|
|
97
|
+
for (const button of buttons) {
|
|
98
|
+
if (!(await button.count().catch(() => 0))) continue;
|
|
99
|
+
try {
|
|
100
|
+
await button.click({ timeout: 8000 });
|
|
101
|
+
return true;
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function tryDownloadAssets(page, run, maxDownloads = 1) {
|
|
108
|
+
const assets = [];
|
|
109
|
+
await openGeneratedMedia(page);
|
|
110
|
+
for (let i = 0; i < maxDownloads; i += 1) {
|
|
111
|
+
const button = page.locator('button').filter({ hasText: /download\s*Download|download/i }).first();
|
|
112
|
+
if (!(await button.count().catch(() => 0))) break;
|
|
113
|
+
const [completed] = await Promise.all([
|
|
114
|
+
page.waitForEvent('download', { timeout: 30000 }).catch(() => null),
|
|
115
|
+
button.click({ timeout: 5000 }).catch(() => null),
|
|
116
|
+
]);
|
|
117
|
+
if (!completed) {
|
|
118
|
+
const imagePath = await downloadLargestRenderedImage(page, run, i).catch(() => null);
|
|
119
|
+
if (!imagePath) break;
|
|
120
|
+
assets.push(imagePath);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const suggested = completed.suggestedFilename();
|
|
124
|
+
const filePath = path.join(run.downloadsDir, suggested);
|
|
125
|
+
await completed.saveAs(filePath);
|
|
126
|
+
assets.push(filePath);
|
|
127
|
+
}
|
|
128
|
+
return assets;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function downloadLargestRenderedImage(page, run, index) {
|
|
132
|
+
const images = await page.locator('img').evaluateAll(items => items
|
|
133
|
+
.map(img => ({ src: img.src, width: img.naturalWidth, height: img.naturalHeight }))
|
|
134
|
+
.filter(item => item.src && item.width >= 256 && item.height >= 256)
|
|
135
|
+
.sort((a, b) => (b.width * b.height) - (a.width * a.height)));
|
|
136
|
+
if (!images.length) return null;
|
|
137
|
+
const response = await page.context().request.get(images[0].src, { timeout: 30000 });
|
|
138
|
+
if (!response.ok()) return null;
|
|
139
|
+
const filePath = path.join(run.downloadsDir, `flow-image-${index + 1}.png`);
|
|
140
|
+
fs.writeFileSync(filePath, await response.body());
|
|
141
|
+
return filePath;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function openGeneratedMedia(page) {
|
|
145
|
+
if (await page.locator('button').filter({ hasText: /download\s*Download/i }).count().catch(() => 0)) return true;
|
|
146
|
+
const candidates = [
|
|
147
|
+
page.getByAltText(/video thumbnail|generated image/i).first(),
|
|
148
|
+
page.locator('img[src*="media.getMediaUrlRedirect"]').last(),
|
|
149
|
+
page.locator('[role="button"]').filter({ hasText: /play_circle/i }).first(),
|
|
150
|
+
page.locator('button').filter({ hasText: /play_circle/i }).first(),
|
|
151
|
+
];
|
|
152
|
+
for (const locator of candidates) {
|
|
153
|
+
if (!(await locator.count().catch(() => 0))) continue;
|
|
154
|
+
try {
|
|
155
|
+
await locator.click({ timeout: 10000 });
|
|
156
|
+
await page.waitForTimeout(3000);
|
|
157
|
+
return true;
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function hasText(page, pattern) {
|
|
164
|
+
return (await page.locator('body').filter({ hasText: pattern }).count().catch(() => 0)) > 0;
|
|
165
|
+
}
|