opencode-branch-memory-manager 0.1.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,230 @@
1
+ import chokidar from "chokidar";
2
+ import type { FSWatcher } from "chokidar";
3
+ import type { PluginConfig } from "./types.js";
4
+ import { GitOperations } from "./git.js";
5
+
6
+ /**
7
+ * Monitors git branch changes
8
+ */
9
+ export class BranchMonitor {
10
+ private watcher?: FSWatcher;
11
+ private pollingInterval?: NodeJS.Timeout;
12
+ private currentBranch?: string;
13
+ private lastPoll?: number;
14
+ private changeCallbacks: Array<
15
+ (oldBranch: string | undefined, newBranch: string) => void
16
+ > = [];
17
+ private isMonitoring = false;
18
+
19
+ constructor(
20
+ private onBranchChange: (
21
+ oldBranch: string | undefined,
22
+ newBranch: string,
23
+ ) => void,
24
+ private config: PluginConfig,
25
+ ) {}
26
+
27
+ /**
28
+ * Start monitoring git branch changes
29
+ */
30
+ async start(): Promise<void> {
31
+ if (this.isMonitoring) {
32
+ return;
33
+ }
34
+
35
+ const gitDir = await GitOperations.getGitDir();
36
+
37
+ if (!gitDir) {
38
+ console.warn("Not in a git repository, branch monitoring disabled");
39
+ return;
40
+ }
41
+
42
+ // Get current branch
43
+ const branch = await GitOperations.getCurrentBranch();
44
+ this.currentBranch = branch || undefined;
45
+
46
+ if (!this.currentBranch) {
47
+ console.warn("Not on a git branch, branch monitoring disabled");
48
+ return;
49
+ }
50
+
51
+ // Start watcher
52
+ if (
53
+ this.config.monitoring.method === "watcher" ||
54
+ this.config.monitoring.method === "both"
55
+ ) {
56
+ this.startWatcher(gitDir);
57
+ }
58
+
59
+ // Start polling as fallback
60
+ if (
61
+ this.config.monitoring.method === "polling" ||
62
+ this.config.monitoring.method === "both"
63
+ ) {
64
+ this.startPolling();
65
+ }
66
+
67
+ this.isMonitoring = true;
68
+ console.log(`✓ Branch monitoring started for: ${this.currentBranch}`);
69
+ console.log(` Method: ${this.config.monitoring.method}`);
70
+ console.log(
71
+ ` Polling interval: ${this.config.monitoring.pollingInterval}ms`,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Start file watcher on .git/HEAD
77
+ * @param gitDir - Path to .git directory
78
+ */
79
+ private startWatcher(gitDir: string): void {
80
+ try {
81
+ const headFile = `${gitDir}/HEAD`;
82
+
83
+ this.watcher = chokidar.watch(headFile, {
84
+ ignoreInitial: true,
85
+ persistent: true,
86
+ awaitWriteFinish: { stabilityThreshold: 100 },
87
+ usePolling: false, // Use native file system events
88
+ });
89
+
90
+ this.watcher.on("change", async () => {
91
+ // Debounce file changes (rapid changes within 100ms)
92
+ await this.checkBranchChange();
93
+ });
94
+
95
+ this.watcher.on("error", (error: unknown) => {
96
+ console.error("Watcher error:", error);
97
+
98
+ // Fall back to polling if watcher fails
99
+ if (this.config.monitoring.method === "watcher") {
100
+ console.info("Watcher failed, falling back to polling");
101
+ this.stopWatcher();
102
+ this.startPolling();
103
+ }
104
+ });
105
+
106
+ console.log(`✓ File watcher started: ${headFile}`);
107
+ } catch (error) {
108
+ console.error("Failed to start watcher:", error);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Start polling for branch changes
114
+ */
115
+ private startPolling(): void {
116
+ const interval = this.config.monitoring.pollingInterval || 1000;
117
+
118
+ this.pollingInterval = setInterval(async () => {
119
+ await this.checkBranchChange();
120
+ }, interval);
121
+
122
+ console.log(`✓ Polling started (interval: ${interval}ms)`);
123
+ }
124
+
125
+ /**
126
+ * Stop monitoring
127
+ */
128
+ stop(): void {
129
+ this.stopWatcher();
130
+ this.stopPolling();
131
+ this.isMonitoring = false;
132
+ console.log("✓ Branch monitoring stopped");
133
+ }
134
+
135
+ /**
136
+ * Stop file watcher
137
+ */
138
+ private stopWatcher(): void {
139
+ if (this.watcher) {
140
+ this.watcher.close();
141
+ this.watcher = undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Stop polling
147
+ */
148
+ private stopPolling(): void {
149
+ if (this.pollingInterval) {
150
+ clearInterval(this.pollingInterval);
151
+ this.pollingInterval = undefined;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Check for branch change
157
+ */
158
+ private async checkBranchChange(): Promise<void> {
159
+ try {
160
+ const newBranch = await GitOperations.getCurrentBranch();
161
+
162
+ if (newBranch && newBranch !== this.currentBranch) {
163
+ const oldBranch = this.currentBranch;
164
+ this.currentBranch = newBranch;
165
+
166
+ console.log(
167
+ `🔄 Branch changed: ${oldBranch || "(none)"} → ${newBranch}`,
168
+ );
169
+
170
+ // Call all registered callbacks
171
+ for (const callback of this.changeCallbacks) {
172
+ try {
173
+ await callback(oldBranch, newBranch);
174
+ } catch (error) {
175
+ console.error("Error in branch change callback:", error);
176
+ }
177
+ }
178
+ }
179
+ } catch (error) {
180
+ console.error("Error checking branch:", error);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Register a callback for branch changes
186
+ * @param callback - Function to call on branch change
187
+ */
188
+ onChange(
189
+ callback: (oldBranch: string | undefined, newBranch: string) => void,
190
+ ): void {
191
+ this.changeCallbacks.push(callback);
192
+ }
193
+
194
+ /**
195
+ * Unregister a callback
196
+ * @param callback - Function to remove
197
+ */
198
+ offChange(
199
+ callback: (oldBranch: string | undefined, newBranch: string) => void,
200
+ ): void {
201
+ const index = this.changeCallbacks.indexOf(callback);
202
+ if (index > -1) {
203
+ this.changeCallbacks.splice(index, 1);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Get current monitored branch
209
+ * @returns Current branch name or undefined
210
+ */
211
+ getCurrentBranch(): string | undefined {
212
+ return this.currentBranch;
213
+ }
214
+
215
+ /**
216
+ * Check if monitoring is active
217
+ * @returns True if monitoring
218
+ */
219
+ isActive(): boolean {
220
+ return this.isMonitoring;
221
+ }
222
+
223
+ /**
224
+ * Test helper: Manually trigger branch change check
225
+ * @internal For testing purposes only
226
+ */
227
+ async _testTriggerCheck(): Promise<void> {
228
+ await this.checkBranchChange();
229
+ }
230
+ }
@@ -0,0 +1,322 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import { existsSync } from 'fs'
4
+ import type { BranchContext } from './types.js'
5
+
6
+ /**
7
+ * Context storage manager for branch-specific contexts
8
+ */
9
+ export class ContextStorage {
10
+ private storageDir: string
11
+
12
+ constructor(storageDir: string) {
13
+ this.storageDir = storageDir
14
+ }
15
+
16
+ /**
17
+ * Get the file path for a branch
18
+ * @param branch - Branch name
19
+ * @returns Full path to branch context file
20
+ */
21
+ private getBranchFile(branch: string): string {
22
+ return path.join(this.storageDir, `${this.sanitizeBranchName(branch)}.json`)
23
+ }
24
+
25
+ /**
26
+ * Get the backup file path for a branch
27
+ * @param branch - Branch name
28
+ * @param timestamp - Timestamp for backup filename
29
+ * @returns Full path to backup file
30
+ */
31
+ private getBackupFile(branch: string, timestamp: number): string {
32
+ const safeBranch = this.sanitizeBranchName(branch)
33
+ return path.join(this.storageDir, `${safeBranch}.backup.${timestamp}.json`)
34
+ }
35
+
36
+ /**
37
+ * Sanitize branch name for safe filename usage
38
+ * @param branch - Branch name to sanitize
39
+ * @returns Sanitized branch name
40
+ */
41
+ private sanitizeBranchName(branch: string): string {
42
+ return branch
43
+ .replace(/[\/\\:*?"<>|]/g, '_')
44
+ .replace(/\s+/g, '-')
45
+ .substring(0, 255)
46
+ }
47
+
48
+ /**
49
+ * Ensure storage directory exists
50
+ */
51
+ async ensureStorageDir(): Promise<void> {
52
+ try {
53
+ await fs.mkdir(this.storageDir, { recursive: true })
54
+ } catch (error) {
55
+ console.error('Failed to create storage directory:', error)
56
+ throw error
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Save context for a branch with automatic backup
62
+ * @param branch - Branch name
63
+ * @param context - Branch context to save
64
+ */
65
+ async saveContext(branch: string, context: BranchContext): Promise<void> {
66
+ await this.ensureStorageDir()
67
+
68
+ const filePath = this.getBranchFile(branch)
69
+ // Use unique temp file to avoid race conditions in concurrent saves
70
+ const tempFile = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2, 9)}`
71
+
72
+ try {
73
+ // Create backup if file exists
74
+ if (existsSync(filePath)) {
75
+ await this.createBackup(branch, filePath)
76
+ }
77
+
78
+ // Write to temp file first (atomic operation)
79
+ await fs.writeFile(tempFile, JSON.stringify(context, null, 2), 'utf8')
80
+
81
+ // Atomic rename (guaranteed on all platforms)
82
+ await fs.rename(tempFile, filePath)
83
+ } catch (error) {
84
+ // Cleanup temp file on failure
85
+ try {
86
+ await fs.unlink(tempFile).catch(() => {})
87
+ } catch {}
88
+ throw error
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Load context for a branch with validation
94
+ * @param branch - Branch name
95
+ * @returns Branch context or null if not found
96
+ */
97
+ async loadContext(branch: string): Promise<BranchContext | null> {
98
+ const filePath = this.getBranchFile(branch)
99
+
100
+ if (!existsSync(filePath)) {
101
+ return null
102
+ }
103
+
104
+ try {
105
+ const content = await fs.readFile(filePath, 'utf8')
106
+ const data = JSON.parse(content) as BranchContext
107
+
108
+ // Validate version compatibility
109
+ if (data.metadata?.version !== '1.0.0') {
110
+ console.warn(`Context version mismatch for branch '${branch}': ${data.metadata?.version}`)
111
+ }
112
+
113
+ return data
114
+ } catch (error) {
115
+ console.error(`Failed to load context for branch '${branch}':`, error)
116
+
117
+ // Try to restore from backup
118
+ const backup = await this.restoreFromBackup(branch)
119
+ if (backup) {
120
+ console.info(`Restored from backup for branch '${branch}'`)
121
+ return backup
122
+ }
123
+
124
+ return null
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Create backup of existing context file
130
+ * @param branch - Branch name
131
+ * @param filePath - Path to existing file
132
+ */
133
+ private async createBackup(branch: string, filePath: string): Promise<void> {
134
+ try {
135
+ // Check if file still exists (might have been deleted by concurrent operation)
136
+ if (!existsSync(filePath)) {
137
+ return
138
+ }
139
+
140
+ const backupFile = this.getBackupFile(branch, Date.now())
141
+ await fs.copyFile(filePath, backupFile)
142
+
143
+ // Clean old backups (keep last 5)
144
+ await this.cleanOldBackups(branch)
145
+ } catch (error) {
146
+ console.warn('Failed to create backup:', error)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Clean old backups, keeping only the last maxBackups
152
+ * @param branch - Branch name
153
+ */
154
+ private async cleanOldBackups(branch: string): Promise<void> {
155
+ try {
156
+ const files = await fs.readdir(this.storageDir)
157
+ const safeBranch = this.sanitizeBranchName(branch)
158
+ const backups = files
159
+ .filter(f => f.startsWith(`${safeBranch}.backup.`) && f.endsWith('.json'))
160
+ .map(f => {
161
+ const match = f.match(/\.backup\.(\d+)\.json$/)
162
+ return { name: f, timestamp: match ? parseInt(match[1], 10) : 0 }
163
+ })
164
+ .sort((a, b) => b.timestamp - a.timestamp)
165
+ .slice(5) // Keep last 5
166
+
167
+ for (const backup of backups) {
168
+ await fs.unlink(path.join(this.storageDir, backup.name)).catch(() => {})
169
+ }
170
+ } catch (error) {
171
+ console.warn('Failed to clean old backups:', error)
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Restore context from backup files
177
+ * @param branch - Branch name
178
+ * @returns Restored context or null if no valid backup found
179
+ */
180
+ private async restoreFromBackup(branch: string): Promise<BranchContext | null> {
181
+ try {
182
+ const files = await fs.readdir(this.storageDir)
183
+ const safeBranch = this.sanitizeBranchName(branch)
184
+ const backups = files
185
+ .filter(f => f.startsWith(`${safeBranch}.backup.`) && f.endsWith('.json'))
186
+ .map(f => {
187
+ const match = f.match(/\.backup\.(\d+)\.json$/)
188
+ return { name: f, timestamp: match ? parseInt(match[1], 10) : 0 }
189
+ })
190
+ .sort((a, b) => b.timestamp - a.timestamp)
191
+
192
+ for (const backup of backups) {
193
+ try {
194
+ const content = await fs.readFile(
195
+ path.join(this.storageDir, backup.name),
196
+ 'utf8'
197
+ )
198
+ return JSON.parse(content) as BranchContext
199
+ } catch {
200
+ // Try next backup
201
+ continue
202
+ }
203
+ }
204
+ } catch (error) {
205
+ console.error('Failed to restore from backup:', error)
206
+ }
207
+ return null
208
+ }
209
+
210
+ /**
211
+ * List all branches with saved contexts
212
+ * @returns Array of branch names
213
+ */
214
+ async listBranches(): Promise<string[]> {
215
+ try {
216
+ await this.ensureStorageDir()
217
+ const files = await fs.readdir(this.storageDir)
218
+ const branches: string[] = []
219
+
220
+ for (const file of files) {
221
+ if (file.endsWith('.json') && !file.includes('.backup.')) {
222
+ try {
223
+ const filePath = path.join(this.storageDir, file)
224
+ const content = await fs.readFile(filePath, 'utf8')
225
+ const data = JSON.parse(content) as BranchContext
226
+ branches.push(data.branch)
227
+ } catch {
228
+ // Skip invalid files
229
+ continue
230
+ }
231
+ }
232
+ }
233
+
234
+ return branches
235
+ } catch (error) {
236
+ console.error('Failed to list branches:', error)
237
+ return []
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Get metadata for a branch's saved context
243
+ * @param branch - Branch name
244
+ * @returns Metadata object
245
+ */
246
+ async getMetadata(branch: string): Promise<{
247
+ size: string
248
+ modified: string
249
+ messageCount: number
250
+ todoCount: number
251
+ fileCount: number
252
+ }> {
253
+ const filePath = this.getBranchFile(branch)
254
+
255
+ if (!existsSync(filePath)) {
256
+ return {
257
+ size: '0KB',
258
+ modified: 'Never',
259
+ messageCount: 0,
260
+ todoCount: 0,
261
+ fileCount: 0
262
+ }
263
+ }
264
+
265
+ try {
266
+ const content = await fs.readFile(filePath, 'utf8')
267
+ const data = JSON.parse(content) as BranchContext
268
+
269
+ // Use metadata.size if available, otherwise calculate from file
270
+ let size: string
271
+ if (data.metadata?.size !== undefined) {
272
+ size = `${(data.metadata.size / 1024).toFixed(1)}KB`
273
+ } else {
274
+ const stats = await fs.stat(filePath)
275
+ size = `${(stats.size / 1024).toFixed(1)}KB`
276
+ }
277
+
278
+ return {
279
+ size,
280
+ modified: data.savedAt || new Date().toISOString(),
281
+ messageCount: data.metadata?.messageCount || 0,
282
+ todoCount: data.metadata?.todoCount || 0,
283
+ fileCount: data.metadata?.fileCount || 0
284
+ }
285
+ } catch (error) {
286
+ console.error('Failed to get metadata:', error)
287
+ return {
288
+ size: 'Error',
289
+ modified: 'Error',
290
+ messageCount: 0,
291
+ todoCount: 0,
292
+ fileCount: 0
293
+ }
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Delete context for a branch including all backups
299
+ * @param branch - Branch name
300
+ */
301
+ async deleteContext(branch: string): Promise<void> {
302
+ const filePath = this.getBranchFile(branch)
303
+
304
+ // Delete main context file
305
+ if (existsSync(filePath)) {
306
+ await fs.unlink(filePath).catch(() => {})
307
+ }
308
+
309
+ // Delete all backups
310
+ try {
311
+ const files = await fs.readdir(this.storageDir)
312
+ const safeBranch = this.sanitizeBranchName(branch)
313
+ const backups = files.filter(f => f.startsWith(`${safeBranch}.backup.`) && f.endsWith('.json'))
314
+
315
+ for (const backup of backups) {
316
+ await fs.unlink(path.join(this.storageDir, backup)).catch(() => {})
317
+ }
318
+ } catch (error) {
319
+ console.error('Failed to delete backups:', error)
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Branch context data structure
3
+ */
4
+ export interface BranchContext {
5
+ branch: string
6
+ savedAt: string
7
+ metadata: {
8
+ version: string
9
+ platform: string
10
+ size: number
11
+ messageCount: number
12
+ todoCount: number
13
+ fileCount: number
14
+ }
15
+ data: {
16
+ messages?: Message[]
17
+ todos?: Todo[]
18
+ files?: string[]
19
+ summary?: string
20
+ description?: string
21
+ [key: string]: unknown // Allow additional dynamic properties
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Message data structure
27
+ */
28
+ export interface Message {
29
+ role: 'user' | 'assistant'
30
+ content: string
31
+ timestamp: string
32
+ }
33
+
34
+ /**
35
+ * Todo item data structure
36
+ */
37
+ export interface Todo {
38
+ id: string
39
+ content: string
40
+ status: 'pending' | 'in_progress' | 'completed'
41
+ }
42
+
43
+ /**
44
+ * Plugin configuration
45
+ */
46
+ export interface PluginConfig {
47
+ autoSave: {
48
+ enabled: boolean
49
+ onMessageChange: boolean
50
+ onBranchChange: boolean
51
+ onToolExecute: boolean
52
+ }
53
+ contextLoading: 'auto' | 'ask' | 'manual'
54
+ context: {
55
+ defaultInclude: ('messages' | 'todos' | 'files')[]
56
+ maxMessages: number
57
+ maxTodos: number
58
+ compression: boolean
59
+ }
60
+ storage: {
61
+ maxBackups: number
62
+ retentionDays: number
63
+ }
64
+ monitoring: {
65
+ method: 'watcher' | 'polling' | 'both'
66
+ pollingInterval: number
67
+ }
68
+ }