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.
- package/.opencode/branch-memory/.gitkeep +0 -0
- package/.opencode/branch-memory/collector.ts +90 -0
- package/.opencode/branch-memory/config.ts +127 -0
- package/.opencode/branch-memory/git.ts +169 -0
- package/.opencode/branch-memory/index.ts +7 -0
- package/.opencode/branch-memory/injector.ts +108 -0
- package/.opencode/branch-memory/monitor.ts +230 -0
- package/.opencode/branch-memory/storage.ts +322 -0
- package/.opencode/branch-memory/types.ts +68 -0
- package/.opencode/dist/branch-memory.js +13040 -0
- package/.opencode/package-lock.json +82 -0
- package/.opencode/plugin/branch-memory-plugin.ts +149 -0
- package/.opencode/tool/branch-memory.ts +254 -0
- package/LICENSE +21 -0
- package/README.md +500 -0
- package/package.json +61 -0
|
@@ -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
|
+
}
|