claudehq 1.0.0 → 1.0.2

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,127 @@
1
+ /**
2
+ * File Watchers Module - Watch for file system changes
3
+ *
4
+ * Watches task, todo, plan, and conversation directories for changes
5
+ * and emits events through the EventBus.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const {
10
+ TASKS_DIR,
11
+ TODOS_DIR,
12
+ PLANS_DIR,
13
+ PROJECTS_DIR,
14
+ EVENT_DEBOUNCE_DELAY
15
+ } = require('./config');
16
+ const { eventBus, EventTypes } = require('./event-bus');
17
+
18
+ let watchDebounce = null;
19
+
20
+ // Callback for rebuilding plan cache (set by plans module)
21
+ let rebuildPlanCacheCallback = null;
22
+ let getSessionForPlanCallback = null;
23
+
24
+ /**
25
+ * Set callback for rebuilding plan cache
26
+ * @param {Function} callback
27
+ */
28
+ function setPlanCacheCallbacks(rebuildCallback, getSessionCallback) {
29
+ rebuildPlanCacheCallback = rebuildCallback;
30
+ getSessionForPlanCallback = getSessionCallback;
31
+ }
32
+
33
+ /**
34
+ * Setup all file watchers
35
+ */
36
+ function setupWatchers() {
37
+ // Watch tasks directory
38
+ if (fs.existsSync(TASKS_DIR)) {
39
+ fs.watch(TASKS_DIR, { recursive: true }, (eventType, filename) => {
40
+ if (filename && filename.endsWith('.json')) {
41
+ clearTimeout(watchDebounce);
42
+ watchDebounce = setTimeout(() => {
43
+ eventBus.emit(EventTypes.FILE_CHANGED, {
44
+ type: 'task',
45
+ filename,
46
+ directory: TASKS_DIR
47
+ });
48
+ }, EVENT_DEBOUNCE_DELAY);
49
+ }
50
+ });
51
+ console.log(` Watching for changes in: ${TASKS_DIR}`);
52
+ }
53
+
54
+ // Watch projects directory for conversation updates
55
+ if (fs.existsSync(PROJECTS_DIR)) {
56
+ fs.watch(PROJECTS_DIR, { recursive: true }, (eventType, filename) => {
57
+ if (filename && filename.endsWith('.jsonl')) {
58
+ clearTimeout(watchDebounce);
59
+ watchDebounce = setTimeout(() => {
60
+ eventBus.emit(EventTypes.FILE_CHANGED, {
61
+ type: 'conversation',
62
+ filename,
63
+ directory: PROJECTS_DIR
64
+ });
65
+ }, 200);
66
+ }
67
+ });
68
+ console.log(` Watching for conversation updates in: ${PROJECTS_DIR}`);
69
+ }
70
+
71
+ // Watch todos directory for changes
72
+ if (fs.existsSync(TODOS_DIR)) {
73
+ fs.watch(TODOS_DIR, (eventType, filename) => {
74
+ if (filename && filename.endsWith('.json')) {
75
+ clearTimeout(watchDebounce);
76
+ watchDebounce = setTimeout(() => {
77
+ // Extract session ID from filename
78
+ const match = filename.match(/^([a-f0-9-]+)-agent-/);
79
+ const sessionId = match ? match[1] : null;
80
+
81
+ eventBus.emit(EventTypes.TODOS_CHANGED, {
82
+ type: 'todo',
83
+ filename,
84
+ sessionId,
85
+ directory: TODOS_DIR
86
+ });
87
+ }, EVENT_DEBOUNCE_DELAY);
88
+ }
89
+ });
90
+ console.log(` Watching for todo changes in: ${TODOS_DIR}`);
91
+ }
92
+
93
+ // Watch plans directory for changes
94
+ if (fs.existsSync(PLANS_DIR)) {
95
+ fs.watch(PLANS_DIR, (eventType, filename) => {
96
+ if (filename && filename.endsWith('.md')) {
97
+ clearTimeout(watchDebounce);
98
+ watchDebounce = setTimeout(() => {
99
+ const slug = filename.replace('.md', '');
100
+
101
+ // If it's a new plan, rebuild cache to find its session
102
+ if (eventType === 'rename' && rebuildPlanCacheCallback) {
103
+ rebuildPlanCacheCallback();
104
+ }
105
+
106
+ const sessionId = getSessionForPlanCallback ? getSessionForPlanCallback(slug) : null;
107
+
108
+ eventBus.emit(EventTypes.PLANS_CHANGED, {
109
+ type: 'plan',
110
+ slug,
111
+ filename,
112
+ sessionId,
113
+ directory: PLANS_DIR
114
+ });
115
+ }, EVENT_DEBOUNCE_DELAY);
116
+ }
117
+ });
118
+ console.log(` Watching for plan changes in: ${PLANS_DIR}`);
119
+ }
120
+
121
+ console.log('');
122
+ }
123
+
124
+ module.exports = {
125
+ setupWatchers,
126
+ setPlanCacheCallbacks
127
+ };
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Conversation Data Module - Conversation file loading and parsing
3
+ *
4
+ * Handles reading conversation files from the Claude projects directory.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const { PROJECTS_DIR } = require('../core/config');
11
+
12
+ /**
13
+ * Find conversation file for a session
14
+ * @param {string} sessionId - Session ID
15
+ * @returns {string|null} Path to conversation file or null
16
+ */
17
+ function findConversationFile(sessionId) {
18
+ // Search through all project directories for a matching conversation file
19
+ if (!fs.existsSync(PROJECTS_DIR)) return null;
20
+
21
+ const projectDirs = fs.readdirSync(PROJECTS_DIR);
22
+ for (const dir of projectDirs) {
23
+ const conversationPath = path.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
24
+ if (fs.existsSync(conversationPath)) {
25
+ return conversationPath;
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /**
32
+ * Load and parse conversation for a session
33
+ * @param {string} sessionId - Session ID
34
+ * @returns {Object} Conversation data with messages array
35
+ */
36
+ function loadConversation(sessionId) {
37
+ const filePath = findConversationFile(sessionId);
38
+ if (!filePath) return { messages: [], found: false };
39
+
40
+ try {
41
+ const fileData = fs.readFileSync(filePath, 'utf-8');
42
+ const lines = fileData.trim().split('\n');
43
+ const messages = [];
44
+
45
+ for (const line of lines) {
46
+ try {
47
+ const entry = JSON.parse(line);
48
+
49
+ // Skip non-message entries
50
+ if (!entry.type || !entry.message) continue;
51
+
52
+ const msgContent = entry.message.content;
53
+ const ts = entry.timestamp;
54
+ const id = entry.uuid;
55
+
56
+ if (entry.type === 'user') {
57
+ // User message: string or array of content blocks
58
+ if (typeof msgContent === 'string') {
59
+ messages.push({ id: `user-${id}`, type: 'user', content: msgContent, timestamp: ts });
60
+ } else if (Array.isArray(msgContent)) {
61
+ for (let i = 0; i < msgContent.length; i++) {
62
+ const block = msgContent[i];
63
+ if (block.type === 'tool_result') {
64
+ // Tool result response
65
+ const txt = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
66
+ messages.push({
67
+ id: `result-${block.tool_use_id}`,
68
+ type: 'tool_result',
69
+ content: txt.length > 200 ? txt.slice(0, 200) + '...' : txt,
70
+ timestamp: ts
71
+ });
72
+ } else if (block.type === 'text' && block.text) {
73
+ // User text
74
+ messages.push({ id: `user-${id}-${i}`, type: 'user', content: block.text, timestamp: ts });
75
+ }
76
+ }
77
+ }
78
+ } else if (entry.type === 'assistant') {
79
+ // Assistant message: array of content blocks
80
+ if (Array.isArray(msgContent)) {
81
+ for (let i = 0; i < msgContent.length; i++) {
82
+ const block = msgContent[i];
83
+ if (block.type === 'text' && block.text) {
84
+ messages.push({ id: `asst-${id}-${i}`, type: 'assistant', content: block.text, timestamp: ts });
85
+ } else if (block.type === 'tool_use') {
86
+ messages.push({
87
+ id: `tool-${block.id}`,
88
+ type: 'tool_use',
89
+ tool: block.name,
90
+ input: block.input,
91
+ timestamp: ts
92
+ });
93
+ }
94
+ }
95
+ }
96
+ }
97
+ } catch (e) {
98
+ // Skip malformed lines
99
+ }
100
+ }
101
+
102
+ return { messages, found: true, path: filePath };
103
+ } catch (e) {
104
+ return { messages: [], found: false, error: e.message };
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get the latest assistant text from a conversation
110
+ * @param {string} sessionId - Session ID
111
+ * @returns {string} Latest assistant text or empty string
112
+ */
113
+ function getLatestAssistantText(sessionId) {
114
+ const filePath = findConversationFile(sessionId);
115
+ if (!filePath) return '';
116
+
117
+ try {
118
+ const fileData = fs.readFileSync(filePath, 'utf-8');
119
+ const lines = fileData.trim().split('\n');
120
+
121
+ // Parse backwards to find the most recent assistant text
122
+ for (let i = lines.length - 1; i >= 0; i--) {
123
+ try {
124
+ const entry = JSON.parse(lines[i]);
125
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
126
+ for (const block of entry.message.content) {
127
+ if (block.type === 'text' && block.text) {
128
+ return block.text;
129
+ }
130
+ }
131
+ }
132
+ } catch (e) {
133
+ // Skip malformed lines
134
+ }
135
+ }
136
+ } catch (e) {
137
+ // File read error
138
+ }
139
+ return '';
140
+ }
141
+
142
+ /**
143
+ * Get session metadata (repo name and first prompt) from conversation
144
+ * @param {string} sessionId - Session ID
145
+ * @returns {Object} Metadata with repoName and firstPrompt
146
+ */
147
+ function getSessionMetadata(sessionId) {
148
+ const filePath = findConversationFile(sessionId);
149
+ if (!filePath) return { repoName: null, firstPrompt: null };
150
+
151
+ try {
152
+ // Extract repo name from path
153
+ const parts = filePath.split(path.sep);
154
+ const projectsIdx = parts.indexOf('projects');
155
+ const repoName = projectsIdx >= 0 && parts[projectsIdx + 1] ? parts[projectsIdx + 1] : null;
156
+
157
+ // Find first user prompt
158
+ const fileData = fs.readFileSync(filePath, 'utf-8');
159
+ const lines = fileData.trim().split('\n');
160
+ let firstPrompt = null;
161
+
162
+ for (const line of lines) {
163
+ try {
164
+ const entry = JSON.parse(line);
165
+ if (entry.type === 'user' && entry.message?.content) {
166
+ const content = entry.message.content;
167
+ if (typeof content === 'string') {
168
+ firstPrompt = content.substring(0, 200);
169
+ } else if (Array.isArray(content)) {
170
+ for (const block of content) {
171
+ if (block.type === 'text' && block.text) {
172
+ firstPrompt = block.text.substring(0, 200);
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ if (firstPrompt) break;
178
+ }
179
+ } catch (e) {
180
+ // Skip malformed lines
181
+ }
182
+ }
183
+
184
+ return { repoName, firstPrompt };
185
+ } catch (e) {
186
+ return { repoName: null, firstPrompt: null };
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ findConversationFile,
192
+ loadConversation,
193
+ getLatestAssistantText,
194
+ getSessionMetadata
195
+ };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Plans Data Module - Plan loading, updating, and session mapping
3
+ *
4
+ * Handles reading and writing plan files from the Claude plans directory.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ const { PLANS_DIR, PROJECTS_DIR } = require('../core/config');
11
+ const { eventBus, EventTypes } = require('../core/event-bus');
12
+
13
+ // Cache for plan slug -> sessionId mapping
14
+ const planSessionCache = new Map();
15
+ let planCacheBuilt = false;
16
+
17
+ /**
18
+ * Build the plan->session mapping by scanning JSONL files
19
+ */
20
+ function buildPlanSessionCache() {
21
+ console.log(' Building plan-session mapping cache...');
22
+ const startTime = Date.now();
23
+ planSessionCache.clear();
24
+
25
+ if (!fs.existsSync(PLANS_DIR) || !fs.existsSync(PROJECTS_DIR)) {
26
+ planCacheBuilt = true;
27
+ return;
28
+ }
29
+
30
+ // Get all plan slugs
31
+ const planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
32
+ const slugs = planFiles.map(f => f.replace('.md', ''));
33
+
34
+ // Search through project directories for slug references
35
+ const projectDirs = fs.readdirSync(PROJECTS_DIR);
36
+
37
+ for (const projectDir of projectDirs) {
38
+ const projectPath = path.join(PROJECTS_DIR, projectDir);
39
+ if (!fs.statSync(projectPath).isDirectory()) continue;
40
+
41
+ // Check main jsonl files
42
+ const jsonlFiles = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
43
+ for (const jsonlFile of jsonlFiles) {
44
+ try {
45
+ const content = fs.readFileSync(path.join(projectPath, jsonlFile), 'utf-8');
46
+ for (const slug of slugs) {
47
+ if (planSessionCache.has(slug)) continue;
48
+ if (content.includes(`"slug":"${slug}"`)) {
49
+ // Find the sessionId in this file
50
+ const lines = content.split('\n');
51
+ for (const line of lines) {
52
+ if (line.includes(`"slug":"${slug}"`)) {
53
+ try {
54
+ const entry = JSON.parse(line);
55
+ if (entry.sessionId) {
56
+ planSessionCache.set(slug, entry.sessionId);
57
+ break;
58
+ }
59
+ } catch (e) {}
60
+ }
61
+ }
62
+ }
63
+ }
64
+ } catch (e) {}
65
+ }
66
+
67
+ // Check subagents directory
68
+ const subagentsPath = path.join(projectPath, 'subagents');
69
+ if (fs.existsSync(subagentsPath)) {
70
+ const subagentFiles = fs.readdirSync(subagentsPath).filter(f => f.endsWith('.jsonl'));
71
+ for (const jsonlFile of subagentFiles) {
72
+ try {
73
+ const content = fs.readFileSync(path.join(subagentsPath, jsonlFile), 'utf-8');
74
+ for (const slug of slugs) {
75
+ if (planSessionCache.has(slug)) continue;
76
+ if (content.includes(`"slug":"${slug}"`)) {
77
+ const lines = content.split('\n');
78
+ for (const line of lines) {
79
+ if (line.includes(`"slug":"${slug}"`)) {
80
+ try {
81
+ const entry = JSON.parse(line);
82
+ if (entry.sessionId) {
83
+ planSessionCache.set(slug, entry.sessionId);
84
+ break;
85
+ }
86
+ } catch (e) {}
87
+ }
88
+ }
89
+ }
90
+ }
91
+ } catch (e) {}
92
+ }
93
+ }
94
+ }
95
+
96
+ planCacheBuilt = true;
97
+ console.log(` Plan cache built: ${planSessionCache.size} plans mapped in ${Date.now() - startTime}ms`);
98
+ }
99
+
100
+ /**
101
+ * Get session ID for a plan slug
102
+ * @param {string} slug - Plan slug
103
+ * @returns {string|null} Session ID or null
104
+ */
105
+ function getSessionForPlan(slug) {
106
+ if (!planCacheBuilt) buildPlanSessionCache();
107
+ return planSessionCache.get(slug) || null;
108
+ }
109
+
110
+ /**
111
+ * Load all plans with their session mappings
112
+ * @returns {Array} Array of plan objects
113
+ */
114
+ function loadAllPlans() {
115
+ if (!planCacheBuilt) buildPlanSessionCache();
116
+
117
+ const plans = [];
118
+
119
+ if (!fs.existsSync(PLANS_DIR)) {
120
+ return plans;
121
+ }
122
+
123
+ const planFiles = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
124
+
125
+ for (const file of planFiles) {
126
+ try {
127
+ const slug = file.replace('.md', '');
128
+ const filepath = path.join(PLANS_DIR, file);
129
+ const stats = fs.statSync(filepath);
130
+ const content = fs.readFileSync(filepath, 'utf-8');
131
+
132
+ // Extract title from first heading
133
+ const titleMatch = content.match(/^#\s+(.+?)(?:\n|$)/m);
134
+ const title = titleMatch ? titleMatch[1].replace(/^Plan:\s*/i, '') : slug;
135
+
136
+ plans.push({
137
+ slug,
138
+ title,
139
+ sessionId: planSessionCache.get(slug) || null,
140
+ filepath,
141
+ size: stats.size,
142
+ modified: stats.mtime.toISOString(),
143
+ preview: content.substring(0, 200).replace(/\n/g, ' ').trim()
144
+ });
145
+ } catch (e) {
146
+ console.error(`Error loading plan ${file}:`, e.message);
147
+ }
148
+ }
149
+
150
+ // Sort by modified date, newest first
151
+ plans.sort((a, b) => new Date(b.modified) - new Date(a.modified));
152
+
153
+ return plans;
154
+ }
155
+
156
+ /**
157
+ * Get plans for a specific session
158
+ * @param {string} sessionId - Session ID
159
+ * @returns {Array} Array of plans for the session
160
+ */
161
+ function getPlansForSession(sessionId) {
162
+ const allPlans = loadAllPlans();
163
+ return allPlans.filter(p => p.sessionId === sessionId);
164
+ }
165
+
166
+ /**
167
+ * Get a single plan by slug
168
+ * @param {string} slug - Plan slug
169
+ * @returns {Object} Plan object or error
170
+ */
171
+ function getPlan(slug) {
172
+ const filepath = path.join(PLANS_DIR, `${slug}.md`);
173
+
174
+ if (!fs.existsSync(filepath)) {
175
+ return { error: 'Plan not found' };
176
+ }
177
+
178
+ try {
179
+ const content = fs.readFileSync(filepath, 'utf-8');
180
+ const stats = fs.statSync(filepath);
181
+ const titleMatch = content.match(/^#\s+(.+?)(?:\n|$)/m);
182
+ const title = titleMatch ? titleMatch[1].replace(/^Plan:\s*/i, '') : slug;
183
+
184
+ return {
185
+ slug,
186
+ title,
187
+ sessionId: getSessionForPlan(slug),
188
+ content,
189
+ modified: stats.mtime.toISOString()
190
+ };
191
+ } catch (e) {
192
+ return { error: e.message };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Update a plan's content
198
+ * @param {string} slug - Plan slug
199
+ * @param {string} content - New content
200
+ * @returns {Object} Result with success flag or error
201
+ */
202
+ function updatePlan(slug, content) {
203
+ const filepath = path.join(PLANS_DIR, `${slug}.md`);
204
+
205
+ if (!fs.existsSync(filepath)) {
206
+ return { error: 'Plan not found' };
207
+ }
208
+
209
+ try {
210
+ fs.writeFileSync(filepath, content);
211
+ const sessionId = getSessionForPlan(slug);
212
+
213
+ eventBus.emit(EventTypes.PLAN_UPDATED, { slug, sessionId });
214
+
215
+ return { success: true, slug };
216
+ } catch (e) {
217
+ return { error: e.message };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Check if plan session cache has been built
223
+ * @returns {boolean}
224
+ */
225
+ function isCacheBuilt() {
226
+ return planCacheBuilt;
227
+ }
228
+
229
+ /**
230
+ * Check if a plan slug is in the cache
231
+ * @param {string} slug - Plan slug
232
+ * @returns {boolean}
233
+ */
234
+ function hasPlanInCache(slug) {
235
+ return planSessionCache.has(slug);
236
+ }
237
+
238
+ module.exports = {
239
+ buildPlanSessionCache,
240
+ getSessionForPlan,
241
+ loadAllPlans,
242
+ getPlansForSession,
243
+ getPlan,
244
+ updatePlan,
245
+ isCacheBuilt,
246
+ hasPlanInCache
247
+ };