@playcraft/cli 0.1.60-beta → 0.1.61-beta
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/dist/project-skills/builtin-import.js +2 -2
- package/dist/skill-registry/messages.js +2 -2
- package/dist/skill-registry/platform-package.js +2 -4
- package/dist/workspace-runtime/codex/app-server.js +24 -1
- package/dist/workspace-runtime/codex/config-toml.js +9 -5
- package/dist/workspace-runtime/codex/delivery-tools.js +3 -10
- package/dist/workspace-runtime/codex/launcher-client.js +25 -13
- package/dist/workspace-runtime/codex/production-skill-tools.js +95 -0
- package/dist/workspace-runtime/processes/skill-command.js +130 -0
- package/dist/workspace-runtime/serve.js +7 -1
- package/dist/workspace-runtime/server/accept-operation.js +10 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -3
- package/dist/workspace-runtime/server/dispatch.js +39 -1
- package/dist/workspace-runtime/server/http.js +25 -3
- package/dist/workspace-runtime/server/operation-record.js +4 -1
- package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
- package/dist/workspace-runtime/workspaces/developer-command.js +65 -64
- package/dist/workspace-runtime/workspaces/directories.js +4 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +3 -1
- package/dist/workspace-runtime/workspaces/platform-export.js +3 -3
- package/dist/workspace-runtime/workspaces/procedural-3d/candidate.js +129 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/contracts.js +34 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/files.js +83 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/host.js +595 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/render-worker.js +159 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-contracts.js +257 -0
- package/dist/workspace-runtime/workspaces/procedural-3d/review-page.js +26 -0
- package/dist/workspace-runtime/workspaces/skill-execution-context.js +69 -0
- package/dist/workspace-runtime/workspaces/skills.js +15 -5
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +7 -0
- package/project-templates/empty/AGENTS.md +10 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { chromium } from 'playwright-core';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { digest } from './files.js';
|
|
8
|
+
import { canonicalJson, reviewProfileHash, validateRenderState, } from './review-contracts.js';
|
|
9
|
+
/** Launched as the development user; Vite and Chromium die with the managed process group. */
|
|
10
|
+
async function main() {
|
|
11
|
+
let raw = '';
|
|
12
|
+
for await (const chunk of process.stdin) {
|
|
13
|
+
raw += chunk;
|
|
14
|
+
if (raw.length > 64 * 1024)
|
|
15
|
+
throw new Error('render_input_limit');
|
|
16
|
+
}
|
|
17
|
+
const input = JSON.parse(raw);
|
|
18
|
+
if (input.reviewProfile &&
|
|
19
|
+
reviewProfileHash(input.reviewProfile.profile) !== input.reviewProfile.hash)
|
|
20
|
+
throw new Error('render_profile_mismatch');
|
|
21
|
+
const project = createRequire(join(input.projectRoot, 'package.json'));
|
|
22
|
+
const vite = await import(pathToFileURL(project.resolve('vite')).href);
|
|
23
|
+
const three = join(dirname(project.resolve('three')), 'three.module.js');
|
|
24
|
+
const threeVersion = JSON.parse(await readFile(join(dirname(three), '../package.json'), 'utf8')).version;
|
|
25
|
+
const server = await vite.createServer({
|
|
26
|
+
configFile: false,
|
|
27
|
+
root: input.sourceRoot,
|
|
28
|
+
logLevel: 'silent',
|
|
29
|
+
resolve: { alias: [{ find: /^three$/, replacement: three }] },
|
|
30
|
+
server: {
|
|
31
|
+
host: '127.0.0.1',
|
|
32
|
+
port: 0,
|
|
33
|
+
strictPort: false,
|
|
34
|
+
fs: { strict: true, allow: [input.sourceRoot, dirname(three)] },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
let browser;
|
|
38
|
+
try {
|
|
39
|
+
await server.listen();
|
|
40
|
+
const address = server.httpServer.address();
|
|
41
|
+
const origin = `http://127.0.0.1:${address.port}`;
|
|
42
|
+
browser = await chromium.launch({
|
|
43
|
+
executablePath: process.env.PLAYCRAFT_CHROMIUM_PATH || '/usr/bin/chromium',
|
|
44
|
+
headless: true,
|
|
45
|
+
args: [
|
|
46
|
+
'--disable-dev-shm-usage',
|
|
47
|
+
'--enable-unsafe-swiftshader',
|
|
48
|
+
'--use-gl=angle',
|
|
49
|
+
'--use-angle=swiftshader',
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
const context = await browser.newContext({
|
|
53
|
+
viewport: { width: 768, height: 768 },
|
|
54
|
+
deviceScaleFactor: 1,
|
|
55
|
+
serviceWorkers: 'block',
|
|
56
|
+
});
|
|
57
|
+
const errors = [];
|
|
58
|
+
await context.route('**/*', (route) => {
|
|
59
|
+
const url = route.request().url();
|
|
60
|
+
if (url === `${origin}/favicon.ico`)
|
|
61
|
+
return route.fulfill({ status: 204 });
|
|
62
|
+
return url.startsWith(`${origin}/`) || /^(data|blob):/.test(url)
|
|
63
|
+
? route.continue()
|
|
64
|
+
: route.abort('blockedbyclient');
|
|
65
|
+
});
|
|
66
|
+
const page = await context.newPage();
|
|
67
|
+
page.on('pageerror', (error) => errors.push(error.message));
|
|
68
|
+
page.on('console', (message) => {
|
|
69
|
+
if (message.type() === 'error')
|
|
70
|
+
errors.push(message.text());
|
|
71
|
+
});
|
|
72
|
+
page.on('requestfailed', (request) => errors.push(`${request.url()}: ${request.failure()?.errorText}`));
|
|
73
|
+
await page.goto(`${origin}/${input.pagePath.split('/').map(encodeURIComponent).join('/')}`, {
|
|
74
|
+
waitUntil: 'networkidle',
|
|
75
|
+
timeout: 30_000,
|
|
76
|
+
});
|
|
77
|
+
await page.waitForFunction('Boolean(window.__PLAYCRAFT_SCULPT__?.ready)', undefined, {
|
|
78
|
+
timeout: 30_000,
|
|
79
|
+
});
|
|
80
|
+
await page.evaluate('Promise.resolve(window.__PLAYCRAFT_SCULPT__.ready)');
|
|
81
|
+
const renderer = await page.evaluate(`(() => {const c=document.querySelector('canvas'); const g=c?.getContext('webgl2')||c?.getContext('webgl'); if(!g) throw new Error('webgl_unavailable'); const e=g.getExtension('WEBGL_debug_renderer_info'); return {renderer:g.getParameter(e?e.UNMASKED_RENDERER_WEBGL:g.RENDERER),vendor:g.getParameter(e?e.UNMASKED_VENDOR_WEBGL:g.VENDOR)};})()`);
|
|
82
|
+
await mkdir(input.outputRoot, { recursive: true });
|
|
83
|
+
const captures = [];
|
|
84
|
+
const states = {};
|
|
85
|
+
const captureState = async (view, stripped = false) => {
|
|
86
|
+
if (!input.reviewProfile)
|
|
87
|
+
return;
|
|
88
|
+
states[view] = validateRenderState(await page.evaluate('window.__PLAYCRAFT_SCULPT__.getRenderState()'), input.reviewProfile.profile, view, stripped);
|
|
89
|
+
};
|
|
90
|
+
for (const view of input.views) {
|
|
91
|
+
await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView(${JSON.stringify(view)})).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
|
|
92
|
+
await page.screenshot({
|
|
93
|
+
path: join(input.outputRoot, `${view}.png`),
|
|
94
|
+
animations: 'disabled',
|
|
95
|
+
});
|
|
96
|
+
await captureState(view);
|
|
97
|
+
captures.push({ view, file: `${view}.png` });
|
|
98
|
+
}
|
|
99
|
+
await page.evaluate(`Promise.resolve(window.__PLAYCRAFT_SCULPT__.setView('hero')).then(()=>window.__PLAYCRAFT_SCULPT__.setMapStripped(true)).then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))`);
|
|
100
|
+
await page.screenshot({
|
|
101
|
+
path: join(input.outputRoot, 'map-stripped.png'),
|
|
102
|
+
animations: 'disabled',
|
|
103
|
+
});
|
|
104
|
+
captures.push({ view: 'map-stripped', file: 'map-stripped.png' });
|
|
105
|
+
await captureState('map-stripped', true);
|
|
106
|
+
const reference = await sharp(input.referencePath)
|
|
107
|
+
.resize(768, 768, { fit: 'contain', background: '#e9e9e9' })
|
|
108
|
+
.png()
|
|
109
|
+
.toBuffer();
|
|
110
|
+
const hero = await sharp(join(input.outputRoot, `${input.views.includes('hero') ? 'hero' : input.views[0]}.png`))
|
|
111
|
+
.resize(768, 768)
|
|
112
|
+
.png()
|
|
113
|
+
.toBuffer();
|
|
114
|
+
await sharp({ create: { width: 1536, height: 768, channels: 3, background: '#e9e9e9' } })
|
|
115
|
+
.composite([
|
|
116
|
+
{ input: reference, left: 0, top: 0 },
|
|
117
|
+
{ input: hero, left: 768, top: 0 },
|
|
118
|
+
])
|
|
119
|
+
.png()
|
|
120
|
+
.toFile(join(input.outputRoot, 'comparison.png'));
|
|
121
|
+
captures.push({ view: 'comparison', file: 'comparison.png' });
|
|
122
|
+
let renderReceipt;
|
|
123
|
+
if (input.reviewProfile) {
|
|
124
|
+
const bytes = canonicalJson({
|
|
125
|
+
schemaVersion: 1,
|
|
126
|
+
reviewProfileHash: input.reviewProfile.hash,
|
|
127
|
+
profile: input.reviewProfile.profile,
|
|
128
|
+
views: states,
|
|
129
|
+
});
|
|
130
|
+
await writeFile(join(input.outputRoot, 'render-state.json'), bytes, { flag: 'wx' });
|
|
131
|
+
renderReceipt = { file: 'render-state.json', hash: digest(bytes) };
|
|
132
|
+
}
|
|
133
|
+
await page.evaluate('window.__PLAYCRAFT_SCULPT__.dispose?.()');
|
|
134
|
+
if (errors.length)
|
|
135
|
+
throw new Error(`render_errors: ${errors.join('\n').slice(0, 4000)}`);
|
|
136
|
+
return {
|
|
137
|
+
captures,
|
|
138
|
+
renderer,
|
|
139
|
+
browser: browser.version(),
|
|
140
|
+
node: process.version,
|
|
141
|
+
three: threeVersion,
|
|
142
|
+
vite: vite.version,
|
|
143
|
+
viewport: { width: 768, height: 768, deviceScaleFactor: 1 },
|
|
144
|
+
ready: true,
|
|
145
|
+
...(renderReceipt ? { renderReceipt } : {}),
|
|
146
|
+
errors,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
await browser?.close();
|
|
151
|
+
await server.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
main()
|
|
155
|
+
.then((result) => process.stdout.write(JSON.stringify({ ok: true, result })))
|
|
156
|
+
.catch((error) => {
|
|
157
|
+
process.stderr.write(String(error));
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { digest, relativeFile } from './files.js';
|
|
3
|
+
export const REVIEW_DIRECTORY = '__playcraft-review';
|
|
4
|
+
export const REVIEW_PAGE = `${REVIEW_DIRECTORY}/index.html`;
|
|
5
|
+
export const REVIEW_VIEWS = ['front', 'right', 'back', 'left', 'hero'];
|
|
6
|
+
const finite = z.number().finite();
|
|
7
|
+
const coordinate = finite.min(-100000).max(100000);
|
|
8
|
+
const vector = z.tuple([coordinate, coordinate, coordinate]);
|
|
9
|
+
const projectedVector = z.tuple([finite, finite, finite]);
|
|
10
|
+
const matrix = z.array(finite).length(16);
|
|
11
|
+
export const reviewProfileSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
schemaVersion: z.literal(1),
|
|
14
|
+
fovDegrees: finite.min(1).max(120),
|
|
15
|
+
near: finite.min(0.0001).max(1000),
|
|
16
|
+
far: finite.max(100000),
|
|
17
|
+
target: vector,
|
|
18
|
+
views: z
|
|
19
|
+
.object({ front: vector, right: vector, back: vector, left: vector, hero: vector })
|
|
20
|
+
.strict(),
|
|
21
|
+
background: z
|
|
22
|
+
.string()
|
|
23
|
+
.regex(/^#[a-fA-F0-9]{6}$/)
|
|
24
|
+
.transform((value) => value.toLowerCase()),
|
|
25
|
+
keyLightPosition: vector,
|
|
26
|
+
referenceCameraStatus: z.literal('estimated'),
|
|
27
|
+
})
|
|
28
|
+
.strict()
|
|
29
|
+
.superRefine((profile, context) => {
|
|
30
|
+
if (profile.far < profile.near + 0.0001)
|
|
31
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid camera range' });
|
|
32
|
+
for (const position of Object.values(profile.views)) {
|
|
33
|
+
const distance = Math.hypot(...position.map((value, index) => value - profile.target[index]));
|
|
34
|
+
if (distance <= profile.near || distance >= profile.far)
|
|
35
|
+
context.addIssue({
|
|
36
|
+
code: z.ZodIssueCode.custom,
|
|
37
|
+
message: 'invalid camera target distance',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
/** A bounded canonical JSON (JavaScript Object Notation, JavaScript对象表示法) encoding. */
|
|
42
|
+
export function canonicalJson(value) {
|
|
43
|
+
let nodes = 0;
|
|
44
|
+
const normalize = (input, depth = 0) => {
|
|
45
|
+
if (++nodes > 4096 || depth > 12)
|
|
46
|
+
throw new Error('procedural_skill_invalid_review_json');
|
|
47
|
+
if (input === null || typeof input === 'string' || typeof input === 'boolean')
|
|
48
|
+
return input;
|
|
49
|
+
if (typeof input === 'number' && Number.isFinite(input))
|
|
50
|
+
return input;
|
|
51
|
+
if (Array.isArray(input))
|
|
52
|
+
return input.map((item) => normalize(item, depth + 1));
|
|
53
|
+
if (input && typeof input === 'object' && Object.getPrototypeOf(input) === Object.prototype)
|
|
54
|
+
return Object.fromEntries(Object.keys(input)
|
|
55
|
+
.sort()
|
|
56
|
+
.map((key) => [key, normalize(input[key], depth + 1)]));
|
|
57
|
+
throw new Error('procedural_skill_invalid_review_json');
|
|
58
|
+
};
|
|
59
|
+
const encoded = JSON.stringify(normalize(value));
|
|
60
|
+
if (Buffer.byteLength(encoded) > 64 * 1024)
|
|
61
|
+
throw new Error('procedural_skill_invalid_review_json');
|
|
62
|
+
return encoded;
|
|
63
|
+
}
|
|
64
|
+
export const reviewProfileHash = (profile) => digest(canonicalJson(profile));
|
|
65
|
+
export const captureV2Schema = z
|
|
66
|
+
.object({
|
|
67
|
+
files: z.array(z.string()).min(1).max(252),
|
|
68
|
+
sourcePaths: z.array(z.string()).min(1).max(128),
|
|
69
|
+
views: z.array(z.enum(REVIEW_VIEWS)).min(3).max(5).default(['front', 'right', 'hero']),
|
|
70
|
+
review: z
|
|
71
|
+
.object({
|
|
72
|
+
factoryPath: z.string(),
|
|
73
|
+
exportName: z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,79}$/),
|
|
74
|
+
parameters: z.record(z.unknown()).optional(),
|
|
75
|
+
profile: reviewProfileSchema,
|
|
76
|
+
})
|
|
77
|
+
.strict(),
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
.superRefine((input, context) => {
|
|
81
|
+
const invalid = () => context.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid controlled review input' });
|
|
82
|
+
if (new Set(input.views).size !== input.views.length || !input.views.includes('hero'))
|
|
83
|
+
invalid();
|
|
84
|
+
if (!input.sourcePaths.includes(input.review.factoryPath) ||
|
|
85
|
+
!/\.(?:[cm]?[jt]sx?)$/.test(input.review.factoryPath))
|
|
86
|
+
invalid();
|
|
87
|
+
if (input.files.some((path) => path.toLowerCase() === REVIEW_DIRECTORY ||
|
|
88
|
+
path.toLowerCase().startsWith(`${REVIEW_DIRECTORY}/`)))
|
|
89
|
+
invalid();
|
|
90
|
+
try {
|
|
91
|
+
relativeFile(input.review.factoryPath);
|
|
92
|
+
if (input.review.parameters &&
|
|
93
|
+
Buffer.byteLength(canonicalJson(input.review.parameters)) > 16 * 1024)
|
|
94
|
+
invalid();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
invalid();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const renderStateSchema = z
|
|
101
|
+
.object({
|
|
102
|
+
schemaVersion: z.literal(1),
|
|
103
|
+
view: z.enum(REVIEW_VIEWS),
|
|
104
|
+
mapStripped: z.boolean(),
|
|
105
|
+
profile: reviewProfileSchema,
|
|
106
|
+
viewport: z
|
|
107
|
+
.object({ width: z.literal(768), height: z.literal(768), deviceScaleFactor: z.literal(1) })
|
|
108
|
+
.strict(),
|
|
109
|
+
camera: z
|
|
110
|
+
.object({
|
|
111
|
+
position: vector,
|
|
112
|
+
target: vector,
|
|
113
|
+
fovDegrees: finite,
|
|
114
|
+
near: finite,
|
|
115
|
+
far: finite,
|
|
116
|
+
projectionMatrix: matrix,
|
|
117
|
+
matrixWorld: matrix,
|
|
118
|
+
matrixWorldInverse: matrix,
|
|
119
|
+
})
|
|
120
|
+
.strict(),
|
|
121
|
+
modelBounds: z.object({ min: vector, max: vector }).strict(),
|
|
122
|
+
projectedBounds: z
|
|
123
|
+
.object({ min: projectedVector, max: projectedVector, conservative: z.literal(true) })
|
|
124
|
+
.strict()
|
|
125
|
+
.nullable(),
|
|
126
|
+
framing: z.enum(['contained', 'bounds-overflow', 'unknown']),
|
|
127
|
+
})
|
|
128
|
+
.strict();
|
|
129
|
+
const close = (a, b) => Math.abs(a - b) <= 1e-7 * Math.max(1, Math.abs(a), Math.abs(b));
|
|
130
|
+
const same = (a, b) => a.length === b.length && a.every((value, index) => close(value, b[index]));
|
|
131
|
+
const cross = (a, b) => [
|
|
132
|
+
a[1] * b[2] - a[2] * b[1],
|
|
133
|
+
a[2] * b[0] - a[0] * b[2],
|
|
134
|
+
a[0] * b[1] - a[1] * b[0],
|
|
135
|
+
];
|
|
136
|
+
const norm = (a) => {
|
|
137
|
+
const length = Math.hypot(...a);
|
|
138
|
+
return a.map((value) => value / length);
|
|
139
|
+
};
|
|
140
|
+
const dot = (a, b) => a.reduce((sum, value, index) => sum + value * b[index], 0);
|
|
141
|
+
/** Independently check the actual helper state against the fixed camera, never fit model bounds. */
|
|
142
|
+
export function validateRenderState(value, profile, view, stripped = false) {
|
|
143
|
+
const state = renderStateSchema.parse(value);
|
|
144
|
+
const expectedView = stripped ? 'hero' : view;
|
|
145
|
+
const position = profile.views[expectedView];
|
|
146
|
+
const fail = () => {
|
|
147
|
+
throw new Error('procedural_skill_review_state_mismatch');
|
|
148
|
+
};
|
|
149
|
+
if (!position ||
|
|
150
|
+
state.view !== expectedView ||
|
|
151
|
+
state.mapStripped !== stripped ||
|
|
152
|
+
canonicalJson(state.profile) !== canonicalJson(profile) ||
|
|
153
|
+
!same(state.camera.position, position) ||
|
|
154
|
+
!same(state.camera.target, profile.target) ||
|
|
155
|
+
!close(state.camera.fovDegrees, profile.fovDegrees) ||
|
|
156
|
+
!close(state.camera.near, profile.near) ||
|
|
157
|
+
!close(state.camera.far, profile.far))
|
|
158
|
+
fail();
|
|
159
|
+
let zAxis = norm(position.map((value, index) => value - profile.target[index]));
|
|
160
|
+
if (Math.hypot(...cross([0, 1, 0], zAxis)) === 0) {
|
|
161
|
+
zAxis[2] += 0.0001;
|
|
162
|
+
zAxis = norm(zAxis);
|
|
163
|
+
}
|
|
164
|
+
const xAxis = norm(cross([0, 1, 0], zAxis)), yAxis = cross(zAxis, xAxis);
|
|
165
|
+
const world = [...xAxis, 0, ...yAxis, 0, ...zAxis, 0, ...position, 1];
|
|
166
|
+
const inverse = [
|
|
167
|
+
xAxis[0],
|
|
168
|
+
yAxis[0],
|
|
169
|
+
zAxis[0],
|
|
170
|
+
0,
|
|
171
|
+
xAxis[1],
|
|
172
|
+
yAxis[1],
|
|
173
|
+
zAxis[1],
|
|
174
|
+
0,
|
|
175
|
+
xAxis[2],
|
|
176
|
+
yAxis[2],
|
|
177
|
+
zAxis[2],
|
|
178
|
+
0,
|
|
179
|
+
-dot(xAxis, position),
|
|
180
|
+
-dot(yAxis, position),
|
|
181
|
+
-dot(zAxis, position),
|
|
182
|
+
1,
|
|
183
|
+
];
|
|
184
|
+
const scale = 1 / Math.tan((profile.fovDegrees * Math.PI) / 360);
|
|
185
|
+
const projection = [
|
|
186
|
+
scale,
|
|
187
|
+
0,
|
|
188
|
+
0,
|
|
189
|
+
0,
|
|
190
|
+
0,
|
|
191
|
+
scale,
|
|
192
|
+
0,
|
|
193
|
+
0,
|
|
194
|
+
0,
|
|
195
|
+
0,
|
|
196
|
+
-(profile.far + profile.near) / (profile.far - profile.near),
|
|
197
|
+
-1,
|
|
198
|
+
0,
|
|
199
|
+
0,
|
|
200
|
+
(-2 * profile.far * profile.near) / (profile.far - profile.near),
|
|
201
|
+
0,
|
|
202
|
+
];
|
|
203
|
+
if (!same(state.camera.matrixWorld, world) ||
|
|
204
|
+
!same(state.camera.matrixWorldInverse, inverse) ||
|
|
205
|
+
!same(state.camera.projectionMatrix, projection))
|
|
206
|
+
fail();
|
|
207
|
+
if (state.modelBounds.min.some((entry, index) => entry > state.modelBounds.max[index]))
|
|
208
|
+
fail();
|
|
209
|
+
const multiply = (m, p) => [0, 1, 2, 3].map((row) => p.reduce((sum, entry, column) => sum + m[column * 4 + row] * entry, 0));
|
|
210
|
+
const points = [];
|
|
211
|
+
for (const x of [state.modelBounds.min[0], state.modelBounds.max[0]])
|
|
212
|
+
for (const y of [state.modelBounds.min[1], state.modelBounds.max[1]])
|
|
213
|
+
for (const z of [state.modelBounds.min[2], state.modelBounds.max[2]]) {
|
|
214
|
+
const clip = multiply(projection, multiply(inverse, [x, y, z, 1]));
|
|
215
|
+
if (!clip.every(Number.isFinite) || clip[3] <= 1e-9) {
|
|
216
|
+
if (state.projectedBounds !== null || state.framing !== 'unknown')
|
|
217
|
+
fail();
|
|
218
|
+
return state;
|
|
219
|
+
}
|
|
220
|
+
points.push(clip.slice(0, 3).map((entry) => entry / clip[3]));
|
|
221
|
+
}
|
|
222
|
+
const min = [0, 1, 2].map((axis) => Math.min(...points.map((point) => point[axis])));
|
|
223
|
+
const max = [0, 1, 2].map((axis) => Math.max(...points.map((point) => point[axis])));
|
|
224
|
+
const framing = min.every((value) => value >= -1) && max.every((value) => value <= 1)
|
|
225
|
+
? 'contained'
|
|
226
|
+
: 'bounds-overflow';
|
|
227
|
+
if (!state.projectedBounds ||
|
|
228
|
+
!same(state.projectedBounds.min, min) ||
|
|
229
|
+
!same(state.projectedBounds.max, max) ||
|
|
230
|
+
state.framing !== framing)
|
|
231
|
+
fail();
|
|
232
|
+
return state;
|
|
233
|
+
}
|
|
234
|
+
export function validateRenderReceipt(value, profile, views) {
|
|
235
|
+
const receipt = z
|
|
236
|
+
.object({
|
|
237
|
+
schemaVersion: z.literal(1),
|
|
238
|
+
reviewProfileHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
239
|
+
profile: reviewProfileSchema,
|
|
240
|
+
views: z.record(z.unknown()),
|
|
241
|
+
})
|
|
242
|
+
.strict()
|
|
243
|
+
.parse(value);
|
|
244
|
+
const expected = [...views, 'map-stripped'];
|
|
245
|
+
if (receipt.reviewProfileHash !== reviewProfileHash(profile) ||
|
|
246
|
+
canonicalJson(receipt.profile) !== canonicalJson(profile) ||
|
|
247
|
+
Object.keys(receipt.views).length !== expected.length ||
|
|
248
|
+
expected.some((view) => !Object.hasOwn(receipt.views, view)))
|
|
249
|
+
throw new Error('procedural_skill_review_receipt_mismatch');
|
|
250
|
+
return {
|
|
251
|
+
...receipt,
|
|
252
|
+
views: Object.fromEntries(expected.map((view) => [
|
|
253
|
+
view,
|
|
254
|
+
validateRenderState(receipt.views[view], profile, view, view === 'map-stripped'),
|
|
255
|
+
])),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonicalJson, REVIEW_DIRECTORY, REVIEW_PAGE, } from './review-contracts.js';
|
|
2
|
+
const literal = (value) => JSON.stringify(value)
|
|
3
|
+
.replace(/</g, '\\u003c')
|
|
4
|
+
.replace(/\u2028/g, '\\u2028')
|
|
5
|
+
.replace(/\u2029/g, '\\u2029');
|
|
6
|
+
/** Only locked helpers and declarative factory data enter the host-owned review page. */
|
|
7
|
+
export function createHostReviewFiles(review, helpers) {
|
|
8
|
+
const factory = `/${review.factoryPath.split('/').map(encodeURIComponent).join('/')}`;
|
|
9
|
+
const profile = canonicalJson(review.profile);
|
|
10
|
+
const page = `<!doctype html><html><head><meta charset="utf-8"><base href="/"><style>html,body{margin:0;width:768px;height:768px;overflow:hidden}</style></head><body><script type="module">
|
|
11
|
+
import { createReviewScene } from '/${REVIEW_DIRECTORY}/review-scene.mjs';
|
|
12
|
+
import * as factory from ${literal(factory)};
|
|
13
|
+
const createModel = factory[${literal(review.exportName)}];
|
|
14
|
+
if (typeof createModel !== 'function') throw new Error('review_factory_missing');
|
|
15
|
+
const parameters = JSON.parse(${literal(canonicalJson(review.parameters ?? {}))});
|
|
16
|
+
const profile = JSON.parse(${literal(profile)});
|
|
17
|
+
const api = createReviewScene(() => createModel(parameters), { profile });
|
|
18
|
+
Object.defineProperty(window, '__PLAYCRAFT_SCULPT__', { value: api, writable: false, configurable: false });
|
|
19
|
+
</script></body></html>`;
|
|
20
|
+
return [
|
|
21
|
+
{ path: REVIEW_PAGE, bytes: Buffer.from(page) },
|
|
22
|
+
{ path: `${REVIEW_DIRECTORY}/review-scene.mjs`, bytes: helpers.scene },
|
|
23
|
+
{ path: `${REVIEW_DIRECTORY}/review-profile.mjs`, bytes: helpers.profile },
|
|
24
|
+
{ path: `${REVIEW_DIRECTORY}/profile.json`, bytes: Buffer.from(profile) },
|
|
25
|
+
];
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { PROCEDURAL_3D_SKILL_REF, resolveWorkspaceSkillExecutionPolicy, workspaceComposerSelectionSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { assertOperationDirectoryAccess } from '../server/directory-access.js';
|
|
4
|
+
import { sessionWorkspaceRoot } from './registry.js';
|
|
5
|
+
import { readWorkspaceSkillSnapshot } from './skills.js';
|
|
6
|
+
/** Read-only preflight. It must precede the directory gate and cannot install or freeze files. */
|
|
7
|
+
export async function preflightSkillExecution(state, request) {
|
|
8
|
+
if (request.kind !== 'session.turn')
|
|
9
|
+
return undefined;
|
|
10
|
+
const body = request.payload && typeof request.payload === 'object'
|
|
11
|
+
? request.payload
|
|
12
|
+
: {};
|
|
13
|
+
if ('directoryAccessMode' in body || 'skillExecutionPolicyVersion' in body)
|
|
14
|
+
throw new Error('untrusted_runtime_field');
|
|
15
|
+
const selection = workspaceComposerSelectionSchema.safeParse({
|
|
16
|
+
context: body.context,
|
|
17
|
+
workMode: body.workMode,
|
|
18
|
+
});
|
|
19
|
+
if (!selection.success)
|
|
20
|
+
throw new Error('invalid_composer_context');
|
|
21
|
+
if (!selection.data.context?.skills.length)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (request.directoryName === 'root')
|
|
24
|
+
throw new Error('context_directory_mismatch');
|
|
25
|
+
const root = await sessionWorkspaceRoot(state, request.workspaceId, request.directoryName);
|
|
26
|
+
return resolveWorkspaceSkillExecutionPolicy(await readWorkspaceSkillSnapshot(root), selection.data.context.skills);
|
|
27
|
+
}
|
|
28
|
+
/** Native tool callers supply the actual thread/turn binding, never shell-provided identity. */
|
|
29
|
+
export async function resolveActiveSkillExecutionContext(state, input) {
|
|
30
|
+
const skillRef = input.skillRef ?? PROCEDURAL_3D_SKILL_REF;
|
|
31
|
+
if (state.journal.destroyed())
|
|
32
|
+
throw new Error('skill_execution_identity_mismatch');
|
|
33
|
+
if (skillRef !== PROCEDURAL_3D_SKILL_REF)
|
|
34
|
+
throw new Error('context_skill_unavailable');
|
|
35
|
+
const session = state.journal.getSession(input.sessionId);
|
|
36
|
+
const matches = state.journal
|
|
37
|
+
.listOperations()
|
|
38
|
+
.map((item) => item.operation)
|
|
39
|
+
.filter((operation) => operation.kind === 'session.turn' &&
|
|
40
|
+
['dispatched', 'accepted'].includes(operation.status) &&
|
|
41
|
+
operation.sessionId === input.sessionId &&
|
|
42
|
+
operation.nativeThreadId === input.nativeThreadId &&
|
|
43
|
+
operation.nativeTurnId === input.nativeTurnId &&
|
|
44
|
+
operation.generation === state.config.generation &&
|
|
45
|
+
operation.runtimeEpoch === state.runtimeEpoch &&
|
|
46
|
+
operation.userId === state.config.userId &&
|
|
47
|
+
operation.environment === state.config.environment);
|
|
48
|
+
const operation = matches.length === 1 ? matches[0] : undefined;
|
|
49
|
+
if (!operation ||
|
|
50
|
+
!session ||
|
|
51
|
+
session.threadId !== input.nativeThreadId ||
|
|
52
|
+
(session.activeTurnId !== undefined && session.activeTurnId !== input.nativeTurnId) ||
|
|
53
|
+
session.workspaceId !== operation.workspaceId ||
|
|
54
|
+
(session.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic'))
|
|
55
|
+
throw new Error('skill_execution_identity_mismatch');
|
|
56
|
+
if (operation.directoryAccessMode !== 'exclusive' || operation.skillExecutionPolicyVersion !== 1)
|
|
57
|
+
throw new Error('requires-exclusive-turn');
|
|
58
|
+
if (operation.workMode === 'plan' || operation.permissionMode === 'read-only')
|
|
59
|
+
throw new Error('skill_execution_readonly');
|
|
60
|
+
await assertOperationDirectoryAccess(state, operation, 'exclusive');
|
|
61
|
+
const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
|
|
62
|
+
if ((await realpath(root)) !== (await realpath(input.root)))
|
|
63
|
+
throw new Error('skill_execution_identity_mismatch');
|
|
64
|
+
const policy = resolveWorkspaceSkillExecutionPolicy(await readWorkspaceSkillSnapshot(root), operation.context?.skills ?? []);
|
|
65
|
+
const entry = policy.entries.find((item) => item.revision.skillRef === skillRef);
|
|
66
|
+
if (!entry || policy.directoryAccessMode !== 'exclusive')
|
|
67
|
+
throw new Error('requires-exclusive-turn');
|
|
68
|
+
return { operation, entry, lockHash: policy.lockHash, declarationHash: policy.declarationHash };
|
|
69
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { cp, lstat, open, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { basename, dirname, join } from 'node:path';
|
|
4
5
|
import { load } from 'js-yaml';
|
|
5
6
|
import { canonicalProjectSkillsJson, createCanonicalBundleArchive, effectiveProjectSkills, extractBundleArchive, inspectProjectSkills, readProjectSkillSource, readProjectSkillsLockFile, sourceTreeDigest, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
|
|
@@ -156,13 +157,22 @@ export async function freezeWorkspaceSkills(root, cache, loadArtifact) {
|
|
|
156
157
|
expectedFiles.add(relative);
|
|
157
158
|
await safeDirectory(generation, dirname(relative));
|
|
158
159
|
// shared
|
|
159
|
-
await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(
|
|
160
|
+
await writeFile(target, file.data, { flag: 'wx', mode: 0o444 }).catch(error => {
|
|
160
161
|
if (error.code !== 'EEXIST')
|
|
161
162
|
throw error;
|
|
162
|
-
const stat = await lstat(target);
|
|
163
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || !file.data.equals(await readFile(target)))
|
|
164
|
-
throw new Error('workspace_skills_digest_mismatch');
|
|
165
163
|
});
|
|
164
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
165
|
+
try {
|
|
166
|
+
const stat = await handle.stat();
|
|
167
|
+
if (!stat.isFile() || stat.nlink !== 1 || !file.data.equals(await handle.readFile()))
|
|
168
|
+
throw new Error('workspace_skills_digest_mismatch');
|
|
169
|
+
// A private controller umask must not remove the developer's read access.
|
|
170
|
+
// Repair only the verified inode, keeping every frozen byte read-only.
|
|
171
|
+
await handle.chmod(0o444);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
166
176
|
}
|
|
167
177
|
const body = files.find(file => file.path === 'SKILL.md');
|
|
168
178
|
if (!body)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@gltf-transform/core": "^4.3.0",
|
|
54
54
|
"@gltf-transform/extensions": "^4.3.0",
|
|
55
55
|
"@gltf-transform/functions": "^4.3.0",
|
|
56
|
-
"@playcraft/build": "0.1.
|
|
57
|
-
"@playcraft/common": "0.1.
|
|
56
|
+
"@playcraft/build": "0.1.61-beta",
|
|
57
|
+
"@playcraft/common": "0.1.61-beta",
|
|
58
58
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
59
59
|
"chokidar": "^4.0.3",
|
|
60
60
|
"commander": "^13.1.0",
|
|
@@ -141,6 +141,13 @@
|
|
|
141
141
|
"surface": "sandbox-agent",
|
|
142
142
|
"mountPath": ".playcraft/skills/remix__playcraft-prefab"
|
|
143
143
|
},
|
|
144
|
+
{
|
|
145
|
+
"skillRef": "remix/playcraft-remix-references",
|
|
146
|
+
"version": "1.1.0",
|
|
147
|
+
"artifactHash": "32d4bb3bb6ed2dc0405997161dc65cd535559a71c227f0425612809ee5c178af",
|
|
148
|
+
"surface": "sandbox-agent",
|
|
149
|
+
"mountPath": ".playcraft/skills/remix__playcraft-remix-references"
|
|
150
|
+
},
|
|
144
151
|
{
|
|
145
152
|
"skillRef": "remix/playcraft-remix-workflow",
|
|
146
153
|
"version": "1.0.0",
|
|
@@ -42,6 +42,16 @@ Reference snapshots under `references/remixes/` are fixed source material. Their
|
|
|
42
42
|
|
|
43
43
|
Use the built-in CLI (Command Line Interface, 命令行界面) commands for platform assets. Run the relevant command's `--help` to inspect its supported parameters. The workspace supplies credentials; keep them out of commands, files, and chat output.
|
|
44
44
|
|
|
45
|
+
### Asset sourcing
|
|
46
|
+
|
|
47
|
+
Follow the user's explicit source choice: reuse specified assets or generate new assets when requested, without asking again. Otherwise, when a task needs images, audio, sprites, animation, models, or video, search the shared library before generating. Cover both authorized team assets and public assets. When the current team is known from verified workspace or project context, search it with `--team-id <teamId>` and search public assets separately with `--visibility public`; a team filter does not include public assets. Without verified team context, omit both filters to search the credential's authorized scopes rather than guessing a team identifier.
|
|
48
|
+
|
|
49
|
+
Inspect promising candidates with `library get` and `library files`, checking their intended use, style, dimensions, format, and required package contents against the task. Reuse suitable assets directly, preferring team assets when candidates are equally suitable, and download the complete file or package into the project's asset directory. Do not substitute a preview for the production files. If a narrow query finds nothing suitable, broaden the keywords or filters and inspect relevant result pages before concluding there is no match. Generate only the missing assets after these searches find no suitable candidate. A failed or unavailable search is not an empty result: report the limitation and recover the search instead of claiming that the library has no suitable assets.
|
|
50
|
+
|
|
51
|
+
Do not ask the user to choose reuse versus generation for every asset. Ask only when candidate differences materially affect the requested direction or suitability remains unclear; present the relevant candidates and tradeoffs. An execution without user interaction should pass that ambiguity to its coordinating conversation through the existing workflow. Report which assets were actually reused or generated and why.
|
|
52
|
+
|
|
53
|
+
### Commands
|
|
54
|
+
|
|
45
55
|
- Shared library: `playcraft library search "<query>" --json`, `library list`, and `library facets` support type, category, tags, team, and pagination filters. `library get <assetId>` reads metadata; `library files <assetId>` lists a complete package. These identifiers belong to the shared library, separate from project assets returned by `tools list-assets`.
|
|
46
56
|
- Download library content: `playcraft library download <assetId> --output <new-path>` saves a file or a complete package directory. `--output-dir <new-directory>` always saves a directory. Packages retain manifests, nested files, and relative dependencies. Download working assets into the project's asset directory; choose `dist/` for final deliverables. Existing output paths are preserved.
|
|
47
57
|
- Layer separation: `playcraft image decompose-layers --input <image> --output-dir <directory>` uses the current workspace layer pipeline and saves a base, separate layers, and metadata with stacking order and bounds. Optional `--prompt`, `--size`, and `--image-model` guide generation. `--engine qwen` selects the legacy pipeline.
|