@vgai/editor 0.4.0 → 0.4.1-canary.20260715.0

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.
Files changed (69) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-DaRRWiOi.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-DLd8cEzW.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-BwRLsjpc.js} +1 -1
  4. package/dist/assets/{game-DeTGdgYt.js → game-Bi4xWV6Y.js} +1 -1
  5. package/dist/assets/{game-BCNN63uV.js → game-BlEDdPMm.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-CKXTrtx6.js} +1 -1
  7. package/dist/assets/{game-B346oj_f.js → game-CjodSgcy.js} +1 -1
  8. package/dist/assets/{game-u6c2Patu.js → game-CpSujmHR.js} +1 -1
  9. package/dist/assets/{game-umrHIqFB.js → game-D5NFr6FT.js} +1 -1
  10. package/dist/assets/{game-CLNm2g0y.js → game-PKdSMKkV.js} +1 -1
  11. package/dist/assets/{game-DQxhCYsZ.js → game-dsUNL9SB.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-X827vWLi.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-BjjzZVF9.js} +1 -1
  14. package/dist/assets/{index-CgcH1RpF.js → index-BLcaXrPd.js} +1 -1
  15. package/dist/assets/index-BtZ1fX9i.css +1 -0
  16. package/dist/assets/{index-DbQzGLxv.js → index-BvKfaQa-.js} +815 -815
  17. package/dist/assets/{index-Fc8Ezr20.js → index-kEMc_dJ8.js} +1 -1
  18. package/dist/assets/{index-D3tcNB2T.js → index-ou19ogsR.js} +1 -1
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-C2PS3-dY.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-BSS7Se0W.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DqPett-m.js} +1 -1
  22. package/dist/assets/webworkerAll-njZ4iAXd.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DjmEFkN0.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +26752 -883
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/editor-server.ts +67 -5
  33. package/server/model-import-conversion.ts +345 -0
  34. package/server/project-output-writer.ts +412 -36
  35. package/src/asset-editor-context.tsx +9 -0
  36. package/src/asset-workflow/asset-capabilities.ts +21 -0
  37. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  38. package/src/asset-workflow/import-contract.ts +2 -0
  39. package/src/asset-workflow/model-inspection.ts +92 -1
  40. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  41. package/src/asset-workflow/project-asset-commands.ts +1 -0
  42. package/src/asset-workflow/project-asset-operations.ts +119 -4
  43. package/src/command-listener.ts +20 -3
  44. package/src/components/AssetBrowser.tsx +301 -26
  45. package/src/components/AssetImportDetails.tsx +207 -92
  46. package/src/components/AssetImportJobStatus.tsx +55 -0
  47. package/src/components/DefaultEditorLayout.tsx +4 -0
  48. package/src/components/OnlineAssetBrowser.tsx +197 -11
  49. package/src/components/WorkspaceDock.tsx +103 -17
  50. package/src/components/asset-documents.tsx +8 -0
  51. package/src/components/asset-editor-persistence.ts +300 -5
  52. package/src/components/asset-selection-section.tsx +1 -0
  53. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  54. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  55. package/src/components/asset-viewers/OnlineAssetDetail.tsx +45 -9
  56. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  57. package/src/components/asset-workflow.css +26 -0
  58. package/src/components/workspace-dock.css +16 -0
  59. package/src/editor-api.ts +159 -22
  60. package/src/play-mode.ts +12 -3
  61. package/src/storage/handle-storage.ts +5 -1
  62. package/src/storage/http-storage.ts +5 -1
  63. package/src/storage/mem-storage.ts +1 -0
  64. package/src/storage/types.ts +2 -0
  65. package/src/workspace-dock-controller.ts +8 -0
  66. package/template/vgai.game.json +1 -1
  67. package/dist/assets/index-bAGhEnnO.css +0 -1
  68. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  69. package/dist/project-scripts.bundle.js +0 -3917
@@ -0,0 +1,97 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import {
5
+ type AssetImportPlan,
6
+ assetImportPlanSchema,
7
+ importRecipePath,
8
+ } from '../src/asset-workflow/import-contract';
9
+
10
+ export interface CatalogRecipeSource {
11
+ stagedPath: string;
12
+ path: string;
13
+ url: string;
14
+ role: 'primary' | 'dependency' | 'material' | 'texture';
15
+ }
16
+
17
+ export async function writeCatalogImportRecipe(input: {
18
+ stagingDirectory: string;
19
+ source: string;
20
+ familyId: string;
21
+ variantId: string;
22
+ deliveryId: string;
23
+ displayName: string;
24
+ destination: string;
25
+ recipeRelativePath?: string;
26
+ sources: readonly CatalogRecipeSource[];
27
+ generatedFiles?: readonly string[];
28
+ contentKind?: AssetImportPlan['contentKind'];
29
+ targetSkeleton?: string;
30
+ runtimeReady: boolean;
31
+ backend: AssetImportPlan['backend'];
32
+ attribution: AssetImportPlan['attribution'];
33
+ settings?: Partial<AssetImportPlan['settings']>;
34
+ }): Promise<AssetImportPlan> {
35
+ const sourceFiles = await Promise.all(
36
+ input.sources.map(async (file) => {
37
+ const [bytes, info] = await Promise.all([readFile(file.stagedPath), stat(file.stagedPath)]);
38
+ return {
39
+ path: file.path,
40
+ url: file.url,
41
+ hash: createHash('sha256').update(bytes).digest('hex'),
42
+ size: info.size,
43
+ role: file.role,
44
+ };
45
+ }),
46
+ );
47
+ const sourceHash = createHash('sha256')
48
+ .update(
49
+ JSON.stringify(
50
+ sourceFiles
51
+ .map(({ path, hash, size }) => ({ path, hash, size }))
52
+ .sort((left, right) => left.path.localeCompare(right.path)),
53
+ ),
54
+ )
55
+ .digest('hex');
56
+ const plan = assetImportPlanSchema.parse({
57
+ version: 1,
58
+ id: randomUUID(),
59
+ identity: {
60
+ familyId: input.familyId,
61
+ variantId: input.variantId,
62
+ deliveryId: input.deliveryId,
63
+ source: input.source,
64
+ displayName: input.displayName,
65
+ },
66
+ sourceFiles,
67
+ generatedFiles: [...(input.generatedFiles ?? [])],
68
+ destination: input.destination,
69
+ contentKind: input.contentKind ?? 'model',
70
+ ...(input.targetSkeleton ? { targetSkeleton: input.targetSkeleton } : {}),
71
+ settings: {
72
+ scale: 1,
73
+ sourceUnits: 'auto',
74
+ upAxis: 'auto',
75
+ forwardAxis: 'auto',
76
+ normals: 'preserve',
77
+ tangents: 'preserve',
78
+ materials: 'import',
79
+ textures: 'copy',
80
+ animations: [],
81
+ meshOptimization: 'safe',
82
+ retainSource: true,
83
+ ...input.settings,
84
+ },
85
+ conflicts: [],
86
+ attribution: input.attribution,
87
+ sourceHash,
88
+ runtimeReady: input.runtimeReady,
89
+ backend: input.backend,
90
+ createdAt: new Date().toISOString(),
91
+ });
92
+ const recipeFile = input.recipeRelativePath ?? basename(importRecipePath(input.destination));
93
+ const recipePath = join(input.stagingDirectory, recipeFile);
94
+ await mkdir(dirname(recipePath), { recursive: true });
95
+ await writeFile(recipePath, `${JSON.stringify(plan, null, 2)}\n`);
96
+ return plan;
97
+ }
@@ -0,0 +1,141 @@
1
+ import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
3
+ import { type AssetImportPlan, assetImportPlanSchema } from '../src/asset-workflow/import-contract';
4
+ import { type CatalogRecipeSource, writeCatalogImportRecipe } from './catalog-import-recipe';
5
+ import {
6
+ findLocalAsset,
7
+ findLocalAssetFamily,
8
+ getLocalAssetLibraryRoot,
9
+ resolveCatalogFile,
10
+ resolveLocalAssetThumbnail,
11
+ } from './local-asset-catalog';
12
+ import {
13
+ convertStagedModelToGlb,
14
+ SERVER_CONVERTIBLE_MODEL_FORMATS,
15
+ } from './model-import-conversion';
16
+ import { replaceStagedProjectDirectory } from './project-output-writer';
17
+ import { isPathInside } from './server-utils';
18
+
19
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: reacquisition, conversion, recipe regeneration, source-retention, and atomic replacement are one transaction boundary.
20
+ export async function reimportLocalCatalogAsset(
21
+ publicRoot: string,
22
+ assetPath: string,
23
+ rawPlan: AssetImportPlan,
24
+ ): Promise<{ path: string; provenanceOperationId?: string }> {
25
+ const plan = assetImportPlanSchema.parse(rawPlan);
26
+ const normalizedAssetPath = assetPath.replace(/^\/+/, '');
27
+ if (normalizedAssetPath !== plan.destination || plan.identity.source !== 'local') {
28
+ throw new Error('Reimport identity does not match the selected local project asset.');
29
+ }
30
+ const family = await findLocalAssetFamily(plan.identity.familyId);
31
+ const variant = await findLocalAsset(plan.identity.variantId);
32
+ if (!family || !variant || variant.familyId !== family.id) {
33
+ throw new Error('The recorded catalog family/variant is no longer available.');
34
+ }
35
+ const sourceFile = variant.files[0];
36
+ if (!sourceFile) throw new Error('The recorded catalog variant has no source file.');
37
+ const destination = resolve(publicRoot, dirname(plan.destination));
38
+ if (!isPathInside(publicRoot, destination))
39
+ throw new Error('Reimport destination escapes public/.');
40
+ const staging = await mkdtemp(join(dirname(destination), `.${basename(destination)}.reimport-`));
41
+ try {
42
+ const libraryRoot = getLocalAssetLibraryRoot();
43
+ const primarySource = resolveCatalogFile(libraryRoot, sourceFile.path);
44
+ const primaryRelative = basename(primarySource);
45
+ const stagedPrimary = join(staging, primaryRelative);
46
+ await copyFile(primarySource, stagedPrimary);
47
+ const recipeSources: CatalogRecipeSource[] = [
48
+ {
49
+ stagedPath: stagedPrimary,
50
+ path: primaryRelative,
51
+ url: plan.sourceFiles[0]?.url ?? `vgai-local:${variant.id}:0`,
52
+ role: 'primary',
53
+ },
54
+ ];
55
+ for (const dependency of sourceFile.dependencies ?? []) {
56
+ const dependencySource = resolveCatalogFile(libraryRoot, dependency);
57
+ const sourceRelative = relative(dirname(primarySource), dependencySource);
58
+ const dependencyRelative = sourceRelative.startsWith('..')
59
+ ? basename(dependencySource)
60
+ : sourceRelative;
61
+ const destinationPath = join(staging, dependencyRelative);
62
+ await mkdir(dirname(destinationPath), { recursive: true });
63
+ await copyFile(dependencySource, destinationPath);
64
+ recipeSources.push({
65
+ stagedPath: destinationPath,
66
+ path: dependencyRelative.replaceAll('\\', '/'),
67
+ url: `catalog:${dependency}`,
68
+ role: dependencyRole(dependency),
69
+ });
70
+ }
71
+ const format = sourceFile.format.toLowerCase();
72
+ const resultFile = basename(plan.destination);
73
+ let backend: AssetImportPlan['backend'] = 'native';
74
+ if (SERVER_CONVERTIBLE_MODEL_FORMATS.has(format)) {
75
+ const output = await convertStagedModelToGlb(stagedPrimary, format, plan.settings);
76
+ await writeFile(join(staging, resultFile), output);
77
+ backend = 'threejs';
78
+ } else if (resultFile !== primaryRelative) {
79
+ await copyFile(stagedPrimary, join(staging, resultFile));
80
+ }
81
+ try {
82
+ const thumbnail = resolveLocalAssetThumbnail(libraryRoot, variant);
83
+ await copyFile(thumbnail, join(staging, '.vgai-thumbnail.webp'));
84
+ } catch {
85
+ // Thumbnails are a presentation cache and never invalidate a source reimport.
86
+ }
87
+ await writeCatalogImportRecipe({
88
+ stagingDirectory: staging,
89
+ source: 'local',
90
+ familyId: family.id,
91
+ variantId: variant.id,
92
+ deliveryId: plan.identity.deliveryId,
93
+ displayName: plan.identity.displayName ?? family.name,
94
+ destination: plan.destination,
95
+ sources: recipeSources,
96
+ contentKind: plan.contentKind,
97
+ ...(plan.targetSkeleton ? { targetSkeleton: plan.targetSkeleton } : {}),
98
+ generatedFiles: backend === 'threejs' ? [plan.destination] : [],
99
+ runtimeReady: ['glb', 'gltf'].includes(format) || backend === 'threejs',
100
+ backend,
101
+ attribution: plan.attribution,
102
+ settings: plan.settings,
103
+ });
104
+ if (backend === 'threejs' && !plan.settings.retainSource) {
105
+ await Promise.all(recipeSources.map((source) => rm(source.stagedPath, { force: true })));
106
+ }
107
+ const result = await replaceStagedProjectDirectory({
108
+ projectRoot: dirname(publicRoot),
109
+ stagedDirectory: staging,
110
+ destination,
111
+ provenance: {
112
+ operationName: 'catalog.asset.reimport',
113
+ operationSource: 'vgai-catalog',
114
+ input: {
115
+ familyId: family.id,
116
+ variantId: variant.id,
117
+ sourceHash: plan.sourceHash,
118
+ settings: plan.settings,
119
+ },
120
+ },
121
+ });
122
+ return {
123
+ path: plan.destination,
124
+ ...(result.provenanceOperationId
125
+ ? { provenanceOperationId: result.provenanceOperationId }
126
+ : {}),
127
+ };
128
+ } catch (error) {
129
+ await rm(staging, { recursive: true, force: true });
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function dependencyRole(path: string): 'dependency' | 'material' | 'texture' {
135
+ const extension = extname(path).toLowerCase();
136
+ if (extension === '.mtl') return 'material';
137
+ if (['.png', '.jpg', '.jpeg', '.webp', '.ktx', '.ktx2', '.dds', '.tga'].includes(extension)) {
138
+ return 'texture';
139
+ }
140
+ return 'dependency';
141
+ }
@@ -1,7 +1,7 @@
1
1
  export const DEFAULT_CLOUD_ASSET_BASE_URL = 'https://vgai-asset-library.aaron-0ed.workers.dev';
2
2
 
3
3
  export interface CloudAssetObject {
4
- key: string;
4
+ key?: string;
5
5
  sha256: string;
6
6
  sizeBytes: number;
7
7
  contentType: string;
@@ -17,6 +17,8 @@ export interface CloudAssetFile {
17
17
 
18
18
  export interface CloudAssetRecord {
19
19
  id: string;
20
+ familyId?: string;
21
+ label?: string;
20
22
  status: 'validated';
21
23
  source: string;
22
24
  name: string;
@@ -25,17 +27,24 @@ export interface CloudAssetRecord {
25
27
  author: string;
26
28
  sourceUrl: string;
27
29
  attribution: string;
30
+ categories?: string[];
31
+ tags?: string[];
28
32
  files: CloudAssetFile[];
29
33
  }
30
34
 
31
35
  interface CloudAssetManifest {
32
- version: 1;
36
+ version: 1 | 2;
33
37
  generatedAt: string;
34
38
  assets: CloudAssetRecord[];
35
39
  }
36
40
 
37
41
  let cachedManifest: { value: CloudAssetManifest; url: string; loadedAt: number } | undefined;
38
42
  const CACHE_MS = 5 * 60 * 1000;
43
+ const GRAPHQL_ID_BATCH = 500;
44
+ const graphqlAssetCache = new Map<
45
+ string,
46
+ { baseUrl: string; value: CloudAssetRecord | null; loadedAt: number }
47
+ >();
39
48
 
40
49
  export function getCloudAssetBaseUrl(): string {
41
50
  return (process.env['VGAI_CLOUD_ASSET_URL'] ?? DEFAULT_CLOUD_ASSET_BASE_URL).replace(/\/+$/, '');
@@ -54,7 +63,7 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
54
63
  const response = await fetch(`${baseUrl}/v1/manifest`, { signal: AbortSignal.timeout(5_000) });
55
64
  if (!response.ok) return null;
56
65
  const value = (await response.json()) as CloudAssetManifest;
57
- if (value.version !== 1 || !Array.isArray(value.assets)) return null;
66
+ if ((value.version !== 1 && value.version !== 2) || !Array.isArray(value.assets)) return null;
58
67
  cachedManifest = { value, url: baseUrl, loadedAt: Date.now() };
59
68
  return value;
60
69
  } catch {
@@ -63,8 +72,65 @@ export async function loadCloudAssetManifest(): Promise<CloudAssetManifest | nul
63
72
  }
64
73
 
65
74
  export async function findCloudAsset(id: string): Promise<CloudAssetRecord | null> {
66
- const manifest = await loadCloudAssetManifest();
67
- return manifest?.assets.find((asset) => asset.id === id) ?? null;
75
+ return (await findCloudAssets([id]))[0] ?? null;
76
+ }
77
+
78
+ async function queryCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[] | null> {
79
+ if (ids.length === 0) return [];
80
+ const response = await fetch(`${getCloudAssetBaseUrl()}/graphql`, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify({
84
+ query: `query CloudAssets($ids: [ID!]!) {
85
+ assetsByIds(ids: $ids) {
86
+ id familyId label status source name type license author sourceUrl attribution categories tags
87
+ files: deliveries {
88
+ label format
89
+ main { sha256 sizeBytes contentType relativePath }
90
+ dependencies { sha256 sizeBytes contentType relativePath }
91
+ }
92
+ }
93
+ }`,
94
+ variables: { ids },
95
+ }),
96
+ signal: AbortSignal.timeout(5_000),
97
+ });
98
+ if (!response.ok) return null;
99
+ const body = (await response.json()) as {
100
+ data?: { assetsByIds?: CloudAssetRecord[] };
101
+ errors?: unknown[];
102
+ };
103
+ return body.errors || !Array.isArray(body.data?.assetsByIds) ? null : body.data.assetsByIds;
104
+ }
105
+
106
+ /** Resolve exact hosted variants through GraphQL; REST manifest is rollout fallback only. */
107
+ export async function findCloudAssets(ids: readonly string[]): Promise<CloudAssetRecord[]> {
108
+ const uniqueIds = [...new Set(ids)];
109
+ const baseUrl = getCloudAssetBaseUrl();
110
+ const now = Date.now();
111
+ const unresolved = uniqueIds.filter((id) => {
112
+ const cached = graphqlAssetCache.get(id);
113
+ return !cached || cached.baseUrl !== baseUrl || now - cached.loadedAt >= CACHE_MS;
114
+ });
115
+ try {
116
+ for (let offset = 0; offset < unresolved.length; offset += GRAPHQL_ID_BATCH) {
117
+ const batch = unresolved.slice(offset, offset + GRAPHQL_ID_BATCH);
118
+ const found = await queryCloudAssets(batch);
119
+ if (!found) throw new Error('GraphQL catalog unavailable.');
120
+ const byId = new Map(found.map((asset) => [asset.id, asset]));
121
+ for (const id of batch) {
122
+ graphqlAssetCache.set(id, { baseUrl, value: byId.get(id) ?? null, loadedAt: now });
123
+ }
124
+ }
125
+ return uniqueIds.flatMap((id) => {
126
+ const asset = graphqlAssetCache.get(id)?.value;
127
+ return asset ? [asset] : [];
128
+ });
129
+ } catch {
130
+ const manifest = await loadCloudAssetManifest();
131
+ const requested = new Set(uniqueIds);
132
+ return manifest?.assets.filter((asset) => requested.has(asset.id)) ?? [];
133
+ }
68
134
  }
69
135
 
70
136
  export function cloudAssetObjectUrl(
@@ -19,6 +19,13 @@ import express from 'express';
19
19
  import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
20
20
  import { applyAssetRename } from '../../engine/src/scene/asset-paths';
21
21
  import type { SceneFile } from '../../engine/src/scene/scene-types';
22
+ import {
23
+ deriveProjectAttributionReport,
24
+ emptyProjectProvenanceDocument,
25
+ formatProjectAttributionMarkdown,
26
+ PROJECT_PROVENANCE_PATH,
27
+ ProjectProvenanceDocumentSchema,
28
+ } from '../../vgai-sdk/src/project/provenance';
22
29
  import {
23
30
  assertEditorCompatibility,
24
31
  assertProjectCompatibility,
@@ -70,6 +77,7 @@ interface AssetEntry {
70
77
  name: string;
71
78
  type: 'file' | 'directory';
72
79
  size: number;
80
+ mtime: number;
73
81
  }
74
82
 
75
83
  interface RecentProject {
@@ -852,6 +860,55 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
852
860
  createAssetLibraryRouter(() => publicRoot),
853
861
  );
854
862
 
863
+ // Derived credits view. The sole durable source is central project provenance.
864
+ router.get('/__editor/project-attribution', async (req: Request, res: Response) => {
865
+ if (projectRoot === engineRoot) {
866
+ res.status(400).json({ error: 'No project is open.' });
867
+ return;
868
+ }
869
+ try {
870
+ const raw = await readFile(join(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8').catch(
871
+ (error: NodeJS.ErrnoException) => {
872
+ if (error.code === 'ENOENT') return null;
873
+ throw error;
874
+ },
875
+ );
876
+ const provenance = raw
877
+ ? ProjectProvenanceDocumentSchema.parse(JSON.parse(raw))
878
+ : emptyProjectProvenanceDocument();
879
+ const activeOperations = Object.fromEntries(
880
+ (
881
+ await Promise.all(
882
+ Object.entries(provenance.operations).map(async ([operationId, operation]) => {
883
+ const present = await Promise.all(
884
+ operation.outputs.map(async (output) => {
885
+ const absolute = resolve(projectRoot, output.path);
886
+ if (!isPathInside(projectRoot, absolute)) return false;
887
+ return stat(absolute).then(
888
+ (info) => info.isFile(),
889
+ () => false,
890
+ );
891
+ }),
892
+ );
893
+ return present.some(Boolean) ? ([operationId, operation] as const) : null;
894
+ }),
895
+ )
896
+ ).filter((entry): entry is readonly [string, (typeof provenance.operations)[string]] =>
897
+ Boolean(entry),
898
+ ),
899
+ );
900
+ const report = deriveProjectAttributionReport({ version: 1, operations: activeOperations });
901
+ const format = typeof req.query['format'] === 'string' ? req.query['format'] : 'json';
902
+ if (format === 'markdown' || req.accepts(['json', 'text/markdown']) === 'text/markdown') {
903
+ res.type('text/markdown').send(formatProjectAttributionMarkdown(report));
904
+ return;
905
+ }
906
+ res.json(report);
907
+ } catch (error) {
908
+ res.status(500).json({ error: `Project attribution failed: ${error}` });
909
+ }
910
+ });
911
+
855
912
  // ---- Project file serving middleware ----
856
913
  // Serves the v2 manifest from root and static files from project's public/
857
914
  router.use((req: Request, res: Response, next) => {
@@ -1253,11 +1310,11 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
1253
1310
  for (const d of dirents) {
1254
1311
  if (d.name.startsWith('.')) continue;
1255
1312
  const entryPath = join(absDir, d.name);
1313
+ const s = await stat(entryPath);
1256
1314
  if (d.isDirectory()) {
1257
- entries.push({ name: d.name, type: 'directory', size: 0 });
1315
+ entries.push({ name: d.name, type: 'directory', size: 0, mtime: s.mtimeMs });
1258
1316
  } else if (d.isFile()) {
1259
- const s = await stat(entryPath);
1260
- entries.push({ name: d.name, type: 'file', size: s.size });
1317
+ entries.push({ name: d.name, type: 'file', size: s.size, mtime: s.mtimeMs });
1261
1318
  }
1262
1319
  }
1263
1320
 
@@ -2251,6 +2308,7 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2251
2308
  // ---- Play-mode console log persistence ----
2252
2309
  const MAX_LOG_FILES = 20;
2253
2310
  let activeLogFile: string | null = null;
2311
+ let logSessionSequence = 0;
2254
2312
 
2255
2313
  router.post('/__editor/log-session', async (req: Request, res: Response) => {
2256
2314
  const body = req.body as { action: 'start' | 'end' };
@@ -2273,8 +2331,12 @@ export function createEditorServer(options: EditorServerOptions): EditorServerRo
2273
2331
  }
2274
2332
 
2275
2333
  const now = new Date();
2276
- const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
2277
- const filename = `play-${ts}.jsonl`;
2334
+ // Milliseconds plus a process-local sequence make restart artifacts
2335
+ // distinct even when two sessions begin inside the same millisecond.
2336
+ // Lexicographic order remains chronological for pruning/tail tooling.
2337
+ const ts = now.toISOString().replace(/[:.]/g, '-');
2338
+ const sequence = String(++logSessionSequence).padStart(4, '0');
2339
+ const filename = `play-${ts}-${sequence}.jsonl`;
2278
2340
  activeLogFile = join(logsDir, filename);
2279
2341
  res.json({ ok: true, file: filename });
2280
2342
  } else {