claudehq 1.0.1 → 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.
- package/bin/cli.js +2 -2
- package/lib/core/claude-events.js +308 -0
- package/lib/core/config.js +79 -0
- package/lib/core/event-bus.js +127 -0
- package/lib/core/sse.js +96 -0
- package/lib/core/watchers.js +127 -0
- package/lib/data/conversation.js +195 -0
- package/lib/data/plans.js +247 -0
- package/lib/data/tasks.js +263 -0
- package/lib/data/todos.js +205 -0
- package/lib/index.js +400 -0
- package/lib/routes/api.js +611 -0
- package/lib/server.js +778 -79
- package/lib/sessions/manager.js +870 -0
- package/package.json +10 -4
- package/public/index.html +6765 -0
|
@@ -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
|
+
};
|