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,99 @@
1
+ /**
2
+ * Atomic I/O utilities for safe file operations.
3
+ *
4
+ * These functions ensure data integrity during concurrent access:
5
+ * - atomicWriteJson: Write-then-rename prevents partial reads
6
+ * - writeImmutable: Exclusive create prevents overwrites
7
+ * - readJsonSafe: Never throws, returns fallback on error
8
+ * - appendJsonl: Append-only log format for event streams
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+
15
+ /**
16
+ * Write JSON data atomically using write-then-rename.
17
+ * Prevents other processes from reading partial/corrupt data.
18
+ *
19
+ * @param {string} filePath - Target file path
20
+ * @param {any} data - Data to serialize as JSON
21
+ */
22
+ function atomicWriteJson(filePath, data) {
23
+ // Ensure parent directory exists
24
+ const dir = path.dirname(filePath);
25
+ if (!fs.existsSync(dir)) {
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ }
28
+
29
+ // Write to temp file in same directory (ensures same filesystem for atomic rename)
30
+ const tempSuffix = crypto.randomBytes(8).toString('hex');
31
+ const tempPath = `${filePath}.tmp.${tempSuffix}`;
32
+
33
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
34
+
35
+ // Atomic rename — replaces target file in a single filesystem operation
36
+ fs.renameSync(tempPath, filePath);
37
+ }
38
+
39
+ /**
40
+ * Write JSON data with exclusive create (fails if file exists).
41
+ * Used for immutable artifacts like versioned snapshots.
42
+ *
43
+ * @param {string} filePath - Target file path
44
+ * @param {any} data - Data to serialize as JSON
45
+ * @throws {Error} Throws EEXIST if file already exists
46
+ */
47
+ function writeImmutable(filePath, data) {
48
+ // Ensure parent directory exists
49
+ const dir = path.dirname(filePath);
50
+ if (!fs.existsSync(dir)) {
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ }
53
+
54
+ // 'wx' flag = exclusive create — atomic check-and-create operation
55
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { flag: 'wx' });
56
+ }
57
+
58
+ /**
59
+ * Read and parse JSON file safely, never throws.
60
+ * Returns fallback value if file doesn't exist or parse fails.
61
+ *
62
+ * @param {string} filePath - File to read
63
+ * @param {any} [fallback={}] - Value to return on error
64
+ * @returns {any} Parsed JSON or fallback
65
+ */
66
+ function readJsonSafe(filePath, fallback = {}) {
67
+ try {
68
+ const content = fs.readFileSync(filePath, 'utf-8');
69
+ return JSON.parse(content);
70
+ } catch (err) {
71
+ // File doesn't exist, permission denied, invalid JSON, etc.
72
+ return fallback;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Append a JSON object to a JSONL (JSON Lines) file.
78
+ * Each line is a complete JSON object for append-only event logs.
79
+ *
80
+ * @param {string} filePath - JSONL file path
81
+ * @param {any} obj - Object to append (serialized as one line)
82
+ */
83
+ function appendJsonl(filePath, obj) {
84
+ // Ensure parent directory exists
85
+ const dir = path.dirname(filePath);
86
+ if (!fs.existsSync(dir)) {
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ }
89
+
90
+ // Append as single line (no pretty printing)
91
+ fs.appendFileSync(filePath, JSON.stringify(obj) + '\n', 'utf-8');
92
+ }
93
+
94
+ module.exports = {
95
+ atomicWriteJson,
96
+ writeImmutable,
97
+ readJsonSafe,
98
+ appendJsonl
99
+ };
@@ -0,0 +1,451 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const DiffEngine = require('./diff-engine');
6
+ const SessionSnapshot = require('./session-snapshot');
7
+ const DecisionJournal = require('./decision-journal');
8
+ const PatternRegistry = require('./pattern-registry');
9
+
10
+ /**
11
+ * BriefingGenerator
12
+ *
13
+ * Generates markdown briefings for session continuity. This module creates
14
+ * comprehensive summaries of what happened since the last session, including
15
+ * git changes, file modifications, decisions made, and active patterns.
16
+ *
17
+ * Part of the Session Continuity Engine (Layer 0).
18
+ */
19
+ class BriefingGenerator {
20
+ /**
21
+ * Creates a new BriefingGenerator instance
22
+ * @param {string} projectPath - Absolute path to the project root directory
23
+ */
24
+ constructor(projectPath) {
25
+ // Initialize all the engines we need to generate a briefing
26
+ this.diff = new DiffEngine(projectPath);
27
+ this.snapshot = new SessionSnapshot(projectPath);
28
+ this.journal = new DecisionJournal(projectPath);
29
+ this.patterns = new PatternRegistry(projectPath);
30
+ this.projectPath = projectPath;
31
+
32
+ // Create a unique hash for this project to store briefings
33
+ // Uses the same hash and path as SessionSnapshot and other modules:
34
+ // ~/.clearctx/continuity/{projectHash}/briefings/
35
+ const hash = crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 16);
36
+ const storageDir = path.join(os.homedir(), '.clearctx', 'continuity', hash);
37
+ this.briefingsDir = path.join(storageDir, 'briefings');
38
+
39
+ // Make sure the briefings directory exists
40
+ if (!fs.existsSync(this.briefingsDir)) {
41
+ fs.mkdirSync(this.briefingsDir, { recursive: true });
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Generates a complete markdown briefing from the latest snapshot and current diff
47
+ * @param {Object} options - Configuration options
48
+ * @param {number} options.maxTokens - Approximate max character length (default: 4000)
49
+ * @param {boolean} options.includeDecisions - Include recent decisions (default: true)
50
+ * @param {boolean} options.includePatterns - Include pattern rules (default: true)
51
+ * @param {number} options.decisionLimit - How many recent decisions (default: 10)
52
+ * @returns {Object} Contains markdown, briefingId, savedTo, and sections
53
+ */
54
+ generate(options = {}) {
55
+ // Set up default options
56
+ const maxTokens = options.maxTokens || 4000;
57
+ const includeDecisions = options.includeDecisions !== false;
58
+ const includePatterns = options.includePatterns !== false;
59
+ const decisionLimit = options.decisionLimit || 10;
60
+
61
+ // Get the latest snapshot to compare against
62
+ const latestSnapshot = this.snapshot.getLatest();
63
+
64
+ // If no snapshot exists, this is the first session
65
+ if (!latestSnapshot) {
66
+ return this._generateFirstSessionBriefing(includePatterns);
67
+ }
68
+
69
+ // Get the diff from the latest snapshot to now
70
+ const diff = this.diff.diffFromLatest();
71
+
72
+ // If diff has no git state, use safe defaults
73
+ const gitChanges = diff.gitChanges || { newCommitCount: 0, commits: [], branchChanged: false, oldBranch: 'unknown', newBranch: 'unknown' };
74
+ const fileChanges = diff.fileChanges || { added: [], modified: [], deleted: [], renamed: [] };
75
+
76
+ // Build all the sections of the briefing
77
+ const sections = {
78
+ header: this._formatHeader(latestSnapshot, diff),
79
+ timeSummary: this._formatTimeSummary(diff, gitChanges, fileChanges),
80
+ gitSection: this._formatGitSection(gitChanges),
81
+ fileSection: this._formatFileSection(fileChanges, (latestSnapshot.workingContext && latestSnapshot.workingContext.activeFiles) || []),
82
+ contextSection: this._formatContextSection(latestSnapshot),
83
+ decisionsSection: includeDecisions ? this._formatDecisions(decisionLimit) : '',
84
+ patternsSection: includePatterns ? this._formatPatterns() : '',
85
+ staleSection: this._formatStaleWarnings(fileChanges, (latestSnapshot.workingContext && latestSnapshot.workingContext.activeFiles) || [])
86
+ };
87
+
88
+ // Combine all sections into one markdown document
89
+ let markdown = [
90
+ sections.header,
91
+ sections.timeSummary,
92
+ sections.gitSection,
93
+ sections.fileSection,
94
+ sections.contextSection,
95
+ sections.decisionsSection,
96
+ sections.patternsSection,
97
+ sections.staleSection
98
+ ].filter(s => s).join('\n\n'); // Filter out empty sections
99
+
100
+ // Trim if we exceed maxTokens (rough approximation: 1 token ≈ 1 character)
101
+ if (markdown.length > maxTokens) {
102
+ markdown = this._trimToTokenLimit(sections, maxTokens);
103
+ }
104
+
105
+ // Save the briefing to disk
106
+ const timestamp = Date.now();
107
+ const briefingId = `brief_${timestamp}`;
108
+ const savedTo = path.join(this.briefingsDir, `${briefingId}.md`);
109
+ fs.writeFileSync(savedTo, markdown, 'utf8');
110
+
111
+ return {
112
+ markdown,
113
+ briefingId,
114
+ savedTo,
115
+ sections
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Generates a briefing for the very first session
121
+ * @param {boolean} includePatterns - Whether to include patterns
122
+ * @returns {Object} Briefing object
123
+ * @private
124
+ */
125
+ _generateFirstSessionBriefing(includePatterns) {
126
+ let markdown = '## Session Briefing\n\n';
127
+ markdown += 'This is the first tracked session for this project. No previous context available.\n';
128
+
129
+ // Include patterns if requested
130
+ if (includePatterns) {
131
+ const patternsSection = this._formatPatterns();
132
+ if (patternsSection) {
133
+ markdown += '\n' + patternsSection;
134
+ }
135
+ }
136
+
137
+ // Save the briefing
138
+ const timestamp = Date.now();
139
+ const briefingId = `brief_${timestamp}`;
140
+ const savedTo = path.join(this.briefingsDir, `${briefingId}.md`);
141
+ fs.writeFileSync(savedTo, markdown, 'utf8');
142
+
143
+ return {
144
+ markdown,
145
+ briefingId,
146
+ savedTo,
147
+ sections: { header: markdown }
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Formats the header section with date and time
153
+ * @param {Object} snapshot - The latest snapshot
154
+ * @param {Object} diff - The diff object
155
+ * @returns {string} Formatted header
156
+ * @private
157
+ */
158
+ _formatHeader(snapshot, diff) {
159
+ const now = new Date();
160
+ const dateStr = now.toLocaleDateString('en-US', {
161
+ month: 'short',
162
+ day: 'numeric',
163
+ year: 'numeric'
164
+ });
165
+ const timeStr = now.toLocaleTimeString('en-US', {
166
+ hour: 'numeric',
167
+ minute: '2-digit',
168
+ hour12: true
169
+ });
170
+ return `## Session Briefing — ${dateStr}, ${timeStr}`;
171
+ }
172
+
173
+ /**
174
+ * Formats the time summary section
175
+ * @param {Object} diff - The diff object
176
+ * @param {Object} gitChanges - Safe git changes object
177
+ * @param {Object} fileChanges - Safe file changes object
178
+ * @returns {string} Formatted time summary
179
+ * @private
180
+ */
181
+ _formatTimeSummary(diff, gitChanges, fileChanges) {
182
+ const timeDelta = this._formatTimeDelta(diff.timeDelta || diff.timeDeltaMs || 0);
183
+ const commitCount = gitChanges.commits ? gitChanges.commits.length : gitChanges.newCommitCount || 0;
184
+ const addedCount = fileChanges.added.length;
185
+ const modifiedCount = fileChanges.modified.length;
186
+ const deletedCount = fileChanges.deleted.length;
187
+
188
+ let summary = `### Since your last session (${timeDelta}):\n`;
189
+ summary += `- **${commitCount} new commit${commitCount !== 1 ? 's' : ''}** on \`${gitChanges.newBranch || 'unknown'}\`\n`;
190
+ summary += `- **${addedCount} file${addedCount !== 1 ? 's' : ''} added**, **${modifiedCount} modified**, **${deletedCount} deleted**`;
191
+
192
+ return summary;
193
+ }
194
+
195
+ /**
196
+ * Formats a time delta in milliseconds to human-readable format
197
+ * @param {number} ms - Milliseconds
198
+ * @returns {string} Human-readable time delta
199
+ * @private
200
+ */
201
+ _formatTimeDelta(ms) {
202
+ const seconds = Math.floor(ms / 1000);
203
+ const minutes = Math.floor(seconds / 60);
204
+ const hours = Math.floor(minutes / 60);
205
+ const days = Math.floor(hours / 24);
206
+
207
+ if (days > 0) return `${days} day${days !== 1 ? 's' : ''} ago`;
208
+ if (hours > 0) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
209
+ if (minutes > 0) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
210
+ return 'just now';
211
+ }
212
+
213
+ /**
214
+ * Formats the git changes section
215
+ * @param {Object} gitChanges - Git changes from diff
216
+ * @returns {string} Formatted git section
217
+ * @private
218
+ */
219
+ _formatGitSection(gitChanges) {
220
+ const commits = gitChanges.commits || [];
221
+ if (!commits.length && !gitChanges.branchChanged) {
222
+ return ''; // No git changes to report
223
+ }
224
+
225
+ let section = '### Git Changes:\n';
226
+
227
+ // List new commits
228
+ if (commits.length > 0) {
229
+ commits.forEach(commit => {
230
+ section += `- \`${commit.hash}\` ${commit.message}\n`;
231
+ });
232
+ }
233
+
234
+ // Report branch changes
235
+ if (gitChanges.branchChanged) {
236
+ section += `- Branch changed from \`${gitChanges.oldBranch || 'unknown'}\` to \`${gitChanges.newBranch || 'unknown'}\``;
237
+ }
238
+
239
+ return section.trim();
240
+ }
241
+
242
+ /**
243
+ * Formats the file changes section
244
+ * @param {Object} fileChanges - File changes from diff
245
+ * @param {Array} activeFiles - Files that were actively being worked on
246
+ * @returns {string} Formatted file section
247
+ * @private
248
+ */
249
+ _formatFileSection(fileChanges, activeFiles) {
250
+ let section = '### File Changes:\n';
251
+
252
+ // Added files
253
+ section += '**Added:**\n';
254
+ if (fileChanges.added.length > 0) {
255
+ fileChanges.added.forEach(file => {
256
+ section += `- \`${file}\`\n`;
257
+ });
258
+ } else {
259
+ section += '- (none)\n';
260
+ }
261
+
262
+ section += '\n**Modified:**\n';
263
+ if (fileChanges.modified.length > 0) {
264
+ fileChanges.modified.forEach(file => {
265
+ // Mark files that were being actively worked on
266
+ const isActive = activeFiles.includes(file);
267
+ section += `- \`${file}\`${isActive ? ' ⚠️ (you were working on this!)' : ''}\n`;
268
+ });
269
+ } else {
270
+ section += '- (none)\n';
271
+ }
272
+
273
+ section += '\n**Deleted:**\n';
274
+ if (fileChanges.deleted.length > 0) {
275
+ fileChanges.deleted.forEach(file => {
276
+ section += `- \`${file}\`\n`;
277
+ });
278
+ } else {
279
+ section += '- (none)\n';
280
+ }
281
+
282
+ return section.trim();
283
+ }
284
+
285
+ /**
286
+ * Formats the previous context section
287
+ * @param {Object} snapshot - The latest snapshot
288
+ * @returns {string} Formatted context section
289
+ * @private
290
+ */
291
+ _formatContextSection(snapshot) {
292
+ // The snapshot stores working context in snapshot.workingContext
293
+ const ctx = snapshot.workingContext;
294
+ if (!ctx) {
295
+ return '';
296
+ }
297
+
298
+ let section = '### Your Previous Context:\n';
299
+
300
+ // What were we working on?
301
+ if (ctx.taskSummary) {
302
+ section += `- **Working on:** ${ctx.taskSummary}\n`;
303
+ }
304
+
305
+ // Any open questions?
306
+ if (ctx.openQuestions && ctx.openQuestions.length > 0) {
307
+ section += '- **Open questions:**\n';
308
+ ctx.openQuestions.forEach(q => {
309
+ section += ` - ${q}\n`;
310
+ });
311
+ }
312
+
313
+ return section.trim();
314
+ }
315
+
316
+ /**
317
+ * Formats the decisions section
318
+ * @param {number} limit - Maximum number of decisions to include
319
+ * @returns {string} Formatted decisions section
320
+ * @private
321
+ */
322
+ _formatDecisions(limit) {
323
+ const decisions = this.journal.list({ limit });
324
+ if (decisions.length === 0) {
325
+ return '';
326
+ }
327
+
328
+ let section = '### Recent Decisions:\n';
329
+ decisions.forEach((decision, index) => {
330
+ const date = new Date(decision.createdAt).toLocaleDateString('en-US', {
331
+ month: 'short',
332
+ day: 'numeric'
333
+ });
334
+ section += `${index + 1}. **${decision.decision}** — ${decision.reason} _(${date})_\n`;
335
+ });
336
+
337
+ return section.trim();
338
+ }
339
+
340
+ /**
341
+ * Formats the patterns section
342
+ * @returns {string} Formatted patterns section
343
+ * @private
344
+ */
345
+ _formatPatterns() {
346
+ const patterns = this.patterns.list();
347
+ if (patterns.length === 0) {
348
+ return '';
349
+ }
350
+
351
+ let section = '### Patterns to Follow:\n';
352
+ patterns.forEach(pattern => {
353
+ const contextStr = pattern.context ? ` _(context: ${pattern.context})_` : '';
354
+ section += `- ${pattern.rule}${contextStr}\n`;
355
+ });
356
+
357
+ return section.trim();
358
+ }
359
+
360
+ /**
361
+ * Formats the stale warnings section
362
+ * @param {Object} fileChanges - File changes from diff
363
+ * @param {Array} activeFiles - Files that were actively being worked on
364
+ * @returns {string} Formatted stale warnings section
365
+ * @private
366
+ */
367
+ _formatStaleWarnings(fileChanges, activeFiles) {
368
+ // Files that were active and have been modified are stale
369
+ const staleFiles = fileChanges.modified.filter(file => activeFiles.includes(file));
370
+
371
+ if (staleFiles.length === 0) {
372
+ return '';
373
+ }
374
+
375
+ let section = '### ⚠ Stale Context Warnings:\n';
376
+ staleFiles.forEach(file => {
377
+ section += `- \`${file}\` was modified since your last session. Re-read before assuming previous context is correct.\n`;
378
+ });
379
+
380
+ return section.trim();
381
+ }
382
+
383
+ /**
384
+ * Trims the briefing to fit within token limit
385
+ * @param {Object} sections - All sections of the briefing
386
+ * @param {number} maxTokens - Maximum allowed characters
387
+ * @returns {string} Trimmed markdown
388
+ * @private
389
+ */
390
+ _trimToTokenLimit(sections, maxTokens) {
391
+ // Priority order: header > timeSummary > gitSection > fileSection > contextSection > decisionsSection > patternsSection > staleSection
392
+ // We trim from least important to most important
393
+ const priorityOrder = [
394
+ 'patternsSection',
395
+ 'decisionsSection',
396
+ 'staleSection',
397
+ 'fileSection',
398
+ 'gitSection',
399
+ 'contextSection',
400
+ 'timeSummary',
401
+ 'header'
402
+ ];
403
+
404
+ let result = '';
405
+ let currentLength = 0;
406
+
407
+ // Start from most important and add sections until we hit the limit
408
+ for (let i = priorityOrder.length - 1; i >= 0; i--) {
409
+ const sectionName = priorityOrder[i];
410
+ const sectionContent = sections[sectionName];
411
+
412
+ if (!sectionContent) continue;
413
+
414
+ const sectionLength = sectionContent.length + 2; // +2 for the newlines
415
+ if (currentLength + sectionLength > maxTokens) {
416
+ break; // Can't fit any more sections
417
+ }
418
+
419
+ result = result ? sectionContent + '\n\n' + result : sectionContent;
420
+ currentLength += sectionLength;
421
+ }
422
+
423
+ return result.trim();
424
+ }
425
+
426
+ /**
427
+ * Gets the most recently generated briefing markdown
428
+ * @returns {string|null} The briefing markdown, or null if none exist
429
+ */
430
+ getLatestBriefing() {
431
+ // Read all briefing files in the directory
432
+ if (!fs.existsSync(this.briefingsDir)) {
433
+ return null;
434
+ }
435
+
436
+ const files = fs.readdirSync(this.briefingsDir)
437
+ .filter(f => f.startsWith('brief_') && f.endsWith('.md'))
438
+ .sort()
439
+ .reverse(); // Most recent first
440
+
441
+ if (files.length === 0) {
442
+ return null;
443
+ }
444
+
445
+ // Read and return the most recent briefing
446
+ const latestFile = path.join(this.briefingsDir, files[0]);
447
+ return fs.readFileSync(latestFile, 'utf8');
448
+ }
449
+ }
450
+
451
+ module.exports = BriefingGenerator;