parley-live 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +8 -2
- package/dist/lens.js +252 -0
- package/kb/index/bm25.json +1 -0
- package/kb/index/chunks-meta.jsonl +11523 -0
- package/lens-builder/build-from-prompt.mjs +19 -0
- package/lens-builder/build.mjs +147 -0
- package/lens-builder/cli.mjs +103 -0
- package/lens-builder/kb/lib-fetch.mjs +35 -0
- package/lens-builder/kb/search.mjs +61 -0
- package/lens-builder/kb/show.mjs +4 -0
- package/lens-builder/kb/tokenize.mjs +27 -0
- package/lens-builder/lib/bridge.mjs +186 -0
- package/lens-builder/lib/submission.mjs +246 -0
- package/lens-builder/mcp-client.mjs +101 -0
- package/lens-builder/pitch/one-pager.mjs +70 -0
- package/lens-builder/planner.mjs +108 -0
- package/lens-builder/publish.mjs +127 -0
- package/lens-builder/training/author-scene.ts +77 -0
- package/lens-builder/training/compile.mjs +258 -0
- package/lens-builder/vendor/specs-publish/LICENSE +208 -0
- package/lens-builder/vendor/specs-publish/NOTICE +13 -0
- package/lens-builder/vendor/specs-publish/scripts/make-eec-script.py +85 -0
- package/lens-builder/vendor/specs-publish/scripts/package.ts +189 -0
- package/lens-builder/vendor/specs-publish/scripts/preflight.ts +852 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-icon.ts +107 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-name.ts +121 -0
- package/lens-builder/vendor/specs-publish/scripts/set-signing-key.ts +129 -0
- package/lens-builder/vendor/specs-publish/scripts/signing-key.js +290 -0
- package/lens-builder/verify.mjs +186 -0
- package/lens-builder/webxr/generate.mjs +238 -0
- package/package.json +11 -5
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
// Copyright 2026 Specs Inc.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// publish · PREFLIGHT (Editor API)
|
|
5
|
+
// Run AS-IS via ExecuteEditorCode — zero config. Pass this file's path as `path`; do NOT read it.
|
|
6
|
+
//
|
|
7
|
+
// Confirms the project is submittable WITHOUT exporting: Lens Studio auth, the open
|
|
8
|
+
// project, submission metadata (packageId + lensName), and a production signing key.
|
|
9
|
+
// Also returns `localAiModerationInput`, a small advisory input bundle for the
|
|
10
|
+
// orchestrating agent's local AI preflight review. The agent may inspect the lens name
|
|
11
|
+
// and local Lens icon before the expensive export; the backend queue remains the
|
|
12
|
+
// authoritative moderation gate.
|
|
13
|
+
// Returns one JSON object the /specs-publish skill branches on.
|
|
14
|
+
//
|
|
15
|
+
// Result shapes (READY/ACTION_REQUIRED may ALSO carry `localAiModerationInput`,
|
|
16
|
+
// advisory agent-only context that must never be treated as approval):
|
|
17
|
+
// { status: "READY", projectPath, packageId, lensName, hasProductionKey: true, localAiModerationInput }
|
|
18
|
+
// { status: "ACTION_REQUIRED", reason, message, issues: [{reason, message}, ...], localAiModerationInput, ... }
|
|
19
|
+
// `reason` + `message` mirror the FIRST issue for back-compat; `issues` lists ALL
|
|
20
|
+
// local-fix items found in a single pass so the agent can prompt for everything at
|
|
21
|
+
// once instead of one-at-a-time. Auth/project-state failures still return immediately.
|
|
22
|
+
// { status: "FAILED", reason, message, stack? }
|
|
23
|
+
|
|
24
|
+
const FileSystem = await import("LensStudio:FileSystem");
|
|
25
|
+
|
|
26
|
+
function errorToString(error: unknown): string {
|
|
27
|
+
return error instanceof Error ? error.message : String(error);
|
|
28
|
+
}
|
|
29
|
+
function stripYamlScalarQuotes(value: string | undefined): string {
|
|
30
|
+
return (value ?? "").trim().replace(/^["']/, "").replace(/["']$/, "");
|
|
31
|
+
}
|
|
32
|
+
function readProjectScalar(text: string, key: string): string {
|
|
33
|
+
const match = text.match(new RegExp(`(?:^|\\n)\\s*${key}:\\s*([^\\r\\n]*)`));
|
|
34
|
+
return stripYamlScalarQuotes(match?.[1]);
|
|
35
|
+
}
|
|
36
|
+
function oneLine(value: string): string {
|
|
37
|
+
return value.replace(/\s+/g, " ").trim();
|
|
38
|
+
}
|
|
39
|
+
function isDefaultLensName(value: string): boolean {
|
|
40
|
+
return value.trim().toLowerCase() === "untitled";
|
|
41
|
+
}
|
|
42
|
+
function actionRequired(reason: string, message: string, extra: Record<string, unknown> = {}): any {
|
|
43
|
+
return { status: "ACTION_REQUIRED", stage: "preflight", reason, message, ...extra };
|
|
44
|
+
}
|
|
45
|
+
function failed(reason: string, message: string, extra: Record<string, unknown> = {}): any {
|
|
46
|
+
return { status: "FAILED", stage: "preflight", reason, message, ...extra };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type LocalAiModerationAsset = {
|
|
50
|
+
assetId: string;
|
|
51
|
+
role: string;
|
|
52
|
+
mediaType: string;
|
|
53
|
+
status: "available" | "skipped";
|
|
54
|
+
iconPath?: string;
|
|
55
|
+
contentType?: string;
|
|
56
|
+
fileName?: string;
|
|
57
|
+
skipReason?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type CommerceKitIndicator = {
|
|
61
|
+
sourcePath: string;
|
|
62
|
+
kind: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type CommerceKitProductIdSource = {
|
|
66
|
+
sourcePath: string;
|
|
67
|
+
sourceKind: string;
|
|
68
|
+
productIds: string[];
|
|
69
|
+
unresolvedExpression?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type CommerceKitInvalidProductId = {
|
|
73
|
+
productId: string;
|
|
74
|
+
expectedPrefix: string;
|
|
75
|
+
sourcePath: string;
|
|
76
|
+
sourceKind: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type CommerceKitPreflightInput = {
|
|
80
|
+
used: boolean;
|
|
81
|
+
indicators: CommerceKitIndicator[];
|
|
82
|
+
productIdSources: CommerceKitProductIdSource[];
|
|
83
|
+
invalidProductIds: CommerceKitInvalidProductId[];
|
|
84
|
+
unresolvedProductIdSources: CommerceKitProductIdSource[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type LocalAiModerationInput = {
|
|
88
|
+
policyVersion: string;
|
|
89
|
+
releaseText: {
|
|
90
|
+
name: string;
|
|
91
|
+
description: string;
|
|
92
|
+
tags: string[];
|
|
93
|
+
};
|
|
94
|
+
packageId: string;
|
|
95
|
+
assets: LocalAiModerationAsset[];
|
|
96
|
+
commerceKit: CommerceKitPreflightInput;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type ProjectTextFile = {
|
|
100
|
+
path: Editor.Path;
|
|
101
|
+
text: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type TextRange = {
|
|
105
|
+
start: number;
|
|
106
|
+
end: number;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function imageContentType(imagePath: Editor.Path): string {
|
|
110
|
+
const path = imagePath.toString().toLowerCase();
|
|
111
|
+
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) {
|
|
112
|
+
return "image/jpeg";
|
|
113
|
+
}
|
|
114
|
+
if (path.endsWith(".webp")) {
|
|
115
|
+
return "image/webp";
|
|
116
|
+
}
|
|
117
|
+
return "image/png";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function multipartFileName(imagePath: Editor.Path, fallback = "image"): string {
|
|
121
|
+
const fileName = imagePath.fileName.toString().replace(/[\r\n"]/g, "_").trim();
|
|
122
|
+
return fileName || fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function localImageAsset(assetId: string, role: string, imagePath: Editor.Path, fallbackFileName: string): LocalAiModerationAsset {
|
|
126
|
+
if (!FileSystem.exists(imagePath) || !FileSystem.isFile(imagePath)) {
|
|
127
|
+
return {
|
|
128
|
+
assetId,
|
|
129
|
+
role,
|
|
130
|
+
mediaType: "image",
|
|
131
|
+
status: "skipped",
|
|
132
|
+
skipReason: "local_image_path_unavailable",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
assetId,
|
|
138
|
+
role,
|
|
139
|
+
mediaType: "image",
|
|
140
|
+
status: "available",
|
|
141
|
+
iconPath: imagePath.toString(),
|
|
142
|
+
contentType: imageContentType(imagePath),
|
|
143
|
+
fileName: multipartFileName(imagePath, fallbackFileName),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function lensIconAssetForLocalAi(project: any): LocalAiModerationAsset {
|
|
148
|
+
if (!project.metaInfo.isIconSet) {
|
|
149
|
+
return {
|
|
150
|
+
assetId: "icon",
|
|
151
|
+
role: "icon",
|
|
152
|
+
mediaType: "image",
|
|
153
|
+
status: "skipped",
|
|
154
|
+
skipReason: "lens_icon_not_set",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const iconPath = project.metaInfo.iconPath;
|
|
159
|
+
if (!iconPath || !FileSystem.exists(iconPath) || !FileSystem.isFile(iconPath)) {
|
|
160
|
+
return {
|
|
161
|
+
assetId: "icon",
|
|
162
|
+
role: "icon",
|
|
163
|
+
mediaType: "image",
|
|
164
|
+
status: "skipped",
|
|
165
|
+
skipReason: "lens_icon_path_unavailable",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
assetId: "icon",
|
|
171
|
+
role: "icon",
|
|
172
|
+
mediaType: "image",
|
|
173
|
+
status: "available",
|
|
174
|
+
iconPath: iconPath.toString(),
|
|
175
|
+
contentType: imageContentType(iconPath),
|
|
176
|
+
fileName: multipartFileName(iconPath, "lens-icon"),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTextAsset(path: Editor.Path): boolean {
|
|
181
|
+
const name = path.fileName.toString().toLowerCase();
|
|
182
|
+
const extension = path.extension.toLowerCase();
|
|
183
|
+
if (name.endsWith(".d.ts") || name.endsWith(".map") || name.endsWith(".meta")) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return ["ts", "tsx", "js", "jsx", "scene", "prefab", "json", "yaml", "yml"].includes(extension);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isLocalImageReference(value: string): boolean {
|
|
190
|
+
const lower = value.toLowerCase();
|
|
191
|
+
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("data:")) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return /\.(png|jpe?g|webp)(?:[?#].*)?$/.test(lower);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isImageAsset(path: Editor.Path): boolean {
|
|
198
|
+
return ["png", "jpg", "jpeg", "webp"].includes(path.extension.toLowerCase());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stripInlineComment(value: string): string {
|
|
202
|
+
return value.replace(/\s+(#|\/\/).*$/, "").trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stripScalarQuotes(value: string): string {
|
|
206
|
+
return stripInlineComment(value).replace(/^["'`]/, "").replace(/["'`]$/, "").trim();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readTextFiles(project: any, relativeDir: string): ProjectTextFile[] {
|
|
210
|
+
const root = project.projectDirectory.appended(new Editor.Path(relativeDir));
|
|
211
|
+
if (!FileSystem.exists(root) || !FileSystem.isDirectory(root)) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return FileSystem.readDir(root, { recursive: true })
|
|
216
|
+
.map((entry: Editor.Path) => root.appended(entry))
|
|
217
|
+
.filter((path: Editor.Path) => FileSystem.exists(path) && FileSystem.isFile(path) && isTextAsset(path))
|
|
218
|
+
.map((path: Editor.Path) => ({ path, text: FileSystem.readFile(path) }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isJavaScriptLineTerminator(char: string): boolean {
|
|
222
|
+
return char === "\n" || char === "\r" || char === "\u2028" || char === "\u2029";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function previousNonWhitespaceIndex(text: string, index: number): number {
|
|
226
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
227
|
+
if (!/\s/.test(text[i])) {
|
|
228
|
+
return i;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return -1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function canStartJavaScriptRegexLiteral(text: string, slashIndex: number): boolean {
|
|
235
|
+
const previousIndex = previousNonWhitespaceIndex(text, slashIndex);
|
|
236
|
+
if (previousIndex < 0) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const previousChar = text[previousIndex];
|
|
241
|
+
if ("([{=,:;!&|?+-*~^<>%".includes(previousChar)) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const previousText = text.slice(Math.max(0, previousIndex - 16), previousIndex + 1);
|
|
246
|
+
return /\b(?:return|throw|case|delete|void|typeof|yield|await|else|do)$/.test(previousText);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function skipJavaScriptRegexLiteral(text: string, slashIndex: number): number {
|
|
250
|
+
if (text[slashIndex] !== "/" || text[slashIndex + 1] === "/" || text[slashIndex + 1] === "*") {
|
|
251
|
+
return slashIndex;
|
|
252
|
+
}
|
|
253
|
+
if (!canStartJavaScriptRegexLiteral(text, slashIndex)) {
|
|
254
|
+
return slashIndex;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let escaped = false;
|
|
258
|
+
let inCharacterClass = false;
|
|
259
|
+
for (let i = slashIndex + 1; i < text.length; i++) {
|
|
260
|
+
const char = text[i];
|
|
261
|
+
if (isJavaScriptLineTerminator(char)) {
|
|
262
|
+
return slashIndex;
|
|
263
|
+
}
|
|
264
|
+
if (escaped) {
|
|
265
|
+
escaped = false;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (char === "\\") {
|
|
269
|
+
escaped = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (char === "[") {
|
|
273
|
+
inCharacterClass = true;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (char === "]" && inCharacterClass) {
|
|
277
|
+
inCharacterClass = false;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (char === "/" && !inCharacterClass) {
|
|
281
|
+
let end = i;
|
|
282
|
+
while (/[A-Za-z]/.test(text[end + 1] ?? "")) {
|
|
283
|
+
end += 1;
|
|
284
|
+
}
|
|
285
|
+
return end;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return slashIndex;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function javaScriptCommentRanges(text: string): TextRange[] {
|
|
292
|
+
const ranges: TextRange[] = [];
|
|
293
|
+
let quote = "";
|
|
294
|
+
let escaped = false;
|
|
295
|
+
for (let i = 0; i < text.length; i++) {
|
|
296
|
+
const char = text[i];
|
|
297
|
+
if (quote) {
|
|
298
|
+
if (escaped) {
|
|
299
|
+
escaped = false;
|
|
300
|
+
} else if (char === "\\") {
|
|
301
|
+
escaped = true;
|
|
302
|
+
} else if (char === quote) {
|
|
303
|
+
quote = "";
|
|
304
|
+
}
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (char === "\"" || char === "'" || char === "`") {
|
|
308
|
+
quote = char;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const regexEndIndex = skipJavaScriptRegexLiteral(text, i);
|
|
312
|
+
if (regexEndIndex > i) {
|
|
313
|
+
i = regexEndIndex;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (char === "/" && text[i + 1] === "/") {
|
|
317
|
+
const start = i;
|
|
318
|
+
i += 2;
|
|
319
|
+
while (i < text.length && !isJavaScriptLineTerminator(text[i])) {
|
|
320
|
+
i += 1;
|
|
321
|
+
}
|
|
322
|
+
ranges.push({ start, end: i });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (char === "/" && text[i + 1] === "*") {
|
|
326
|
+
const start = i;
|
|
327
|
+
i += 2;
|
|
328
|
+
while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
329
|
+
i += 1;
|
|
330
|
+
}
|
|
331
|
+
ranges.push({ start, end: i < text.length - 1 ? i + 2 : text.length });
|
|
332
|
+
if (i < text.length - 1) {
|
|
333
|
+
i += 1;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return ranges;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isIndexInRanges(index: number, ranges: TextRange[]): boolean {
|
|
341
|
+
return ranges.some((range) => index >= range.start && index < range.end);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function includesOutsideComments(text: string, needle: string, commentRanges: TextRange[]): boolean {
|
|
345
|
+
let index = text.indexOf(needle);
|
|
346
|
+
while (index >= 0) {
|
|
347
|
+
if (!isIndexInRanges(index, commentRanges)) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
index = text.indexOf(needle, index + needle.length);
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function hasMatchOutsideComments(text: string, regex: RegExp, commentRanges: TextRange[]): boolean {
|
|
356
|
+
regex.lastIndex = 0;
|
|
357
|
+
let match: RegExpExecArray | null;
|
|
358
|
+
while ((match = regex.exec(text)) !== null) {
|
|
359
|
+
if (!isIndexInRanges(match.index, commentRanges)) {
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function commerceIndicatorKinds(text: string): string[] {
|
|
367
|
+
const commentRanges = javaScriptCommentRanges(text);
|
|
368
|
+
const kinds: string[] = [];
|
|
369
|
+
if (includesOutsideComments(text, "LensStudio:CommerceKitModule", commentRanges)) {
|
|
370
|
+
kinds.push("commerce_module");
|
|
371
|
+
}
|
|
372
|
+
if (includesOutsideComments(text, "CommerceKit.lspkg", commentRanges) || includesOutsideComments(text, "CommerceKit", commentRanges)) {
|
|
373
|
+
kinds.push("commerce_package");
|
|
374
|
+
}
|
|
375
|
+
if (hasMatchOutsideComments(text, /\bqueryProductDetails\s*\(/g, commentRanges)) {
|
|
376
|
+
kinds.push("query_product_details");
|
|
377
|
+
}
|
|
378
|
+
if (hasMatchOutsideComments(text, /\blaunchPurchaseFlow\s*\(/g, commentRanges)) {
|
|
379
|
+
kinds.push("purchase_flow");
|
|
380
|
+
}
|
|
381
|
+
if (
|
|
382
|
+
hasMatchOutsideComments(text, /\bProductCatalog\b/g, commentRanges) ||
|
|
383
|
+
hasMatchOutsideComments(text, /(?:^|\n)\s*productCatalog:\s*(?:\n|$)/g, commentRanges)
|
|
384
|
+
) {
|
|
385
|
+
kinds.push("product_catalog");
|
|
386
|
+
}
|
|
387
|
+
return kinds;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function matchingCloseIndex(text: string, openIndex: number, openChar: string, closeChar: string): number {
|
|
391
|
+
let depth = 0;
|
|
392
|
+
let quote = "";
|
|
393
|
+
let escaped = false;
|
|
394
|
+
for (let i = openIndex; i < text.length; i++) {
|
|
395
|
+
const char = text[i];
|
|
396
|
+
if (quote) {
|
|
397
|
+
if (escaped) {
|
|
398
|
+
escaped = false;
|
|
399
|
+
} else if (char === "\\") {
|
|
400
|
+
escaped = true;
|
|
401
|
+
} else if (char === quote) {
|
|
402
|
+
quote = "";
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (char === "\"" || char === "'" || char === "`") {
|
|
407
|
+
quote = char;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const regexEndIndex = skipJavaScriptRegexLiteral(text, i);
|
|
411
|
+
if (regexEndIndex > i) {
|
|
412
|
+
i = regexEndIndex;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (char === "/" && text[i + 1] === "/") {
|
|
416
|
+
i += 2;
|
|
417
|
+
while (i < text.length && !isJavaScriptLineTerminator(text[i])) {
|
|
418
|
+
i += 1;
|
|
419
|
+
}
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (char === "/" && text[i + 1] === "*") {
|
|
423
|
+
i += 2;
|
|
424
|
+
while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) {
|
|
425
|
+
i += 1;
|
|
426
|
+
}
|
|
427
|
+
if (i < text.length - 1) {
|
|
428
|
+
i += 1;
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (char === openChar) {
|
|
433
|
+
depth += 1;
|
|
434
|
+
} else if (char === closeChar) {
|
|
435
|
+
depth -= 1;
|
|
436
|
+
if (depth === 0) {
|
|
437
|
+
return i;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return -1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function stringLiterals(text: string): string[] {
|
|
445
|
+
const values: string[] = [];
|
|
446
|
+
const commentRanges = javaScriptCommentRanges(text);
|
|
447
|
+
const literalRe = /(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
|
|
448
|
+
let match: RegExpExecArray | null;
|
|
449
|
+
while ((match = literalRe.exec(text)) !== null) {
|
|
450
|
+
if (isIndexInRanges(match.index, commentRanges)) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (match[1] === "`" && match[2].includes("${")) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
values.push(match[2].replace(/\\(["'`\\])/g, "$1"));
|
|
457
|
+
}
|
|
458
|
+
return values;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function findAssignedArrayLiteral(text: string, identifier: string, commentRanges: TextRange[]): string | undefined {
|
|
462
|
+
const assignmentRe = new RegExp(`(?:const|let|var)\\s+${identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*(?::[^=]+)?=\\s*\\[`, "gm");
|
|
463
|
+
let match: RegExpExecArray | null;
|
|
464
|
+
while ((match = assignmentRe.exec(text)) !== null) {
|
|
465
|
+
if (isIndexInRanges(match.index, commentRanges)) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const openIndex = match.index + match[0].lastIndexOf("[");
|
|
469
|
+
const closeIndex = matchingCloseIndex(text, openIndex, "[", "]");
|
|
470
|
+
if (closeIndex < 0) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
return text.slice(openIndex, closeIndex + 1);
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function productIdsFromObjectArray(arrayLiteral: string): string[] {
|
|
479
|
+
const values: string[] = [];
|
|
480
|
+
const commentRanges = javaScriptCommentRanges(arrayLiteral);
|
|
481
|
+
const productIdRe = /\bproductId\s*:\s*(["'`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
|
|
482
|
+
let match: RegExpExecArray | null;
|
|
483
|
+
while ((match = productIdRe.exec(arrayLiteral)) !== null) {
|
|
484
|
+
if (isIndexInRanges(match.index, commentRanges)) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (match[1] === "`" && match[2].includes("${")) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
values.push(match[2].replace(/\\(["'`\\])/g, "$1"));
|
|
491
|
+
}
|
|
492
|
+
return values;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function resolveProductIdsExpression(expression: string, text: string, commentRanges: TextRange[]): { productIds: string[]; unresolvedExpression?: string } {
|
|
496
|
+
const trimmed = expression.trim();
|
|
497
|
+
if (trimmed.startsWith("[")) {
|
|
498
|
+
return { productIds: stringLiterals(trimmed) };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const identifierMatch = trimmed.match(/^[A-Za-z_$][\w$]*$/);
|
|
502
|
+
if (identifierMatch) {
|
|
503
|
+
const arrayLiteral = findAssignedArrayLiteral(text, trimmed, commentRanges);
|
|
504
|
+
return arrayLiteral ? { productIds: stringLiterals(arrayLiteral) } : { productIds: [], unresolvedExpression: trimmed };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const mappedProductIds = trimmed.match(/^([A-Za-z_$][\w$]*)\.map\([^=]*=>\s*[^.]+\.productId\s*\)$/);
|
|
508
|
+
if (mappedProductIds) {
|
|
509
|
+
const arrayLiteral = findAssignedArrayLiteral(text, mappedProductIds[1], commentRanges);
|
|
510
|
+
return arrayLiteral ? { productIds: productIdsFromObjectArray(arrayLiteral) } : { productIds: [], unresolvedExpression: trimmed };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return { productIds: [], unresolvedExpression: trimmed };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function queryProductDetailsSources(file: ProjectTextFile): CommerceKitProductIdSource[] {
|
|
517
|
+
const sources: CommerceKitProductIdSource[] = [];
|
|
518
|
+
const commentRanges = javaScriptCommentRanges(file.text);
|
|
519
|
+
const callRe = /\bqueryProductDetails\s*\(/g;
|
|
520
|
+
let match: RegExpExecArray | null;
|
|
521
|
+
while ((match = callRe.exec(file.text)) !== null) {
|
|
522
|
+
if (isIndexInRanges(match.index, commentRanges)) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const openIndex = match.index + match[0].lastIndexOf("(");
|
|
526
|
+
const closeIndex = matchingCloseIndex(file.text, openIndex, "(", ")");
|
|
527
|
+
if (closeIndex < 0) {
|
|
528
|
+
sources.push({
|
|
529
|
+
sourcePath: file.path.toString(),
|
|
530
|
+
sourceKind: "queryProductDetails",
|
|
531
|
+
productIds: [],
|
|
532
|
+
unresolvedExpression: "unterminated_call",
|
|
533
|
+
});
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const expression = file.text.slice(openIndex + 1, closeIndex);
|
|
537
|
+
if (!expression.trim()) {
|
|
538
|
+
callRe.lastIndex = closeIndex + 1;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const resolved = resolveProductIdsExpression(expression, file.text, commentRanges);
|
|
542
|
+
sources.push({
|
|
543
|
+
sourcePath: file.path.toString(),
|
|
544
|
+
sourceKind: "queryProductDetails",
|
|
545
|
+
productIds: resolved.productIds,
|
|
546
|
+
...(resolved.unresolvedExpression ? { unresolvedExpression: resolved.unresolvedExpression } : {}),
|
|
547
|
+
});
|
|
548
|
+
callRe.lastIndex = closeIndex + 1;
|
|
549
|
+
}
|
|
550
|
+
return sources;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function hasPackagePrefix(productId: string, packageId: string): boolean {
|
|
554
|
+
return Boolean(packageId) && (productId === packageId || productId.startsWith(`${packageId}.`));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function resolveLocalAssetPath(project: any, sourcePath: Editor.Path, value: string): Editor.Path {
|
|
558
|
+
const cleanValue = stripScalarQuotes(value);
|
|
559
|
+
if (cleanValue.startsWith("/")) {
|
|
560
|
+
return new Editor.Path(cleanValue);
|
|
561
|
+
}
|
|
562
|
+
if (cleanValue.startsWith("Assets/") || cleanValue.startsWith("Packages/")) {
|
|
563
|
+
return project.projectDirectory.appended(new Editor.Path(cleanValue));
|
|
564
|
+
}
|
|
565
|
+
if (cleanValue.startsWith("./") || cleanValue.startsWith("../")) {
|
|
566
|
+
return sourcePath.parent.appended(new Editor.Path(cleanValue));
|
|
567
|
+
}
|
|
568
|
+
return project.projectDirectory.appended(new Editor.Path(cleanValue));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function isCatalogComment(line: string): boolean {
|
|
572
|
+
const trimmed = line.trim();
|
|
573
|
+
return trimmed.startsWith("#") || trimmed.startsWith("//");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function catalogFieldValue(line: string, fieldName: string): string | undefined {
|
|
577
|
+
const fieldRe = new RegExp(`^\\s*(?:-\\s*)?${fieldName}:\\s*(.*)$`);
|
|
578
|
+
const match = fieldRe.exec(line);
|
|
579
|
+
return match ? stripScalarQuotes(match[1]) : undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function productCatalogIconAssets(project: any, file: ProjectTextFile): LocalAiModerationAsset[] {
|
|
583
|
+
const assets: LocalAiModerationAsset[] = [];
|
|
584
|
+
if (!file.text.includes("productCatalog:")) {
|
|
585
|
+
return assets;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const lines = file.text.split(/\r?\n/);
|
|
589
|
+
let inCatalog = false;
|
|
590
|
+
let catalogIndent = 0;
|
|
591
|
+
let itemIndent: number | undefined;
|
|
592
|
+
let currentProductId = "";
|
|
593
|
+
let currentIconUri = "";
|
|
594
|
+
|
|
595
|
+
function flushProductIcon() {
|
|
596
|
+
if (!currentProductId) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (!currentIconUri) {
|
|
600
|
+
assets.push({
|
|
601
|
+
assetId: `product_catalog:${currentProductId}`,
|
|
602
|
+
role: "product_catalog_icon",
|
|
603
|
+
mediaType: "image",
|
|
604
|
+
status: "skipped",
|
|
605
|
+
skipReason: "product_catalog_icon_uri_empty",
|
|
606
|
+
});
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (!isLocalImageReference(currentIconUri)) {
|
|
610
|
+
assets.push({
|
|
611
|
+
assetId: `product_catalog:${currentProductId}`,
|
|
612
|
+
role: "product_catalog_icon",
|
|
613
|
+
mediaType: "image",
|
|
614
|
+
status: "skipped",
|
|
615
|
+
skipReason: "product_catalog_icon_uri_not_local_image",
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
assets.push(
|
|
620
|
+
localImageAsset(
|
|
621
|
+
`product_catalog:${currentProductId}`,
|
|
622
|
+
"product_catalog_icon",
|
|
623
|
+
resolveLocalAssetPath(project, file.path, currentIconUri),
|
|
624
|
+
"product-catalog-icon",
|
|
625
|
+
),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for (const line of lines) {
|
|
630
|
+
if (!line.trim() || isCatalogComment(line)) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
634
|
+
const catalogMatch = line.match(/^(\s*)productCatalog:\s*$/);
|
|
635
|
+
if (catalogMatch) {
|
|
636
|
+
inCatalog = true;
|
|
637
|
+
catalogIndent = catalogMatch[1].length;
|
|
638
|
+
itemIndent = undefined;
|
|
639
|
+
currentProductId = "";
|
|
640
|
+
currentIconUri = "";
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (inCatalog && indent <= catalogIndent) {
|
|
644
|
+
flushProductIcon();
|
|
645
|
+
currentProductId = "";
|
|
646
|
+
currentIconUri = "";
|
|
647
|
+
inCatalog = false;
|
|
648
|
+
itemIndent = undefined;
|
|
649
|
+
}
|
|
650
|
+
if (!inCatalog) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const itemMatch = line.match(/^(\s*)-\s*(.*)$/);
|
|
655
|
+
if (itemMatch) {
|
|
656
|
+
if (itemIndent !== undefined && indent > itemIndent) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
flushProductIcon();
|
|
660
|
+
itemIndent = itemMatch[1].length;
|
|
661
|
+
currentIconUri = "";
|
|
662
|
+
currentProductId = "";
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const idValue = catalogFieldValue(line, "id");
|
|
666
|
+
if (idValue !== undefined) {
|
|
667
|
+
currentProductId = idValue;
|
|
668
|
+
}
|
|
669
|
+
const iconValue = catalogFieldValue(line, "iconUri");
|
|
670
|
+
if (iconValue !== undefined) {
|
|
671
|
+
currentIconUri = iconValue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (inCatalog) {
|
|
675
|
+
flushProductIcon();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return assets;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function projectImageAssets(project: any): LocalAiModerationAsset[] {
|
|
682
|
+
const root = project.projectDirectory.appended(new Editor.Path("Assets"));
|
|
683
|
+
if (!FileSystem.exists(root) || !FileSystem.isDirectory(root)) {
|
|
684
|
+
return [];
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return FileSystem.readDir(root, { recursive: true })
|
|
688
|
+
.map((entry: Editor.Path) => root.appended(entry))
|
|
689
|
+
.filter((path: Editor.Path) => FileSystem.exists(path) && FileSystem.isFile(path) && isImageAsset(path))
|
|
690
|
+
.map((path: Editor.Path) =>
|
|
691
|
+
localImageAsset(
|
|
692
|
+
`project_asset:${path.relative(project.projectDirectory).toString()}`,
|
|
693
|
+
"project_image_asset",
|
|
694
|
+
path,
|
|
695
|
+
"project-image-asset",
|
|
696
|
+
),
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function dedupeAssets(assets: LocalAiModerationAsset[]): LocalAiModerationAsset[] {
|
|
701
|
+
const seen = new Set<string>();
|
|
702
|
+
return assets.filter((asset) => {
|
|
703
|
+
const key = asset.iconPath ? `path:${asset.iconPath}` : `${asset.role}:${asset.assetId}:${asset.skipReason ?? ""}`;
|
|
704
|
+
if (seen.has(key)) {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
seen.add(key);
|
|
708
|
+
return true;
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function buildLocalModerationAssets(project: any, files: ProjectTextFile[]): LocalAiModerationAsset[] {
|
|
713
|
+
return dedupeAssets([
|
|
714
|
+
lensIconAssetForLocalAi(project),
|
|
715
|
+
...files.flatMap((file) => productCatalogIconAssets(project, file)),
|
|
716
|
+
...projectImageAssets(project),
|
|
717
|
+
]);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function buildCommerceKitPreflightInput(files: ProjectTextFile[], packageId: string): CommerceKitPreflightInput {
|
|
721
|
+
const commerceFiles = files
|
|
722
|
+
.map((file) => ({ file, kinds: commerceIndicatorKinds(file.text) }))
|
|
723
|
+
.filter((entry) => entry.kinds.length > 0);
|
|
724
|
+
|
|
725
|
+
const productIdSources: CommerceKitProductIdSource[] = [];
|
|
726
|
+
for (const entry of commerceFiles) {
|
|
727
|
+
productIdSources.push(...queryProductDetailsSources(entry.file));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const invalidProductIds: CommerceKitInvalidProductId[] = [];
|
|
731
|
+
for (const source of productIdSources) {
|
|
732
|
+
if (source.sourceKind !== "queryProductDetails") {
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
for (const productId of source.productIds) {
|
|
736
|
+
if (!hasPackagePrefix(productId, packageId)) {
|
|
737
|
+
invalidProductIds.push({
|
|
738
|
+
productId,
|
|
739
|
+
expectedPrefix: packageId,
|
|
740
|
+
sourcePath: source.sourcePath,
|
|
741
|
+
sourceKind: source.sourceKind,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
used: commerceFiles.length > 0,
|
|
749
|
+
indicators: commerceFiles.flatMap((entry) => entry.kinds.map((kind) => ({ sourcePath: entry.file.path.toString(), kind }))),
|
|
750
|
+
productIdSources,
|
|
751
|
+
invalidProductIds,
|
|
752
|
+
unresolvedProductIdSources: productIdSources.filter((source) => Boolean(source.unresolvedExpression)),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Agent-only, advisory local AI moderation input. This does NOT call a backend and does
|
|
757
|
+
// not decide approval; it only gives the orchestrating agent the local evidence it can
|
|
758
|
+
// inspect before export.
|
|
759
|
+
function buildLocalAiModerationInput(name: string, packageId: string, project: any): LocalAiModerationInput {
|
|
760
|
+
const projectTextFiles = readTextFiles(project, "Assets");
|
|
761
|
+
return {
|
|
762
|
+
policyVersion: "generic-ai-review-2026-04-07",
|
|
763
|
+
releaseText: { name, description: "", tags: [] },
|
|
764
|
+
packageId,
|
|
765
|
+
assets: buildLocalModerationAssets(project, projectTextFiles),
|
|
766
|
+
commerceKit: buildCommerceKitPreflightInput(projectTextFiles, packageId),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
const auth = pluginSystem.findInterface(Editor.IAuthorization);
|
|
772
|
+
if (!auth) {
|
|
773
|
+
return actionRequired("no_auth_interface", "Lens Studio authorization is unavailable. Sign in to Lens Studio, then retry.");
|
|
774
|
+
}
|
|
775
|
+
if (!auth.isAuthorized) {
|
|
776
|
+
return actionRequired("not_signed_in", "Sign in to Lens Studio from the profile menu, then retry.");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const project = pluginSystem.findInterface(Editor.Model.IModel).project;
|
|
780
|
+
const projectPath = project.projectFile.toString();
|
|
781
|
+
const projectText = FileSystem.readFile(project.projectFile);
|
|
782
|
+
const packageId = oneLine(readProjectScalar(projectText, "packageId"));
|
|
783
|
+
const lensName = oneLine(stripYamlScalarQuotes(project.metaInfo.lensName) || readProjectScalar(projectText, "lensName"));
|
|
784
|
+
|
|
785
|
+
// Collect ALL local-fix issues in one pass so the agent can prompt for everything at
|
|
786
|
+
// once. Auth/project-state issues above still bail early because nothing else can run.
|
|
787
|
+
const issues: { reason: string; message: string }[] = [];
|
|
788
|
+
|
|
789
|
+
// The caller compares projectPath against the .esproj resolved in Discover; a mismatch
|
|
790
|
+
// means the wrong project is open (ask the user to open the right one).
|
|
791
|
+
const missingPackageId = !packageId;
|
|
792
|
+
const missingLensName = !lensName || isDefaultLensName(lensName);
|
|
793
|
+
if (missingPackageId || missingLensName) {
|
|
794
|
+
const message =
|
|
795
|
+
missingPackageId && missingLensName
|
|
796
|
+
? "The project needs a package ID and a non-default Lens name before it can be submitted."
|
|
797
|
+
: missingPackageId
|
|
798
|
+
? "The project needs a package ID before it can be submitted."
|
|
799
|
+
: "The project needs a non-default Lens name before it can be submitted.";
|
|
800
|
+
issues.push({
|
|
801
|
+
reason: "missing_submission_metadata",
|
|
802
|
+
message,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const signingKey = project.metaInfo.spkProductionKeyPath;
|
|
807
|
+
if (!signingKey || signingKey.isEmpty || !FileSystem.exists(signingKey)) {
|
|
808
|
+
issues.push({
|
|
809
|
+
reason: "no_prod_signing_key",
|
|
810
|
+
message: "This project needs a production signing key before export. Generate or add one (reusing an existing key is fine) in Project Settings > SPECS Settings.",
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// isIconSet is false when the project still has the default placeholder icon
|
|
815
|
+
// (Qt resource path under :/Model/Icons/metainfo/...). Submission requires a real icon.
|
|
816
|
+
if (!project.metaInfo.isIconSet) {
|
|
817
|
+
issues.push({
|
|
818
|
+
reason: "no_lens_icon",
|
|
819
|
+
message: "This project needs a Lens icon before it can be submitted. The publish flow will generate and assign one automatically.",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const localAiModerationInput = buildLocalAiModerationInput(lensName, packageId, project);
|
|
824
|
+
|
|
825
|
+
if (issues.length > 0) {
|
|
826
|
+
// `reason` + `message` mirror the first issue for back-compat with single-reason
|
|
827
|
+
// branching; `issues` lists everything so the agent can present them together.
|
|
828
|
+
return {
|
|
829
|
+
status: "ACTION_REQUIRED",
|
|
830
|
+
stage: "preflight",
|
|
831
|
+
reason: issues[0].reason,
|
|
832
|
+
message: issues[0].message,
|
|
833
|
+
issues,
|
|
834
|
+
localAiModerationInput,
|
|
835
|
+
projectPath,
|
|
836
|
+
packageId,
|
|
837
|
+
lensName,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return {
|
|
842
|
+
status: "READY",
|
|
843
|
+
stage: "preflight",
|
|
844
|
+
projectPath,
|
|
845
|
+
packageId,
|
|
846
|
+
lensName,
|
|
847
|
+
hasProductionKey: true,
|
|
848
|
+
localAiModerationInput,
|
|
849
|
+
};
|
|
850
|
+
} catch (error) {
|
|
851
|
+
return failed("exception", errorToString(error), { stack: error instanceof Error ? error.stack : undefined });
|
|
852
|
+
}
|