scene-capability-engine 3.3.24 → 3.3.26

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.
@@ -0,0 +1,598 @@
1
+ const path = require('path');
2
+ const { spawnSync } = require('child_process');
3
+ const fs = require('fs-extra');
4
+ const minimatchModule = require('minimatch');
5
+
6
+ const minimatch = typeof minimatchModule === 'function'
7
+ ? minimatchModule
8
+ : (minimatchModule && typeof minimatchModule.minimatch === 'function'
9
+ ? minimatchModule.minimatch
10
+ : () => false);
11
+
12
+ const TIMELINE_SCHEMA_VERSION = '1.0';
13
+ const TIMELINE_CONFIG_RELATIVE_PATH = path.join('.sce', 'config', 'timeline.json');
14
+ const TIMELINE_DIR = path.join('.sce', 'timeline');
15
+ const TIMELINE_INDEX_FILE = 'index.json';
16
+ const TIMELINE_SNAPSHOTS_DIR = 'snapshots';
17
+
18
+ const DEFAULT_TIMELINE_CONFIG = Object.freeze({
19
+ enabled: true,
20
+ auto_interval_minutes: 30,
21
+ max_entries: 120,
22
+ exclude_paths: [
23
+ '.git/**',
24
+ 'node_modules/**',
25
+ '.sce/timeline/**',
26
+ 'coverage/**',
27
+ 'dist/**',
28
+ 'build/**',
29
+ '.next/**',
30
+ 'tmp/**',
31
+ 'temp/**'
32
+ ]
33
+ });
34
+
35
+ function nowIso() {
36
+ return new Date().toISOString();
37
+ }
38
+
39
+ function normalizePosix(relativePath) {
40
+ return `${relativePath || ''}`.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
41
+ }
42
+
43
+ function normalizeBoolean(value, fallback = false) {
44
+ if (typeof value === 'boolean') {
45
+ return value;
46
+ }
47
+ const normalized = `${value || ''}`.trim().toLowerCase();
48
+ if (!normalized) {
49
+ return fallback;
50
+ }
51
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
52
+ return true;
53
+ }
54
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
55
+ return false;
56
+ }
57
+ return fallback;
58
+ }
59
+
60
+ function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) {
61
+ const parsed = Number.parseInt(`${value}`, 10);
62
+ if (!Number.isFinite(parsed) || parsed <= 0) {
63
+ return fallback;
64
+ }
65
+ return Math.min(parsed, max);
66
+ }
67
+
68
+ function safeSnapshotId(value) {
69
+ return `${value || ''}`
70
+ .trim()
71
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
72
+ .replace(/^-+|-+$/g, '')
73
+ .slice(0, 96);
74
+ }
75
+
76
+ function createSnapshotId(prefix = 'ts') {
77
+ const now = new Date();
78
+ const yyyy = now.getUTCFullYear();
79
+ const mm = `${now.getUTCMonth() + 1}`.padStart(2, '0');
80
+ const dd = `${now.getUTCDate()}`.padStart(2, '0');
81
+ const hh = `${now.getUTCHours()}`.padStart(2, '0');
82
+ const mi = `${now.getUTCMinutes()}`.padStart(2, '0');
83
+ const ss = `${now.getUTCSeconds()}`.padStart(2, '0');
84
+ const rand = Math.random().toString(36).slice(2, 8);
85
+ return `${prefix}-${yyyy}${mm}${dd}-${hh}${mi}${ss}-${rand}`;
86
+ }
87
+
88
+ class ProjectTimelineStore {
89
+ constructor(projectPath = process.cwd(), fileSystem = fs) {
90
+ this._projectPath = projectPath;
91
+ this._fileSystem = fileSystem;
92
+ this._timelineDir = path.join(projectPath, TIMELINE_DIR);
93
+ this._indexPath = path.join(this._timelineDir, TIMELINE_INDEX_FILE);
94
+ this._snapshotsDir = path.join(this._timelineDir, TIMELINE_SNAPSHOTS_DIR);
95
+ this._configPath = path.join(projectPath, TIMELINE_CONFIG_RELATIVE_PATH);
96
+ }
97
+
98
+ async getConfig() {
99
+ let filePayload = {};
100
+ if (await this._fileSystem.pathExists(this._configPath)) {
101
+ try {
102
+ filePayload = await this._fileSystem.readJson(this._configPath);
103
+ } catch (_error) {
104
+ filePayload = {};
105
+ }
106
+ }
107
+
108
+ const merged = {
109
+ ...DEFAULT_TIMELINE_CONFIG,
110
+ ...(filePayload && typeof filePayload === 'object' ? filePayload : {})
111
+ };
112
+
113
+ merged.enabled = normalizeBoolean(merged.enabled, DEFAULT_TIMELINE_CONFIG.enabled);
114
+ merged.auto_interval_minutes = normalizePositiveInteger(
115
+ merged.auto_interval_minutes,
116
+ DEFAULT_TIMELINE_CONFIG.auto_interval_minutes,
117
+ 24 * 60
118
+ );
119
+ merged.max_entries = normalizePositiveInteger(
120
+ merged.max_entries,
121
+ DEFAULT_TIMELINE_CONFIG.max_entries,
122
+ 10000
123
+ );
124
+
125
+ const rawExcludes = Array.isArray(merged.exclude_paths)
126
+ ? merged.exclude_paths
127
+ : DEFAULT_TIMELINE_CONFIG.exclude_paths;
128
+ merged.exclude_paths = Array.from(new Set(
129
+ rawExcludes
130
+ .map((item) => normalizePosix(item))
131
+ .filter(Boolean)
132
+ ));
133
+
134
+ return merged;
135
+ }
136
+
137
+ async updateConfig(patch = {}) {
138
+ const current = await this.getConfig();
139
+ const next = {
140
+ ...current,
141
+ ...(patch && typeof patch === 'object' ? patch : {})
142
+ };
143
+
144
+ next.enabled = normalizeBoolean(next.enabled, current.enabled);
145
+ next.auto_interval_minutes = normalizePositiveInteger(next.auto_interval_minutes, current.auto_interval_minutes, 24 * 60);
146
+ next.max_entries = normalizePositiveInteger(next.max_entries, current.max_entries, 10000);
147
+ next.exclude_paths = Array.from(new Set(
148
+ (Array.isArray(next.exclude_paths) ? next.exclude_paths : current.exclude_paths)
149
+ .map((item) => normalizePosix(item))
150
+ .filter(Boolean)
151
+ ));
152
+
153
+ await this._fileSystem.ensureDir(path.dirname(this._configPath));
154
+ await this._fileSystem.writeJson(this._configPath, next, { spaces: 2 });
155
+ return next;
156
+ }
157
+
158
+ async maybeAutoSnapshot(options = {}) {
159
+ const config = await this.getConfig();
160
+ if (!config.enabled) {
161
+ return {
162
+ mode: 'timeline-auto',
163
+ success: true,
164
+ created: false,
165
+ reason: 'disabled'
166
+ };
167
+ }
168
+
169
+ const index = await this._readIndex();
170
+ const latest = index.snapshots[0] || null;
171
+ const intervalMinutes = normalizePositiveInteger(options.intervalMinutes, config.auto_interval_minutes, 24 * 60);
172
+
173
+ if (latest && latest.created_at) {
174
+ const elapsedMs = Date.now() - Date.parse(latest.created_at);
175
+ if (Number.isFinite(elapsedMs) && elapsedMs < intervalMinutes * 60 * 1000) {
176
+ return {
177
+ mode: 'timeline-auto',
178
+ success: true,
179
+ created: false,
180
+ reason: 'interval-not-reached',
181
+ latest_snapshot_id: latest.snapshot_id,
182
+ minutes_remaining: Math.max(0, Math.ceil((intervalMinutes * 60 * 1000 - elapsedMs) / 60000))
183
+ };
184
+ }
185
+ }
186
+
187
+ const created = await this.saveSnapshot({
188
+ trigger: 'auto',
189
+ event: options.event || 'auto.tick',
190
+ summary: options.summary || 'auto timeline checkpoint'
191
+ });
192
+ return {
193
+ mode: 'timeline-auto',
194
+ success: true,
195
+ created: true,
196
+ snapshot: created
197
+ };
198
+ }
199
+
200
+ async saveSnapshot(options = {}) {
201
+ const config = await this.getConfig();
202
+ if (!config.enabled && options.force !== true) {
203
+ return {
204
+ mode: 'timeline-save',
205
+ success: true,
206
+ created: false,
207
+ reason: 'disabled'
208
+ };
209
+ }
210
+
211
+ await this._fileSystem.ensureDir(this._snapshotsDir);
212
+
213
+ const requestedId = safeSnapshotId(options.snapshotId);
214
+ const snapshotId = requestedId || createSnapshotId('tl');
215
+ const createdAt = nowIso();
216
+
217
+ const snapshotRoot = path.join(this._snapshotsDir, snapshotId);
218
+ const workspaceRoot = path.join(snapshotRoot, 'workspace');
219
+ await this._fileSystem.ensureDir(workspaceRoot);
220
+
221
+ const excludePatterns = this._buildExcludePatterns(config.exclude_paths);
222
+ const files = await this._collectWorkspaceFiles(excludePatterns);
223
+
224
+ let totalBytes = 0;
225
+ for (const relativePath of files) {
226
+ const sourcePath = path.join(this._projectPath, relativePath);
227
+ const targetPath = path.join(workspaceRoot, relativePath);
228
+ await this._fileSystem.ensureDir(path.dirname(targetPath));
229
+ await this._fileSystem.copyFile(sourcePath, targetPath);
230
+ const stat = await this._fileSystem.stat(targetPath);
231
+ totalBytes += Number(stat.size || 0);
232
+ }
233
+
234
+ const metadata = {
235
+ schema_version: TIMELINE_SCHEMA_VERSION,
236
+ snapshot_id: snapshotId,
237
+ created_at: createdAt,
238
+ trigger: `${options.trigger || 'manual'}`,
239
+ event: `${options.event || 'manual.save'}`,
240
+ summary: `${options.summary || ''}`.trim(),
241
+ session_id: `${options.sessionId || ''}`.trim() || null,
242
+ scene_id: `${options.sceneId || ''}`.trim() || null,
243
+ command: `${options.command || ''}`.trim() || null,
244
+ file_count: files.length,
245
+ total_bytes: totalBytes,
246
+ git: this._readGitStatus(),
247
+ files_manifest: 'files.json'
248
+ };
249
+
250
+ await this._fileSystem.writeJson(path.join(snapshotRoot, 'snapshot.json'), metadata, { spaces: 2 });
251
+ await this._fileSystem.writeJson(path.join(snapshotRoot, 'files.json'), {
252
+ snapshot_id: snapshotId,
253
+ file_count: files.length,
254
+ files
255
+ }, { spaces: 2 });
256
+
257
+ const index = await this._readIndex();
258
+ const entry = {
259
+ snapshot_id: snapshotId,
260
+ created_at: createdAt,
261
+ trigger: metadata.trigger,
262
+ event: metadata.event,
263
+ summary: metadata.summary,
264
+ scene_id: metadata.scene_id,
265
+ session_id: metadata.session_id,
266
+ command: metadata.command,
267
+ file_count: files.length,
268
+ total_bytes: totalBytes,
269
+ path: this._toRelativePosix(snapshotRoot),
270
+ git: metadata.git
271
+ };
272
+
273
+ index.snapshots = Array.isArray(index.snapshots) ? index.snapshots : [];
274
+ index.snapshots.unshift(entry);
275
+
276
+ const limit = normalizePositiveInteger(config.max_entries, DEFAULT_TIMELINE_CONFIG.max_entries, 10000);
277
+ if (index.snapshots.length > limit) {
278
+ const removed = index.snapshots.splice(limit);
279
+ for (const obsolete of removed) {
280
+ const obsoletePath = path.join(this._projectPath, normalizePosix(obsolete.path || ''));
281
+ try {
282
+ await this._fileSystem.remove(obsoletePath);
283
+ } catch (_error) {
284
+ // best effort cleanup
285
+ }
286
+ }
287
+ }
288
+
289
+ await this._writeIndex(index);
290
+
291
+ return {
292
+ ...entry,
293
+ snapshot_root: snapshotRoot,
294
+ workspace_root: workspaceRoot
295
+ };
296
+ }
297
+
298
+ async listSnapshots(options = {}) {
299
+ const index = await this._readIndex();
300
+ const trigger = `${options.trigger || ''}`.trim();
301
+ const limit = normalizePositiveInteger(options.limit, 20, 1000);
302
+
303
+ let snapshots = Array.isArray(index.snapshots) ? [...index.snapshots] : [];
304
+ if (trigger) {
305
+ snapshots = snapshots.filter((item) => `${item.trigger || ''}`.trim() === trigger);
306
+ }
307
+ snapshots = snapshots.slice(0, limit);
308
+
309
+ return {
310
+ mode: 'timeline-list',
311
+ success: true,
312
+ total: snapshots.length,
313
+ snapshots
314
+ };
315
+ }
316
+
317
+ async getSnapshot(snapshotId) {
318
+ const normalizedId = safeSnapshotId(snapshotId);
319
+ if (!normalizedId) {
320
+ throw new Error('snapshotId is required');
321
+ }
322
+
323
+ const index = await this._readIndex();
324
+ const entry = index.snapshots.find((item) => item.snapshot_id === normalizedId);
325
+ if (!entry) {
326
+ throw new Error(`Timeline snapshot not found: ${normalizedId}`);
327
+ }
328
+
329
+ const snapshotRoot = path.join(this._projectPath, normalizePosix(entry.path || ''));
330
+ const metadataPath = path.join(snapshotRoot, 'snapshot.json');
331
+ const filesPath = path.join(snapshotRoot, 'files.json');
332
+
333
+ let metadata = null;
334
+ let files = null;
335
+ try {
336
+ metadata = await this._fileSystem.readJson(metadataPath);
337
+ } catch (_error) {
338
+ metadata = null;
339
+ }
340
+ try {
341
+ files = await this._fileSystem.readJson(filesPath);
342
+ } catch (_error) {
343
+ files = null;
344
+ }
345
+
346
+ return {
347
+ mode: 'timeline-show',
348
+ success: true,
349
+ snapshot: entry,
350
+ metadata,
351
+ files
352
+ };
353
+ }
354
+
355
+ async restoreSnapshot(snapshotId, options = {}) {
356
+ const normalizedId = safeSnapshotId(snapshotId);
357
+ if (!normalizedId) {
358
+ throw new Error('snapshotId is required');
359
+ }
360
+
361
+ const config = await this.getConfig();
362
+ const index = await this._readIndex();
363
+ const entry = index.snapshots.find((item) => item.snapshot_id === normalizedId);
364
+ if (!entry) {
365
+ throw new Error(`Timeline snapshot not found: ${normalizedId}`);
366
+ }
367
+
368
+ const snapshotRoot = path.join(this._projectPath, normalizePosix(entry.path || ''));
369
+ const workspaceRoot = path.join(snapshotRoot, 'workspace');
370
+ if (!await this._fileSystem.pathExists(workspaceRoot)) {
371
+ throw new Error(`Timeline snapshot workspace missing: ${normalizedId}`);
372
+ }
373
+
374
+ if (options.preSave !== false) {
375
+ await this.saveSnapshot({
376
+ trigger: 'manual',
377
+ event: 'restore.pre-save',
378
+ summary: `pre-restore checkpoint before ${normalizedId}`,
379
+ force: true
380
+ });
381
+ }
382
+
383
+ const excludePatterns = this._buildExcludePatterns(config.exclude_paths);
384
+ const snapshotFiles = await this._collectFilesFromDirectory(workspaceRoot, excludePatterns, true);
385
+
386
+ for (const relativePath of snapshotFiles) {
387
+ const sourcePath = path.join(workspaceRoot, relativePath);
388
+ const targetPath = path.join(this._projectPath, relativePath);
389
+ await this._fileSystem.ensureDir(path.dirname(targetPath));
390
+ await this._fileSystem.copyFile(sourcePath, targetPath);
391
+ }
392
+
393
+ if (options.prune === true) {
394
+ const currentFiles = await this._collectWorkspaceFiles(excludePatterns);
395
+ const snapshotSet = new Set(snapshotFiles);
396
+ for (const relativePath of currentFiles) {
397
+ if (!snapshotSet.has(relativePath)) {
398
+ await this._fileSystem.remove(path.join(this._projectPath, relativePath));
399
+ }
400
+ }
401
+ }
402
+
403
+ const restored = await this.saveSnapshot({
404
+ trigger: 'restore',
405
+ event: 'restore.completed',
406
+ summary: `restored from ${normalizedId}`,
407
+ force: true
408
+ });
409
+
410
+ return {
411
+ mode: 'timeline-restore',
412
+ success: true,
413
+ restored_from: normalizedId,
414
+ restored_snapshot: restored,
415
+ pruned: options.prune === true
416
+ };
417
+ }
418
+
419
+ _buildExcludePatterns(configPatterns = []) {
420
+ const defaults = DEFAULT_TIMELINE_CONFIG.exclude_paths;
421
+ return Array.from(new Set([
422
+ ...defaults,
423
+ ...(Array.isArray(configPatterns) ? configPatterns : [])
424
+ ].map((item) => normalizePosix(item)).filter(Boolean)));
425
+ }
426
+
427
+ _isExcluded(relativePath, patterns = []) {
428
+ const normalized = normalizePosix(relativePath);
429
+ if (!normalized) {
430
+ return false;
431
+ }
432
+ for (const pattern of patterns) {
433
+ if (minimatch(normalized, pattern, { dot: true })) {
434
+ return true;
435
+ }
436
+ }
437
+ return false;
438
+ }
439
+
440
+ async _collectWorkspaceFiles(excludePatterns = []) {
441
+ return this._collectFilesFromDirectory(this._projectPath, excludePatterns, false);
442
+ }
443
+
444
+ async _collectFilesFromDirectory(rootPath, excludePatterns = [], relativeMode = false) {
445
+ const files = [];
446
+ const queue = [''];
447
+
448
+ while (queue.length > 0) {
449
+ const currentRelative = queue.shift();
450
+ const currentAbsolute = currentRelative
451
+ ? path.join(rootPath, currentRelative)
452
+ : rootPath;
453
+
454
+ let entries = [];
455
+ try {
456
+ entries = await this._fileSystem.readdir(currentAbsolute, { withFileTypes: true });
457
+ } catch (_error) {
458
+ continue;
459
+ }
460
+
461
+ for (const entry of entries) {
462
+ const childRelative = currentRelative
463
+ ? normalizePosix(path.join(currentRelative, entry.name))
464
+ : normalizePosix(entry.name);
465
+
466
+ if (this._isExcluded(childRelative, excludePatterns)) {
467
+ continue;
468
+ }
469
+
470
+ if (entry.isDirectory()) {
471
+ queue.push(childRelative);
472
+ continue;
473
+ }
474
+
475
+ if (!entry.isFile()) {
476
+ continue;
477
+ }
478
+
479
+ files.push(relativeMode ? childRelative : childRelative);
480
+ }
481
+ }
482
+
483
+ files.sort();
484
+ return files;
485
+ }
486
+
487
+ _toRelativePosix(absolutePath) {
488
+ return path.relative(this._projectPath, absolutePath).replace(/\\/g, '/');
489
+ }
490
+
491
+ _readGitStatus() {
492
+ const branch = this._spawnGit(['rev-parse', '--abbrev-ref', 'HEAD']);
493
+ const head = this._spawnGit(['rev-parse', 'HEAD']);
494
+ const porcelain = this._spawnGit(['status', '--porcelain']);
495
+ const dirtyFiles = porcelain.ok
496
+ ? porcelain.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
497
+ : [];
498
+ return {
499
+ branch: branch.ok ? branch.stdout : null,
500
+ head: head.ok ? head.stdout : null,
501
+ dirty: dirtyFiles.length > 0,
502
+ dirty_count: dirtyFiles.length,
503
+ dirty_files: dirtyFiles.slice(0, 100)
504
+ };
505
+ }
506
+
507
+ _spawnGit(args = []) {
508
+ try {
509
+ const result = spawnSync('git', args, {
510
+ cwd: this._projectPath,
511
+ encoding: 'utf8',
512
+ windowsHide: true
513
+ });
514
+ if (result.status !== 0) {
515
+ return { ok: false, stdout: '', stderr: `${result.stderr || ''}`.trim() };
516
+ }
517
+ return { ok: true, stdout: `${result.stdout || ''}`.trim(), stderr: '' };
518
+ } catch (error) {
519
+ return { ok: false, stdout: '', stderr: error.message };
520
+ }
521
+ }
522
+
523
+ async _readIndex() {
524
+ if (!await this._fileSystem.pathExists(this._indexPath)) {
525
+ return {
526
+ schema_version: TIMELINE_SCHEMA_VERSION,
527
+ updated_at: nowIso(),
528
+ snapshots: []
529
+ };
530
+ }
531
+
532
+ try {
533
+ const payload = await this._fileSystem.readJson(this._indexPath);
534
+ return {
535
+ schema_version: payload && payload.schema_version ? payload.schema_version : TIMELINE_SCHEMA_VERSION,
536
+ updated_at: payload && payload.updated_at ? payload.updated_at : nowIso(),
537
+ snapshots: Array.isArray(payload && payload.snapshots) ? payload.snapshots : []
538
+ };
539
+ } catch (_error) {
540
+ return {
541
+ schema_version: TIMELINE_SCHEMA_VERSION,
542
+ updated_at: nowIso(),
543
+ snapshots: []
544
+ };
545
+ }
546
+ }
547
+
548
+ async _writeIndex(index = {}) {
549
+ const payload = {
550
+ schema_version: TIMELINE_SCHEMA_VERSION,
551
+ updated_at: nowIso(),
552
+ snapshots: Array.isArray(index.snapshots) ? index.snapshots : []
553
+ };
554
+
555
+ await this._fileSystem.ensureDir(this._timelineDir);
556
+ await this._fileSystem.writeJson(this._indexPath, payload, { spaces: 2 });
557
+ return payload;
558
+ }
559
+ }
560
+
561
+ async function captureTimelineCheckpoint(options = {}, dependencies = {}) {
562
+ const projectPath = dependencies.projectPath || process.cwd();
563
+ const store = dependencies.timelineStore || new ProjectTimelineStore(projectPath, dependencies.fileSystem || fs);
564
+
565
+ try {
566
+ if (options.auto !== false) {
567
+ await store.maybeAutoSnapshot({
568
+ event: options.event || 'checkpoint.auto',
569
+ summary: options.autoSummary || options.summary || ''
570
+ });
571
+ }
572
+ return await store.saveSnapshot({
573
+ trigger: options.trigger || 'key-event',
574
+ event: options.event || 'checkpoint.event',
575
+ summary: options.summary || '',
576
+ command: options.command || '',
577
+ sessionId: options.sessionId,
578
+ sceneId: options.sceneId
579
+ });
580
+ } catch (error) {
581
+ return {
582
+ mode: 'timeline-checkpoint',
583
+ success: false,
584
+ error: error.message
585
+ };
586
+ }
587
+ }
588
+
589
+ module.exports = {
590
+ ProjectTimelineStore,
591
+ TIMELINE_SCHEMA_VERSION,
592
+ TIMELINE_CONFIG_RELATIVE_PATH,
593
+ TIMELINE_DIR,
594
+ TIMELINE_INDEX_FILE,
595
+ TIMELINE_SNAPSHOTS_DIR,
596
+ DEFAULT_TIMELINE_CONFIG,
597
+ captureTimelineCheckpoint
598
+ };