claudehq 1.0.1 → 1.0.3

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
+ };