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.
File without changes
@@ -0,0 +1,90 @@
1
+ import type { BranchContext, PluginConfig } from "./types.js";
2
+ import { GitOperations } from "./git.js";
3
+
4
+ /**
5
+ * Collects context data from various sources
6
+ */
7
+ export class ContextCollector {
8
+ private config: PluginConfig;
9
+
10
+ constructor(config: PluginConfig) {
11
+ this.config = config;
12
+ }
13
+
14
+ /**
15
+ * Collect context from available sources
16
+ * @param includeMessages - Include conversation messages
17
+ * @param includeTodos - Include todo items
18
+ * @param includeFiles - Include file references
19
+ * @param description - Description of what's being saved
20
+ * @returns Complete context object
21
+ */
22
+ async collectContext(
23
+ includeMessages: boolean = true,
24
+ includeTodos: boolean = true,
25
+ includeFiles: boolean = true,
26
+ description: string = "",
27
+ ): Promise<BranchContext> {
28
+ const currentBranch = await GitOperations.getCurrentBranch();
29
+
30
+ if (!currentBranch) {
31
+ throw new Error("Not on a git branch");
32
+ }
33
+
34
+ const data: BranchContext["data"] = {
35
+ description: description || "",
36
+ };
37
+
38
+ // Collect messages (placeholder - will use OpenCode SDK when available)
39
+ if (includeMessages) {
40
+ data.messages = await this.collectMessages();
41
+ }
42
+
43
+ // Collect todos (placeholder - will use OpenCode SDK when available)
44
+ if (includeTodos) {
45
+ data.todos = await this.collectTodos();
46
+ }
47
+
48
+ // Collect modified files from git
49
+ if (includeFiles) {
50
+ data.files = await GitOperations.getModifiedFiles();
51
+ }
52
+
53
+ // Calculate metadata
54
+ const context: BranchContext = {
55
+ branch: currentBranch,
56
+ savedAt: new Date().toISOString(),
57
+ metadata: {
58
+ version: "1.0.0",
59
+ platform: process.platform,
60
+ size: JSON.stringify(data).length,
61
+ messageCount: data.messages?.length || 0,
62
+ todoCount: data.todos?.length || 0,
63
+ fileCount: data.files?.length || 0,
64
+ },
65
+ data,
66
+ };
67
+
68
+ return context;
69
+ }
70
+
71
+ /**
72
+ * Collect conversation messages
73
+ * @returns Array of messages
74
+ */
75
+ private async collectMessages(): Promise<BranchContext["data"]["messages"]> {
76
+ // Placeholder for SDK integration
77
+ // When OpenCode SDK is available, this will fetch recent messages
78
+ return [];
79
+ }
80
+
81
+ /**
82
+ * Collect todo items
83
+ * @returns Array of todos
84
+ */
85
+ private async collectTodos(): Promise<BranchContext["data"]["todos"]> {
86
+ // Placeholder for SDK integration
87
+ // When OpenCode SDK is available, this will fetch todo items
88
+ return [];
89
+ }
90
+ }
@@ -0,0 +1,127 @@
1
+ import * as fs from 'fs/promises'
2
+ import * as path from 'path'
3
+ import { existsSync } from 'fs'
4
+ import type { PluginConfig } from './types.js'
5
+
6
+ /**
7
+ * Default configuration for the branch memory plugin
8
+ */
9
+ const DEFAULT_CONFIG: PluginConfig = {
10
+ autoSave: {
11
+ enabled: true,
12
+ onMessageChange: true,
13
+ onBranchChange: true,
14
+ onToolExecute: true
15
+ },
16
+ contextLoading: 'auto',
17
+ context: {
18
+ defaultInclude: ['messages', 'todos', 'files'],
19
+ maxMessages: 50,
20
+ maxTodos: 20,
21
+ compression: false
22
+ },
23
+ storage: {
24
+ maxBackups: 5,
25
+ retentionDays: 90
26
+ },
27
+ monitoring: {
28
+ method: 'both',
29
+ pollingInterval: 1000
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Configuration manager for branch memory plugin
35
+ */
36
+ export class ConfigManager {
37
+ private static configPath: string
38
+ private static projectPath: string
39
+
40
+ /**
41
+ * Set the project path for configuration
42
+ * @param projectPath - The root directory of the project
43
+ */
44
+ static setProjectPath(projectPath: string): void {
45
+ this.projectPath = projectPath
46
+ this.configPath = path.join(projectPath, '.opencode', 'config', 'branch-memory.json')
47
+ }
48
+
49
+ /**
50
+ * Load configuration from project directory, falling back to defaults
51
+ * @returns Configuration object
52
+ */
53
+ static async load(): Promise<PluginConfig> {
54
+ if (existsSync(this.configPath)) {
55
+ try {
56
+ const content = await fs.readFile(this.configPath, 'utf8')
57
+ const userConfig = JSON.parse(content) as Partial<PluginConfig>
58
+
59
+ // Deep merge user config with defaults
60
+ return this.deepMerge(DEFAULT_CONFIG, userConfig) as PluginConfig
61
+ } catch (error) {
62
+ console.warn('Failed to load config, using defaults:', error instanceof Error ? error.message : error)
63
+ return { ...DEFAULT_CONFIG }
64
+ }
65
+ }
66
+ return { ...DEFAULT_CONFIG }
67
+ }
68
+
69
+ /**
70
+ * Get default configuration
71
+ * @returns Default configuration object
72
+ */
73
+ static getDefault(): PluginConfig {
74
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as PluginConfig
75
+ }
76
+
77
+ /**
78
+ * Get storage directory path
79
+ * @param projectPath - The root directory of the project
80
+ * @returns Path to storage directory
81
+ */
82
+ static getStorageDir(projectPath: string): string {
83
+ return path.join(projectPath, '.opencode', 'branch-memory')
84
+ }
85
+
86
+ /**
87
+ * Save configuration to project directory
88
+ * @param config - Configuration object to save
89
+ */
90
+ static async save(config: PluginConfig): Promise<void> {
91
+ const configDir = path.dirname(this.configPath)
92
+
93
+ try {
94
+ await fs.mkdir(configDir, { recursive: true })
95
+ await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf8')
96
+ } catch (error) {
97
+ console.error('Failed to save configuration:', error)
98
+ throw error
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Deep merge two objects
104
+ * @param target - Target object (defaults)
105
+ * @param source - Source object (user config)
106
+ * @returns Merged object
107
+ */
108
+ private static deepMerge<T>(target: T, source: Partial<T>): T {
109
+ const result = { ...target }
110
+
111
+ for (const key in source) {
112
+ const sourceValue = source[key]
113
+ const targetValue = result[key]
114
+
115
+ if (sourceValue !== undefined) {
116
+ if (typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue) &&
117
+ typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)) {
118
+ result[key] = this.deepMerge(targetValue as any, sourceValue as any) as any
119
+ } else {
120
+ result[key] = sourceValue as any
121
+ }
122
+ }
123
+ }
124
+
125
+ return result
126
+ }
127
+ }
@@ -0,0 +1,169 @@
1
+ import { spawn } from 'child_process'
2
+
3
+ /**
4
+ * Git operations for branch memory manager
5
+ */
6
+ export class GitOperations {
7
+ private static async runGitCommand(...args: string[]): Promise<{
8
+ stdout: string
9
+ stderr: string
10
+ exitCode: number
11
+ }> {
12
+ return new Promise((resolve, reject) => {
13
+ const git = spawn('git', args, {
14
+ cwd: process.cwd(),
15
+ stdio: ['pipe', 'pipe', 'pipe']
16
+ })
17
+
18
+ let stdout = ''
19
+ let stderr = ''
20
+
21
+ git.stdout.on('data', (data) => {
22
+ stdout += data.toString()
23
+ })
24
+
25
+ git.stderr.on('data', (data) => {
26
+ stderr += data.toString()
27
+ })
28
+
29
+ git.on('close', (code) => {
30
+ resolve({
31
+ stdout: stdout.trim(),
32
+ stderr: stderr.trim(),
33
+ exitCode: code ?? 0
34
+ })
35
+ })
36
+
37
+ git.on('error', (err) => {
38
+ resolve({
39
+ stdout: stdout.trim(),
40
+ stderr: stderr.trim(),
41
+ exitCode: 1
42
+ })
43
+ })
44
+ })
45
+ }
46
+
47
+ static async getCurrentBranch(): Promise<string | null> {
48
+ const result = await this.runGitCommand('symbolic-ref', '--short', 'HEAD')
49
+
50
+ if (result.exitCode !== 0 || result.stderr.length > 0) {
51
+ return null
52
+ }
53
+
54
+ const branch = result.stdout
55
+
56
+ if (branch === 'HEAD' || branch === '') {
57
+ return null
58
+ }
59
+
60
+ return branch
61
+ }
62
+
63
+ static async getGitDir(): Promise<string | null> {
64
+ const result = await this.runGitCommand('rev-parse', '--git-dir')
65
+
66
+ if (result.exitCode !== 0 || result.stderr.length > 0) {
67
+ return null
68
+ }
69
+
70
+ return result.stdout
71
+ }
72
+
73
+ static async isGitRepo(): Promise<boolean> {
74
+ const gitDir = await this.getGitDir()
75
+ return gitDir !== null
76
+ }
77
+
78
+ static async isBareRepo(): Promise<boolean> {
79
+ const result = await this.runGitCommand('rev-parse', '--is-bare-repository')
80
+ return result.stdout === 'true' && result.exitCode === 0
81
+ }
82
+
83
+ static async getModifiedFiles(): Promise<string[]> {
84
+ const result = await this.runGitCommand('diff', '--name-only')
85
+
86
+ if (result.exitCode !== 0) {
87
+ return []
88
+ }
89
+
90
+ if (result.stdout.length === 0) {
91
+ return []
92
+ }
93
+
94
+ return result.stdout.split('\n').filter(f => f.length > 0)
95
+ }
96
+
97
+ static async getAllBranches(): Promise<string[]> {
98
+ const result = await this.runGitCommand('branch', "--format='%(refname:short)'")
99
+
100
+ if (result.exitCode !== 0) {
101
+ return []
102
+ }
103
+
104
+ if (result.stdout.length === 0) {
105
+ return []
106
+ }
107
+
108
+ return result.stdout.split('\n').filter(f => f.length > 0)
109
+ }
110
+
111
+ static sanitizeBranchName(branch: string): string {
112
+ return branch
113
+ .replace(/[\/\\:*?"<>|]/g, '_')
114
+ .replace(/\s+/g, '-')
115
+ .replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
116
+ .replace(/[\uE000-\uF8FF\uFFF0-\uFFFF]/g, '')
117
+ .substring(0, 255)
118
+ .trim()
119
+ }
120
+
121
+ static async getCurrentCommit(): Promise<string | null> {
122
+ const result = await this.runGitCommand('rev-parse', 'HEAD')
123
+
124
+ if (result.exitCode !== 0 || result.stderr.length > 0) {
125
+ return null
126
+ }
127
+
128
+ return result.stdout
129
+ }
130
+
131
+ static async isGitAvailable(): Promise<boolean> {
132
+ const result = await this.runGitCommand('--version')
133
+ return result.exitCode === 0
134
+ }
135
+
136
+ static async getRemoteUrl(): Promise<string | null> {
137
+ const branch = await this.getCurrentBranch()
138
+ if (!branch) {
139
+ return null
140
+ }
141
+
142
+ const remoteResult = await this.runGitCommand('config', `branch.${branch}.remote`)
143
+ if (remoteResult.exitCode !== 0) {
144
+ return null
145
+ }
146
+
147
+ const remote = remoteResult.stdout
148
+ if (remote.length === 0) {
149
+ return null
150
+ }
151
+
152
+ const urlResult = await this.runGitCommand('remote', 'get-url', remote)
153
+ if (urlResult.exitCode !== 0) {
154
+ return null
155
+ }
156
+
157
+ return urlResult.stdout
158
+ }
159
+
160
+ static async hasUncommittedChanges(): Promise<boolean> {
161
+ const result = await this.runGitCommand('status', '--porcelain')
162
+
163
+ if (result.exitCode !== 0) {
164
+ return false
165
+ }
166
+
167
+ return result.stdout.length > 0
168
+ }
169
+ }
@@ -0,0 +1,7 @@
1
+ export { ContextStorage } from "./storage.js";
2
+ export { GitOperations } from "./git.js";
3
+ export { ContextCollector } from "./collector.js";
4
+ export { ContextInjector } from "./injector.js";
5
+ export { BranchMonitor } from "./monitor.js";
6
+ export { ConfigManager } from "./config.js";
7
+ export type { BranchContext, PluginConfig, Message, Todo } from "./types.js";
@@ -0,0 +1,108 @@
1
+ import type { ToolContext } from '@opencode-ai/plugin'
2
+ import type { BranchContext } from './types.js'
3
+
4
+ /**
5
+ * Injects context into OpenCode sessions
6
+ */
7
+ export class ContextInjector {
8
+ private context: ToolContext
9
+
10
+ constructor(context: ToolContext) {
11
+ this.context = context
12
+ }
13
+
14
+ /**
15
+ * Inject context into current session without triggering AI response
16
+ * @param branchContext - Branch context to inject
17
+ */
18
+ async injectContext(branchContext: BranchContext): Promise<void> {
19
+ const summary = this.formatContextSummary(branchContext)
20
+
21
+ // Inject context without triggering AI response
22
+ // This would use the OpenCode SDK client.session.prompt() with noReply: true
23
+ // For now, log the injection
24
+ console.log('\nšŸ“„ Context injected for branch:', branchContext.branch)
25
+ console.log('─'.repeat(50))
26
+ console.log(summary)
27
+ console.log('─'.repeat(50))
28
+ }
29
+
30
+ /**
31
+ * Ask user if they want to load context (interactive mode)
32
+ * @param branchContext - Branch context to load
33
+ * @returns User's decision
34
+ */
35
+ async askUserAboutContextLoading(
36
+ branchContext: BranchContext
37
+ ): Promise<boolean> {
38
+ const summary = this.formatContextSummary(branchContext)
39
+
40
+ console.log('\nšŸ“„ Context available for branch:', branchContext.branch)
41
+ console.log('─'.repeat(50))
42
+ console.log(summary)
43
+ console.log('─'.repeat(50))
44
+ console.log('Load this context? (y/n)')
45
+
46
+ // For now, auto-return true (would use TUI API in production)
47
+ // In real implementation, this would use OpenCode TUI to prompt user
48
+ return true
49
+ }
50
+
51
+ /**
52
+ * Format context as human-readable summary
53
+ * @param context - Branch context to format
54
+ * @returns Formatted summary string
55
+ */
56
+ private formatContextSummary(context: BranchContext): string {
57
+ const lines = [
58
+ `# Branch Context Loaded: ${context.branch}`,
59
+ `Restored from: ${context.savedAt}`,
60
+ ''
61
+ ]
62
+
63
+ if (context.data.description) {
64
+ lines.push('## Description')
65
+ lines.push(context.data.description)
66
+ lines.push('')
67
+ }
68
+
69
+ if (context.data.messages && context.data.messages.length > 0) {
70
+ lines.push(`## Recent Messages (${context.data.messages.length})`)
71
+ lines.push('... [Conversation history loaded] ...')
72
+ lines.push('')
73
+ }
74
+
75
+ if (context.data.todos && context.data.todos.length > 0) {
76
+ lines.push(`## Todo Items (${context.data.todos.length})`)
77
+ for (const todo of context.data.todos) {
78
+ const checkbox = todo.status === 'completed' ? 'x' : ' '
79
+ lines.push(`- [${checkbox}] ${todo.content}`)
80
+ }
81
+ lines.push('')
82
+ }
83
+
84
+ if (context.data.files && context.data.files.length > 0) {
85
+ lines.push(`## Modified Files (${context.data.files.length})`)
86
+ for (const file of context.data.files) {
87
+ lines.push(`- ${file}`)
88
+ }
89
+ lines.push('')
90
+ }
91
+
92
+ return lines.join('\n')
93
+ }
94
+
95
+ /**
96
+ * Check if there is context data to inject
97
+ * @param context - Branch context to check
98
+ * @returns True if context has data
99
+ */
100
+ hasContextData(context: BranchContext): boolean {
101
+ return !!(
102
+ context.data.description ||
103
+ (context.data.messages && context.data.messages.length > 0) ||
104
+ (context.data.todos && context.data.todos.length > 0) ||
105
+ (context.data.files && context.data.files.length > 0)
106
+ )
107
+ }
108
+ }