clearctx 3.0.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.
@@ -0,0 +1,170 @@
1
+ /**
2
+ * SafetyNet — Automatic limits and protection for automated sessions.
3
+ *
4
+ * Enforces:
5
+ * - Cost limit — Kill session if cumulative cost exceeds threshold
6
+ * - Turn limit — Kill session if total agent turns exceed threshold
7
+ * - Time limit — Kill session if a single interaction takes too long
8
+ * - File protection — Prevent modifications to critical files/paths
9
+ *
10
+ * Integrates with StreamSession via the 'result' event.
11
+ *
12
+ * Usage:
13
+ * const safety = new SafetyNet({
14
+ * maxCostUsd: 1.00, // Kill if total cost exceeds $1
15
+ * maxTurns: 30, // Kill if total turns exceed 30
16
+ * maxDurationMs: 300000, // Kill if single interaction > 5 min
17
+ * protectedPaths: ['.env', 'node_modules', '.git'],
18
+ * });
19
+ * safety.attach(streamSession);
20
+ */
21
+
22
+ const { EventEmitter } = require('events');
23
+
24
+ // Default safety limits
25
+ const DEFAULTS = {
26
+ maxCostUsd: 2.00, // $2 per session
27
+ maxTurns: 50, // 50 agent turns
28
+ maxDurationMs: 300000, // 5 minutes per interaction
29
+ protectedPaths: [ // Files that should never be modified
30
+ '.env',
31
+ '.env.local',
32
+ '.env.production',
33
+ 'credentials',
34
+ 'secrets',
35
+ '.git/',
36
+ 'node_modules/',
37
+ ],
38
+ };
39
+
40
+ class SafetyNet extends EventEmitter {
41
+ /**
42
+ * @param {object} [limits] - Override default limits
43
+ * @param {number} [limits.maxCostUsd] - Max total cost in USD
44
+ * @param {number} [limits.maxTurns] - Max total agent turns
45
+ * @param {number} [limits.maxDurationMs] - Max time per interaction
46
+ * @param {string[]} [limits.protectedPaths] - Paths that can't be modified
47
+ */
48
+ constructor(limits = {}) {
49
+ super();
50
+ this.maxCostUsd = limits.maxCostUsd ?? DEFAULTS.maxCostUsd;
51
+ this.maxTurns = limits.maxTurns ?? DEFAULTS.maxTurns;
52
+ this.maxDurationMs = limits.maxDurationMs ?? DEFAULTS.maxDurationMs;
53
+ this.protectedPaths = limits.protectedPaths || DEFAULTS.protectedPaths;
54
+
55
+ this.violations = []; // Record of all limit violations
56
+ this.attached = null; // The session we're monitoring
57
+ }
58
+
59
+ /**
60
+ * Attach to a StreamSession to monitor it.
61
+ * @param {StreamSession} session
62
+ */
63
+ attach(session) {
64
+ this.attached = session;
65
+
66
+ // Check limits after each result
67
+ session.on('result', (response) => {
68
+ this._checkCost(session, response);
69
+ this._checkTurns(session, response);
70
+ });
71
+
72
+ // Check for protected file access in tool calls
73
+ session.on('tool-use', (toolCall) => {
74
+ this._checkProtectedPaths(session, toolCall);
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Check if cost limit is exceeded.
80
+ */
81
+ _checkCost(session, response) {
82
+ if (this.maxCostUsd && session.totalCostUsd > this.maxCostUsd) {
83
+ const violation = {
84
+ type: 'cost_exceeded',
85
+ limit: this.maxCostUsd,
86
+ actual: session.totalCostUsd,
87
+ message: `Cost limit exceeded: $${session.totalCostUsd.toFixed(4)} > $${this.maxCostUsd.toFixed(2)} limit`,
88
+ timestamp: new Date().toISOString(),
89
+ };
90
+ this.violations.push(violation);
91
+ this.emit('violation', violation);
92
+ this.emit('kill', violation);
93
+
94
+ // Force kill the session
95
+ session.kill();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Check if turn limit is exceeded.
101
+ */
102
+ _checkTurns(session, response) {
103
+ if (this.maxTurns && session.totalTurns > this.maxTurns) {
104
+ const violation = {
105
+ type: 'turns_exceeded',
106
+ limit: this.maxTurns,
107
+ actual: session.totalTurns,
108
+ message: `Turn limit exceeded: ${session.totalTurns} > ${this.maxTurns} limit`,
109
+ timestamp: new Date().toISOString(),
110
+ };
111
+ this.violations.push(violation);
112
+ this.emit('violation', violation);
113
+ this.emit('kill', violation);
114
+
115
+ session.kill();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check if a tool call targets a protected path.
121
+ */
122
+ _checkProtectedPaths(session, toolCall) {
123
+ if (!toolCall.input) return;
124
+
125
+ // Check common file path fields in tool inputs
126
+ const pathFields = ['file_path', 'path', 'command'];
127
+ for (const field of pathFields) {
128
+ const value = toolCall.input[field];
129
+ if (typeof value !== 'string') continue;
130
+
131
+ for (const protected_path of this.protectedPaths) {
132
+ if (value.includes(protected_path)) {
133
+ // Only flag write operations
134
+ const writeTools = ['Write', 'Edit', 'NotebookEdit', 'Bash'];
135
+ if (writeTools.includes(toolCall.name)) {
136
+ const violation = {
137
+ type: 'protected_path',
138
+ tool: toolCall.name,
139
+ path: value,
140
+ protectedPattern: protected_path,
141
+ message: `Attempted to modify protected path: ${value} (matches "${protected_path}")`,
142
+ timestamp: new Date().toISOString(),
143
+ };
144
+ this.violations.push(violation);
145
+ this.emit('violation', violation);
146
+ // Don't kill — just warn. The permission system handles blocking.
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get a summary of all violations.
155
+ */
156
+ getSummary() {
157
+ return {
158
+ totalViolations: this.violations.length,
159
+ violations: this.violations,
160
+ limits: {
161
+ maxCostUsd: this.maxCostUsd,
162
+ maxTurns: this.maxTurns,
163
+ maxDurationMs: this.maxDurationMs,
164
+ protectedPaths: this.protectedPaths,
165
+ },
166
+ };
167
+ }
168
+ }
169
+
170
+ module.exports = SafetyNet;
@@ -0,0 +1,508 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const { execSync } = require('child_process');
6
+ const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
7
+ const { acquireLock, releaseLock } = require('./file-lock');
8
+
9
+ /**
10
+ * SessionSnapshot - Captures lightweight snapshots of project state for the Session Continuity Engine.
11
+ *
12
+ * This module provides snapshot capabilities for tracking project state across sessions.
13
+ * It captures git state, file tree information, and working context to enable session continuity.
14
+ *
15
+ * Features:
16
+ * - Lightweight git state capture (branch, commits, dirty files)
17
+ * - Working context tracking (active files, tasks, blockers)
18
+ * - Snapshot history management
19
+ * - Project-specific storage using content-addressed directories
20
+ *
21
+ * Storage structure:
22
+ * ~/.clearctx/continuity/{projectHash}/
23
+ * ├── snapshots/ # Individual snapshot files
24
+ * ├── locks/ # Lock files for safe concurrent access
25
+ * └── meta.json # Project metadata and latest snapshot pointer
26
+ */
27
+ class SessionSnapshot {
28
+ /**
29
+ * Creates a new SessionSnapshot instance for a project.
30
+ *
31
+ * @param {string} projectPath - Absolute path to the project directory (e.g., 'E:\MyProject')
32
+ *
33
+ * How it works:
34
+ * 1. Computes a unique hash from the project path to create isolated storage
35
+ * 2. Sets up storage directories in the user's home directory
36
+ * 3. Creates necessary subdirectories (snapshots/, locks/)
37
+ * 4. Initializes meta.json with project information if it doesn't exist
38
+ */
39
+ constructor(projectPath) {
40
+ // Store the absolute path to the project we're tracking
41
+ this.projectPath = projectPath;
42
+
43
+ // Create a unique hash from the project path
44
+ // This allows us to store snapshots for different projects separately
45
+ // We use SHA256 and take the first 16 characters for a short, unique identifier
46
+ this.projectHash = crypto
47
+ .createHash('sha256')
48
+ .update(projectPath)
49
+ .digest('hex')
50
+ .slice(0, 16);
51
+
52
+ // Set up the main storage directory for this project's continuity data
53
+ // Example: C:\Users\username\.clearctx\continuity\a1b2c3d4e5f6g7h8\
54
+ this.storageDir = path.join(
55
+ os.homedir(),
56
+ '.clearctx',
57
+ 'continuity',
58
+ this.projectHash
59
+ );
60
+
61
+ // Set up subdirectories for organizing different types of data
62
+ this.snapshotsDir = path.join(this.storageDir, 'snapshots');
63
+ this.locksDir = path.join(this.storageDir, 'locks');
64
+ this.metaPath = path.join(this.storageDir, 'meta.json');
65
+
66
+ // Create all necessary directories if they don't exist
67
+ // recursive: true means it will create parent directories too
68
+ fs.mkdirSync(this.snapshotsDir, { recursive: true });
69
+ fs.mkdirSync(this.locksDir, { recursive: true });
70
+
71
+ // Initialize meta.json if it doesn't exist
72
+ // This file stores project metadata and points to the latest snapshot
73
+ if (!fs.existsSync(this.metaPath)) {
74
+ const metaData = {
75
+ projectPath: this.projectPath,
76
+ projectHash: this.projectHash,
77
+ createdAt: new Date().toISOString()
78
+ };
79
+ atomicWriteJson(this.metaPath, metaData);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Captures a lightweight snapshot of the current project state.
85
+ *
86
+ * This is the main method for creating snapshots. It captures:
87
+ * - Git state (branch, commits, modified files, staged files)
88
+ * - File tree information (tracked file count)
89
+ * - Working context (active files, tasks, questions, blockers)
90
+ *
91
+ * @param {string} sessionName - Name of the session capturing this snapshot
92
+ * @param {object} workingContext - Optional context object with:
93
+ * - activeFiles: Array of file paths currently being worked on
94
+ * - taskSummary: Brief description of current task
95
+ * - openQuestions: Array of questions or uncertainties
96
+ * - blockers: Array of blocking issues
97
+ * @returns {object} The complete snapshot object
98
+ *
99
+ * Example:
100
+ * const snapshot = sessionSnapshot.capture('build-session', {
101
+ * activeFiles: ['src/auth.js', 'src/routes.js'],
102
+ * taskSummary: 'Implementing user authentication',
103
+ * openQuestions: ['Which hash algorithm to use?'],
104
+ * blockers: ['Need API key from client']
105
+ * });
106
+ */
107
+ capture(sessionName, workingContext = {}) {
108
+ // Generate a unique ID for this snapshot using timestamp
109
+ // Example: 'snap_1707849600000'
110
+ const snapshotId = 'snap_' + Date.now();
111
+
112
+ // Capture git state by running git commands
113
+ const gitState = this._captureGitState();
114
+
115
+ // Capture file tree information
116
+ const fileTree = this._captureFileTree();
117
+
118
+ // Build the complete snapshot object
119
+ const snapshot = {
120
+ snapshotId: snapshotId,
121
+ projectPath: this.projectPath,
122
+ capturedAt: new Date().toISOString(),
123
+ capturedBy: sessionName,
124
+ git: gitState,
125
+ fileTree: fileTree,
126
+ workingContext: {
127
+ activeFiles: workingContext.activeFiles || [],
128
+ taskSummary: workingContext.taskSummary || '',
129
+ openQuestions: workingContext.openQuestions || [],
130
+ blockers: workingContext.blockers || []
131
+ }
132
+ };
133
+
134
+ // Write the snapshot to disk using atomic write for safety
135
+ const snapshotPath = path.join(this.snapshotsDir, `${snapshotId}.json`);
136
+ atomicWriteJson(snapshotPath, snapshot);
137
+
138
+ // Update meta.json to point to this latest snapshot
139
+ // We use locks to prevent concurrent writes from corrupting the file
140
+ acquireLock(this.locksDir, 'meta');
141
+ try {
142
+ const meta = readJsonSafe(this.metaPath, {});
143
+ meta.lastSnapshotId = snapshotId;
144
+ atomicWriteJson(this.metaPath, meta);
145
+ } finally {
146
+ // Always release the lock, even if there was an error
147
+ releaseLock(this.locksDir, 'meta');
148
+ }
149
+
150
+ return snapshot;
151
+ }
152
+
153
+ /**
154
+ * Captures git state by running git commands.
155
+ *
156
+ * This is an internal helper method that executes various git commands
157
+ * to gather information about the repository state.
158
+ *
159
+ * @private
160
+ * @returns {object|null} Git state object, or null if not a git repository
161
+ *
162
+ * The returned object contains:
163
+ * - branch: Current branch name (or 'DETACHED' if in detached HEAD)
164
+ * - headCommit: SHA hash of the current commit
165
+ * - headMessage: Commit message of the current commit
166
+ * - isDirty: Boolean indicating if there are uncommitted changes
167
+ * - untrackedCount: Number of untracked files
168
+ * - modifiedFiles: Array of modified file paths (unstaged)
169
+ * - stagedFiles: Array of staged file paths
170
+ * - recentCommits: Array of recent commit objects [{hash, message}, ...]
171
+ */
172
+ _captureGitState() {
173
+ try {
174
+ // First, verify this is a git repository
175
+ // This will throw an error if we're not in a git repo
176
+ execSync('git rev-parse --show-toplevel', {
177
+ cwd: this.projectPath,
178
+ encoding: 'utf-8',
179
+ timeout: 10000,
180
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
181
+ });
182
+
183
+ // Get the current commit hash (HEAD)
184
+ const headCommit = execSync('git rev-parse HEAD', {
185
+ cwd: this.projectPath,
186
+ encoding: 'utf-8',
187
+ timeout: 10000,
188
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
189
+ }).trim();
190
+
191
+ // Get the current branch name
192
+ // If we're in detached HEAD state, this will be empty
193
+ let branch = execSync('git branch --show-current', {
194
+ cwd: this.projectPath,
195
+ encoding: 'utf-8',
196
+ timeout: 10000,
197
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
198
+ }).trim();
199
+
200
+ // Handle detached HEAD state
201
+ if (!branch) {
202
+ branch = 'DETACHED';
203
+ }
204
+
205
+ // Get recent commits (last 10)
206
+ // Format: "abc1234 Commit message here"
207
+ const logOutput = execSync('git log --oneline -n 10', {
208
+ cwd: this.projectPath,
209
+ encoding: 'utf-8',
210
+ timeout: 10000,
211
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
212
+ }).trim();
213
+
214
+ // Parse the log output into an array of commit objects
215
+ const recentCommits = logOutput
216
+ .split('\n')
217
+ .filter(line => line.trim()) // Remove empty lines
218
+ .map(line => {
219
+ // Split the line into hash and message
220
+ const spaceIndex = line.indexOf(' ');
221
+ if (spaceIndex === -1) {
222
+ return { hash: line, message: '' };
223
+ }
224
+ return {
225
+ hash: line.substring(0, spaceIndex),
226
+ message: line.substring(spaceIndex + 1)
227
+ };
228
+ });
229
+
230
+ // Get the message of the current commit (first line only)
231
+ const headMessage = recentCommits.length > 0 ? recentCommits[0].message : '';
232
+
233
+ // Get status information using porcelain format
234
+ // Porcelain format is machine-readable and stable across git versions
235
+ const statusOutput = execSync('git status --porcelain', {
236
+ cwd: this.projectPath,
237
+ encoding: 'utf-8',
238
+ timeout: 10000,
239
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
240
+ }).trim();
241
+
242
+ // Parse the status output to extract file states
243
+ const statusInfo = this._parseGitStatus(statusOutput);
244
+
245
+ return {
246
+ branch: branch,
247
+ headCommit: headCommit,
248
+ headMessage: headMessage,
249
+ isDirty: statusInfo.isDirty,
250
+ untrackedCount: statusInfo.untrackedCount,
251
+ modifiedFiles: statusInfo.modifiedFiles,
252
+ stagedFiles: statusInfo.stagedFiles,
253
+ recentCommits: recentCommits
254
+ };
255
+ } catch (error) {
256
+ // If any git command fails, this is likely not a git repository
257
+ // or git is not available, so we return null
258
+ return null;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Parses git status --porcelain output into structured information.
264
+ *
265
+ * Git status porcelain format uses two columns:
266
+ * - First column (X): staged status
267
+ * - Second column (Y): unstaged status
268
+ *
269
+ * Examples:
270
+ * "M file.js" → staged modification
271
+ * " M file.js" → unstaged modification
272
+ * "MM file.js" → staged and unstaged modifications
273
+ * "?? file.js" → untracked file
274
+ * "A file.js" → newly added (staged)
275
+ *
276
+ * @private
277
+ * @param {string} statusOutput - Raw output from 'git status --porcelain'
278
+ * @returns {object} Parsed status information
279
+ */
280
+ _parseGitStatus(statusOutput) {
281
+ const modifiedFiles = [];
282
+ const stagedFiles = [];
283
+ let untrackedCount = 0;
284
+ let isDirty = false;
285
+
286
+ // If there's any status output, the working tree is dirty
287
+ if (statusOutput.length > 0) {
288
+ isDirty = true;
289
+ }
290
+
291
+ // Parse each line of the status output
292
+ const lines = statusOutput.split('\n').filter(line => line.trim());
293
+
294
+ for (const line of lines) {
295
+ if (line.length < 4) continue; // Skip invalid lines
296
+
297
+ // Extract the status codes and file path
298
+ // Format: "XY filename" where X=staged, Y=unstaged
299
+ const stagedCode = line[0];
300
+ const unstagedCode = line[1];
301
+ const filePath = line.substring(3); // Skip "XY " prefix
302
+
303
+ // Check for untracked files (marked with ??)
304
+ if (stagedCode === '?' && unstagedCode === '?') {
305
+ untrackedCount++;
306
+ continue;
307
+ }
308
+
309
+ // Check for staged changes (first column is not space)
310
+ // Staged codes: M (modified), A (added), D (deleted), R (renamed), C (copied)
311
+ if (stagedCode !== ' ' && stagedCode !== '?') {
312
+ stagedFiles.push(filePath);
313
+ }
314
+
315
+ // Check for unstaged modifications (second column is M)
316
+ if (unstagedCode === 'M') {
317
+ modifiedFiles.push(filePath);
318
+ }
319
+ }
320
+
321
+ return {
322
+ isDirty: isDirty,
323
+ untrackedCount: untrackedCount,
324
+ modifiedFiles: modifiedFiles,
325
+ stagedFiles: stagedFiles
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Captures file tree information.
331
+ *
332
+ * For git repositories, this counts the number of tracked files.
333
+ * For non-git projects, returns null.
334
+ *
335
+ * @private
336
+ * @returns {object|null} File tree information
337
+ */
338
+ _captureFileTree() {
339
+ try {
340
+ // Use 'git ls-files' to list all tracked files
341
+ const lsOutput = execSync('git ls-files', {
342
+ cwd: this.projectPath,
343
+ encoding: 'utf-8',
344
+ timeout: 10000,
345
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
346
+ });
347
+
348
+ // Count the lines (each line is a file)
349
+ const lines = lsOutput.trim().split('\n').filter(line => line.trim());
350
+ const trackedFileCount = lines.length;
351
+
352
+ return {
353
+ trackedFileCount: trackedFileCount
354
+ };
355
+ } catch (error) {
356
+ // Not a git repository or git command failed
357
+ return null;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Returns the most recent snapshot, or null if none exist.
363
+ *
364
+ * This method first checks meta.json for the lastSnapshotId pointer.
365
+ * If that's not available, it scans the snapshots directory for the
366
+ * most recent file based on filename timestamp.
367
+ *
368
+ * @returns {object|null} The latest snapshot object, or null if none exist
369
+ */
370
+ getLatest() {
371
+ // Try to get the latest snapshot ID from meta.json
372
+ const meta = readJsonSafe(this.metaPath, {});
373
+
374
+ if (meta.lastSnapshotId) {
375
+ // We have a pointer to the latest snapshot
376
+ const snapshotPath = path.join(this.snapshotsDir, `${meta.lastSnapshotId}.json`);
377
+ const snapshot = readJsonSafe(snapshotPath, null);
378
+ if (snapshot) {
379
+ return snapshot;
380
+ }
381
+ }
382
+
383
+ // If meta.json doesn't have lastSnapshotId or the file doesn't exist,
384
+ // scan the snapshots directory for the most recent file
385
+ try {
386
+ const files = fs.readdirSync(this.snapshotsDir);
387
+
388
+ // Filter for snapshot files (snap_*.json)
389
+ const snapshotFiles = files.filter(f => f.startsWith('snap_') && f.endsWith('.json'));
390
+
391
+ if (snapshotFiles.length === 0) {
392
+ return null;
393
+ }
394
+
395
+ // Sort by timestamp (extracted from filename) in descending order
396
+ snapshotFiles.sort((a, b) => {
397
+ // Extract timestamp from filename: snap_1707849600000.json → 1707849600000
398
+ const timestampA = parseInt(a.replace('snap_', '').replace('.json', ''));
399
+ const timestampB = parseInt(b.replace('snap_', '').replace('.json', ''));
400
+ return timestampB - timestampA; // Descending order
401
+ });
402
+
403
+ // Read and return the most recent snapshot
404
+ const latestFile = snapshotFiles[0];
405
+ const snapshotPath = path.join(this.snapshotsDir, latestFile);
406
+ return readJsonSafe(snapshotPath, null);
407
+ } catch (error) {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Returns a specific snapshot by ID, or null if not found.
414
+ *
415
+ * @param {string} snapshotId - The snapshot ID to retrieve (e.g., 'snap_1707849600000')
416
+ * @returns {object|null} The snapshot object, or null if not found
417
+ */
418
+ get(snapshotId) {
419
+ const snapshotPath = path.join(this.snapshotsDir, `${snapshotId}.json`);
420
+ return readJsonSafe(snapshotPath, null);
421
+ }
422
+
423
+ /**
424
+ * Returns an array of snapshot summaries, newest first.
425
+ *
426
+ * This method lists all snapshots and returns basic information about each one.
427
+ * Useful for displaying snapshot history to users or finding specific snapshots.
428
+ *
429
+ * @param {number} limit - Maximum number of snapshots to return (default: 20)
430
+ * @returns {Array} Array of snapshot summary objects
431
+ *
432
+ * Each summary contains:
433
+ * - snapshotId: The unique snapshot identifier
434
+ * - capturedAt: ISO timestamp of when the snapshot was captured
435
+ * - capturedBy: Session name that captured the snapshot
436
+ * - branch: Git branch at the time of capture
437
+ * - headCommit: Git commit hash at the time of capture
438
+ */
439
+ list(limit = 20) {
440
+ try {
441
+ // Read all files in the snapshots directory
442
+ const files = fs.readdirSync(this.snapshotsDir);
443
+
444
+ // Filter for snapshot files
445
+ const snapshotFiles = files.filter(f => f.startsWith('snap_') && f.endsWith('.json'));
446
+
447
+ // Read each snapshot and extract summary information
448
+ const summaries = [];
449
+ for (const file of snapshotFiles) {
450
+ const snapshotPath = path.join(this.snapshotsDir, file);
451
+ const snapshot = readJsonSafe(snapshotPath, null);
452
+
453
+ if (snapshot) {
454
+ // Extract only the summary fields we need
455
+ summaries.push({
456
+ snapshotId: snapshot.snapshotId,
457
+ capturedAt: snapshot.capturedAt,
458
+ capturedBy: snapshot.capturedBy,
459
+ branch: snapshot.git ? snapshot.git.branch : null,
460
+ headCommit: snapshot.git ? snapshot.git.headCommit : null
461
+ });
462
+ }
463
+ }
464
+
465
+ // Sort by capturedAt timestamp in descending order (newest first)
466
+ summaries.sort((a, b) => {
467
+ return new Date(b.capturedAt) - new Date(a.capturedAt);
468
+ });
469
+
470
+ // Return only the first 'limit' items
471
+ return summaries.slice(0, limit);
472
+ } catch (error) {
473
+ // If there's an error reading the directory, return empty array
474
+ return [];
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Returns the continuity storage directory path for this project.
480
+ *
481
+ * This is the root directory where all continuity data is stored
482
+ * for the current project.
483
+ *
484
+ * @returns {string} Absolute path to the storage directory
485
+ *
486
+ * Example: 'C:\Users\username\.clearctx\continuity\a1b2c3d4e5f6g7h8'
487
+ */
488
+ getProjectDir() {
489
+ return this.storageDir;
490
+ }
491
+
492
+ /**
493
+ * Returns the SHA256 hash prefix used for storage.
494
+ *
495
+ * This is the 16-character hash derived from the project path,
496
+ * used to create a unique storage directory for each project.
497
+ *
498
+ * @returns {string} The project hash (16 hex characters)
499
+ *
500
+ * Example: 'a1b2c3d4e5f6g7h8'
501
+ */
502
+ getProjectHash() {
503
+ return this.projectHash;
504
+ }
505
+ }
506
+
507
+ // Export the class directly (CommonJS style)
508
+ module.exports = SessionSnapshot;