neohive 6.0.2 → 6.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.
@@ -0,0 +1,291 @@
1
+ 'use strict';
2
+
3
+ // GitHub Projects v2 sync — mirrors neohive tasks to a GitHub Project board.
4
+ // Uses GraphQL API with draft issues. One-way sync (neohive → GitHub).
5
+ //
6
+ // Configuration (any of these sources):
7
+ // 1. GITHUB_TOKEN + GITHUB_PROJECT_ID env vars
8
+ // 2. .neohive/config.json → { github: { token, project_id, org, status_field_id, status_options } }
9
+ //
10
+ // When unconfigured, all functions are no-ops (graceful degradation).
11
+
12
+ const https = require('https');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { DATA_DIR } = require('./config');
16
+ const log = require('./logger');
17
+
18
+ // --- Config ---
19
+
20
+ function getGitHubConfig() {
21
+ const config = { token: null, project_id: null, org: null, status_field_id: null, status_options: {} };
22
+
23
+ // Env vars take priority
24
+ if (process.env.GITHUB_TOKEN) config.token = process.env.GITHUB_TOKEN;
25
+ if (process.env.GITHUB_PROJECT_ID) config.project_id = process.env.GITHUB_PROJECT_ID;
26
+
27
+ // Fall back to .neohive/config.json
28
+ const configFile = path.join(DATA_DIR, 'config.json');
29
+ if (fs.existsSync(configFile)) {
30
+ try {
31
+ const fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf8'));
32
+ if (fileConfig.github) {
33
+ if (!config.token && fileConfig.github.token) config.token = fileConfig.github.token;
34
+ if (!config.project_id && fileConfig.github.project_id) config.project_id = fileConfig.github.project_id;
35
+ if (fileConfig.github.org) config.org = fileConfig.github.org;
36
+ if (fileConfig.github.status_field_id) config.status_field_id = fileConfig.github.status_field_id;
37
+ if (fileConfig.github.status_options) config.status_options = fileConfig.github.status_options;
38
+ }
39
+ } catch (e) { log.debug('github-sync: failed to read config:', e.message); }
40
+ }
41
+
42
+ return config;
43
+ }
44
+
45
+ function isConfigured() {
46
+ const config = getGitHubConfig();
47
+ return !!(config.token && config.project_id);
48
+ }
49
+
50
+ // --- GraphQL client ---
51
+
52
+ function graphql(token, query, variables) {
53
+ return new Promise((resolve, reject) => {
54
+ const data = JSON.stringify({ query, variables: variables || {} });
55
+ const req = https.request({
56
+ hostname: 'api.github.com',
57
+ path: '/graphql',
58
+ method: 'POST',
59
+ headers: {
60
+ 'Authorization': `Bearer ${token}`,
61
+ 'Content-Type': 'application/json',
62
+ 'User-Agent': 'neohive-github-sync',
63
+ 'Content-Length': Buffer.byteLength(data),
64
+ },
65
+ }, (res) => {
66
+ let body = '';
67
+ res.on('data', c => body += c);
68
+ res.on('end', () => {
69
+ try {
70
+ const parsed = JSON.parse(body);
71
+ if (parsed.errors) {
72
+ reject(new Error(parsed.errors.map(e => e.message).join('; ')));
73
+ } else {
74
+ resolve(parsed);
75
+ }
76
+ } catch (e) {
77
+ reject(new Error('Invalid JSON response from GitHub API'));
78
+ }
79
+ });
80
+ });
81
+ req.on('error', reject);
82
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('GitHub API timeout')); });
83
+ req.write(data);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ // --- Sync mapping file ---
89
+ // Tracks neohive task ID → GitHub project item ID mapping
90
+
91
+ const SYNC_MAP_FILE = path.join(DATA_DIR, 'github-sync-map.json');
92
+
93
+ function getSyncMap() {
94
+ if (!fs.existsSync(SYNC_MAP_FILE)) return {};
95
+ try { return JSON.parse(fs.readFileSync(SYNC_MAP_FILE, 'utf8')); } catch { return {}; }
96
+ }
97
+
98
+ function saveSyncMap(map) {
99
+ fs.writeFileSync(SYNC_MAP_FILE, JSON.stringify(map, null, 2));
100
+ }
101
+
102
+ // --- Status mapping ---
103
+
104
+ const DEFAULT_STATUS_MAP = {
105
+ pending: 'Todo',
106
+ in_progress: 'In Progress',
107
+ in_review: 'In Review',
108
+ done: 'Done',
109
+ blocked: 'Blocked',
110
+ blocked_permanent: 'Blocked',
111
+ };
112
+
113
+ // --- Core sync functions ---
114
+
115
+ /**
116
+ * Sync a single task to GitHub Projects.
117
+ * Creates a draft issue if new, updates status field if existing.
118
+ * Non-blocking — errors are logged but not thrown.
119
+ */
120
+ async function syncTask(task) {
121
+ if (!isConfigured()) return null;
122
+
123
+ const config = getGitHubConfig();
124
+ const syncMap = getSyncMap();
125
+
126
+ try {
127
+ const itemId = syncMap[task.id];
128
+
129
+ if (!itemId) {
130
+ // New task — create draft issue
131
+ const body = [
132
+ task.description || '',
133
+ '',
134
+ `---`,
135
+ `Neohive ID: \`${task.id}\``,
136
+ task.assignee ? `Assignee: ${task.assignee}` : '',
137
+ `Status: ${task.status}`,
138
+ `Created by: ${task.created_by || 'unknown'}`,
139
+ ].filter(Boolean).join('\n');
140
+
141
+ const result = await graphql(config.token,
142
+ `mutation($projectId: ID!, $title: String!, $body: String) {
143
+ addProjectV2DraftIssue(input: {projectId: $projectId, title: $title, body: $body}) {
144
+ projectItem { id }
145
+ }
146
+ }`,
147
+ { projectId: config.project_id, title: task.title, body }
148
+ );
149
+
150
+ const newItemId = result.data.addProjectV2DraftIssue.projectItem.id;
151
+ syncMap[task.id] = newItemId;
152
+ saveSyncMap(syncMap);
153
+
154
+ log.info(`github-sync: created draft issue for task "${task.title}" → ${newItemId}`);
155
+
156
+ // Update status field if configured
157
+ if (config.status_field_id && config.status_options[task.status]) {
158
+ await updateStatusField(config, newItemId, task.status);
159
+ }
160
+
161
+ return { action: 'created', item_id: newItemId };
162
+ } else {
163
+ // Existing task — update status field
164
+ if (config.status_field_id && config.status_options[task.status]) {
165
+ await updateStatusField(config, itemId, task.status);
166
+ log.info(`github-sync: updated status for "${task.title}" → ${task.status}`);
167
+ return { action: 'updated', item_id: itemId, status: task.status };
168
+ }
169
+ return { action: 'no_update', item_id: itemId, reason: 'status field not configured' };
170
+ }
171
+ } catch (e) {
172
+ log.warn(`github-sync: failed to sync task "${task.title}":`, e.message);
173
+ return { action: 'error', error: e.message };
174
+ }
175
+ }
176
+
177
+ async function updateStatusField(config, itemId, status) {
178
+ const optionId = config.status_options[status];
179
+ if (!optionId) return;
180
+
181
+ await graphql(config.token,
182
+ `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
183
+ updateProjectV2ItemFieldValue(input: {
184
+ projectId: $projectId, itemId: $itemId,
185
+ fieldId: $fieldId, value: {singleSelectOptionId: $optionId}
186
+ }) { projectV2Item { id } }
187
+ }`,
188
+ { projectId: config.project_id, itemId, fieldId: config.status_field_id, optionId }
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Sync all tasks from tasks.json to GitHub Projects.
194
+ * Creates missing items, updates status for existing ones.
195
+ */
196
+ async function syncAllTasks() {
197
+ if (!isConfigured()) return { error: 'GitHub sync not configured. Set GITHUB_TOKEN and GITHUB_PROJECT_ID.' };
198
+
199
+ const tasksFile = path.join(DATA_DIR, 'tasks.json');
200
+ if (!fs.existsSync(tasksFile)) return { error: 'No tasks.json found' };
201
+
202
+ let tasks;
203
+ try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch { return { error: 'Invalid tasks.json' }; }
204
+
205
+ const results = { created: 0, updated: 0, errors: 0, skipped: 0 };
206
+ for (const task of tasks) {
207
+ const result = await syncTask(task);
208
+ if (!result) { results.skipped++; continue; }
209
+ if (result.action === 'created') results.created++;
210
+ else if (result.action === 'updated') results.updated++;
211
+ else if (result.action === 'error') results.errors++;
212
+ else results.skipped++;
213
+ }
214
+
215
+ return { success: true, ...results, total: tasks.length };
216
+ }
217
+
218
+ /**
219
+ * Discover project fields — helper for initial setup.
220
+ * Returns field IDs and single-select options for status mapping.
221
+ */
222
+ async function discoverFields() {
223
+ if (!isConfigured()) return { error: 'GitHub sync not configured.' };
224
+
225
+ const config = getGitHubConfig();
226
+ const result = await graphql(config.token,
227
+ `query($projectId: ID!) {
228
+ node(id: $projectId) {
229
+ ... on ProjectV2 {
230
+ title
231
+ fields(first: 20) {
232
+ nodes {
233
+ ... on ProjectV2FieldCommon { id name }
234
+ ... on ProjectV2SingleSelectField {
235
+ id name
236
+ options { id name }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }`,
243
+ { projectId: config.project_id }
244
+ );
245
+
246
+ const project = result.data.node;
247
+ return {
248
+ project_title: project.title,
249
+ fields: project.fields.nodes.map(f => ({
250
+ id: f.id,
251
+ name: f.name,
252
+ ...(f.options && { options: f.options }),
253
+ })),
254
+ hint: 'Find the Status field, copy its id to github.status_field_id in .neohive/config.json. Map each status option id to github.status_options: { "pending": "OPTION_ID", "in_progress": "OPTION_ID", ... }',
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Get sync status — how many tasks are synced, unsynced, stale.
260
+ */
261
+ function getSyncStatus() {
262
+ const configured = isConfigured();
263
+ const syncMap = getSyncMap();
264
+ const tasksFile = path.join(DATA_DIR, 'tasks.json');
265
+ let tasks = [];
266
+ if (fs.existsSync(tasksFile)) {
267
+ try { tasks = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); } catch {}
268
+ }
269
+
270
+ const synced = tasks.filter(t => syncMap[t.id]).length;
271
+ const unsynced = tasks.filter(t => !syncMap[t.id]).length;
272
+
273
+ return {
274
+ configured,
275
+ total_tasks: tasks.length,
276
+ synced,
277
+ unsynced,
278
+ sync_map_entries: Object.keys(syncMap).length,
279
+ status_mapping: DEFAULT_STATUS_MAP,
280
+ };
281
+ }
282
+
283
+ module.exports = {
284
+ isConfigured,
285
+ getGitHubConfig,
286
+ syncTask,
287
+ syncAllTasks,
288
+ discoverFields,
289
+ getSyncStatus,
290
+ DEFAULT_STATUS_MAP,
291
+ };
package/lib/hooks.js ADDED
@@ -0,0 +1,173 @@
1
+ 'use strict';
2
+
3
+ // Event hooks system — agents subscribe to events and get auto-notified via system messages.
4
+ // Hooks registry stored in .neohive/hooks.json.
5
+ //
6
+ // Supported events:
7
+ // task.status_changed — fires when any task changes status (filter by status, assignee)
8
+ // agent.idle — fires when an agent is idle for >2 min
9
+ // agent.stuck — fires when an agent is unresponsive for >10 min
10
+ // workflow.advanced — fires when a workflow step completes
11
+ // review.submitted — fires when a review is submitted
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { DATA_DIR } = require('./config');
16
+ const log = require('./logger');
17
+
18
+ const HOOKS_FILE = path.join(DATA_DIR, 'hooks.json');
19
+ const VALID_EVENTS = ['task.status_changed', 'agent.idle', 'agent.stuck', 'workflow.advanced', 'review.submitted', 'rule.changed'];
20
+
21
+ // --- Registry ---
22
+
23
+ function getHooks() {
24
+ if (!fs.existsSync(HOOKS_FILE)) return [];
25
+ try { return JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf8')); } catch { return []; }
26
+ }
27
+
28
+ function saveHooks(hooks) {
29
+ fs.writeFileSync(HOOKS_FILE, JSON.stringify(hooks, null, 2));
30
+ }
31
+
32
+ /**
33
+ * Subscribe an agent to an event.
34
+ * @param {string} agent - agent name
35
+ * @param {string} event - event name (e.g. 'task.status_changed')
36
+ * @param {object} filter - optional filter (e.g. { status: 'done', assignee: 'Nick' })
37
+ * @returns {{ success, hook_id } | { error }}
38
+ */
39
+ function subscribe(agent, event, filter) {
40
+ if (!VALID_EVENTS.includes(event)) {
41
+ return { error: `Invalid event. Must be one of: ${VALID_EVENTS.join(', ')}` };
42
+ }
43
+
44
+ const hooks = getHooks();
45
+
46
+ // Prevent duplicate subscriptions
47
+ const existing = hooks.find(h => h.agent === agent && h.event === event &&
48
+ JSON.stringify(h.filter || {}) === JSON.stringify(filter || {}));
49
+ if (existing) {
50
+ return { success: true, hook_id: existing.id, message: 'Already subscribed to this event.' };
51
+ }
52
+
53
+ if (hooks.length >= 500) return { error: 'Hook limit reached (max 500).' };
54
+
55
+ const hook = {
56
+ id: 'hook_' + Date.now().toString(36) + Math.random().toString(36).substring(2, 6),
57
+ agent,
58
+ event,
59
+ filter: filter || {},
60
+ created_at: new Date().toISOString(),
61
+ };
62
+ hooks.push(hook);
63
+ saveHooks(hooks);
64
+
65
+ return { success: true, hook_id: hook.id, event, filter: hook.filter, message: `Subscribed to ${event}. You will receive system messages when this event fires.` };
66
+ }
67
+
68
+ /**
69
+ * Unsubscribe from a hook by ID, or all hooks for an agent.
70
+ */
71
+ function unsubscribe(agent, hookId) {
72
+ const hooks = getHooks();
73
+ if (hookId) {
74
+ const idx = hooks.findIndex(h => h.id === hookId && h.agent === agent);
75
+ if (idx === -1) return { error: 'Hook not found or not owned by you.' };
76
+ hooks.splice(idx, 1);
77
+ saveHooks(hooks);
78
+ return { success: true, message: 'Unsubscribed.' };
79
+ }
80
+ // Unsubscribe all for this agent
81
+ const before = hooks.length;
82
+ const filtered = hooks.filter(h => h.agent !== agent);
83
+ saveHooks(filtered);
84
+ return { success: true, removed: before - filtered.length, message: `Removed ${before - filtered.length} hook(s).` };
85
+ }
86
+
87
+ /**
88
+ * List hooks for an agent (or all if agent is null).
89
+ */
90
+ function listHooks(agent) {
91
+ const hooks = getHooks();
92
+ const filtered = agent ? hooks.filter(h => h.agent === agent) : hooks;
93
+ return {
94
+ count: filtered.length,
95
+ hooks: filtered.map(h => ({ id: h.id, agent: h.agent, event: h.event, filter: h.filter, created_at: h.created_at })),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Emit an event — checks all subscriptions and returns list of agents to notify.
101
+ * Caller (fireEvent in server.js) is responsible for actually sending the messages.
102
+ *
103
+ * @param {string} event - event name
104
+ * @param {object} data - event data (varies by event type)
105
+ * @returns {Array<{ agent, message }>} - agents to notify with formatted messages
106
+ */
107
+ function emit(event, data) {
108
+ const hooks = getHooks();
109
+ const subscribers = hooks.filter(h => h.event === event);
110
+ if (subscribers.length === 0) return [];
111
+
112
+ const notifications = [];
113
+
114
+ for (const hook of subscribers) {
115
+ // Apply filters
116
+ if (!matchesFilter(hook.filter, data)) continue;
117
+ // Don't notify the agent that triggered the event
118
+ if (data._source_agent && data._source_agent === hook.agent) continue;
119
+
120
+ const message = formatEventMessage(event, data);
121
+ if (message) {
122
+ notifications.push({ agent: hook.agent, message });
123
+ }
124
+ }
125
+
126
+ return notifications;
127
+ }
128
+
129
+ function matchesFilter(filter, data) {
130
+ if (!filter || Object.keys(filter).length === 0) return true;
131
+ for (const [key, value] of Object.entries(filter)) {
132
+ if (data[key] !== undefined && data[key] !== value) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ function formatEventMessage(event, data) {
138
+ switch (event) {
139
+ case 'task.status_changed':
140
+ return `[HOOK] Task "${data.title || data.task_id}" status → ${data.status}` +
141
+ (data.assignee ? ` (assignee: ${data.assignee})` : '') +
142
+ (data.changed_by ? ` by ${data.changed_by}` : '');
143
+ case 'agent.idle':
144
+ return `[HOOK] Agent ${data.agent} has been idle for ${Math.round((data.idle_seconds || 0) / 60)} minutes.`;
145
+ case 'agent.stuck':
146
+ return `[HOOK] Agent ${data.agent} is unresponsive (${Math.round((data.idle_seconds || 0) / 60)}+ min). ${data.tasks_reassigned || 0} task(s) may need reassignment.`;
147
+ case 'workflow.advanced':
148
+ return `[HOOK] Workflow "${data.workflow_name}" step ${data.step_id} completed` +
149
+ (data.progress ? ` (${data.progress})` : '') +
150
+ (data.next_assignee ? `. Next: ${data.next_assignee}` : '');
151
+ case 'review.submitted':
152
+ return `[HOOK] Review "${data.file}" ${data.status} by ${data.reviewer}` +
153
+ (data.feedback ? `: ${data.feedback.substring(0, 100)}` : '');
154
+ case 'rule.changed': {
155
+ const action = data.action || 'changed';
156
+ const scope = data.scope_role || data.scope_provider || data.scope_agent
157
+ ? ` (scoped to: ${[data.scope_role, data.scope_provider, data.scope_agent].filter(Boolean).join(', ')})`
158
+ : '';
159
+ return `[HOOK] Rule ${action} by ${data.changed_by || 'unknown'}: "${(data.text || data.rule_id || '').substring(0, 100)}"${scope}. Call get_briefing() to load updated rules.`;
160
+ }
161
+ default:
162
+ return `[HOOK] ${event}: ${JSON.stringify(data).substring(0, 200)}`;
163
+ }
164
+ }
165
+
166
+ module.exports = {
167
+ VALID_EVENTS,
168
+ subscribe,
169
+ unsubscribe,
170
+ listHooks,
171
+ emit,
172
+ getHooks,
173
+ };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const WORKING_STALE_MS = 10000;
7
+
8
+ /**
9
+ * Read IDE liveness hint written by vscode-extension (ide-activity-{agent}.json).
10
+ * v2 fields: focused, ide_idle, extension_online, working, shell_working, last_tool_call, timestamp.
11
+ * @param {string} dataDir - .neohive directory
12
+ * @param {string} agentName
13
+ */
14
+ function readIdeActivity(dataDir, agentName) {
15
+ if (!dataDir || !agentName || !/^[a-zA-Z0-9_-]{1,20}$/.test(agentName)) return null;
16
+ const f = path.join(dataDir, `ide-activity-${agentName}.json`);
17
+ if (!fs.existsSync(f)) return null;
18
+ try {
19
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
20
+ return {
21
+ focused: j.focused === true,
22
+ ide_idle: j.ide_idle === true,
23
+ extension_online: j.extension_online !== false,
24
+ working: j.working === true,
25
+ shell_working: j.shell_working === true,
26
+ last_tool_call: typeof j.last_tool_call === 'string' ? j.last_tool_call : null,
27
+ timestamp: typeof j.timestamp === 'string' ? j.timestamp : null,
28
+ };
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Read last_stdin_activity from heartbeat-{agent}.json (written by server.js stdin tracker).
36
+ * @param {string} dataDir
37
+ * @param {string} agentName
38
+ * @returns {string|null} ISO timestamp or null
39
+ */
40
+ function readStdinActivity(dataDir, agentName) {
41
+ if (!dataDir || !agentName) return null;
42
+ const f = path.join(dataDir, `heartbeat-${agentName}.json`);
43
+ try {
44
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
45
+ return typeof j.last_stdin_activity === 'string' ? j.last_stdin_activity : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Layer IDE extension + stdin hints on list_agents / apiAgents entries (mutates entry).
53
+ *
54
+ * Priority order:
55
+ * 1. extension_online: false → offline
56
+ * 2. listening_since set (from agent) → listening (never override)
57
+ * 3. working OR shell_working OR → working
58
+ * stdin activity < 10s OR
59
+ * last_tool_call < 10s
60
+ * 4. ide_idle: true → idle
61
+ * 5. default → keep existing status
62
+ *
63
+ * @param {object} entry - built agent row
64
+ * @param {ReturnType<typeof readIdeActivity>} ide
65
+ * @param {object} [opts]
66
+ * @param {string} [opts.dataDir] - .neohive dir for reading heartbeat stdin
67
+ * @param {string} [opts.agentName]
68
+ */
69
+ function applyIdeActivityHint(entry, ide, opts) {
70
+ if (!ide || !entry) return;
71
+
72
+ entry.ide_activity = {
73
+ focused: ide.focused,
74
+ ide_idle: ide.ide_idle,
75
+ extension_online: ide.extension_online,
76
+ working: ide.working,
77
+ shell_working: ide.shell_working,
78
+ last_tool_call: ide.last_tool_call,
79
+ timestamp: ide.timestamp,
80
+ };
81
+
82
+ // Priority 1: extension offline → agent offline
83
+ if (ide.extension_online === false) {
84
+ entry.alive = false;
85
+ entry.status = 'offline';
86
+ entry.idle_seconds = null;
87
+ entry.is_listening = false;
88
+ return;
89
+ }
90
+
91
+ // Priority 2: listening (set by server via listen()) → never override
92
+ if (entry.is_listening) return;
93
+
94
+ // Check stdin freshness from heartbeat
95
+ let stdinRecent = false;
96
+ if (opts && opts.dataDir && opts.agentName) {
97
+ const stdinTs = readStdinActivity(opts.dataDir, opts.agentName);
98
+ if (stdinTs) {
99
+ stdinRecent = (Date.now() - new Date(stdinTs).getTime()) < WORKING_STALE_MS;
100
+ }
101
+ }
102
+
103
+ // Check tool call freshness
104
+ let toolCallRecent = false;
105
+ if (ide.last_tool_call) {
106
+ toolCallRecent = (Date.now() - new Date(ide.last_tool_call).getTime()) < WORKING_STALE_MS;
107
+ }
108
+
109
+ // Priority 3: active work signals → working
110
+ if ((ide.working || ide.shell_working || stdinRecent || toolCallRecent) && entry.alive) {
111
+ entry.status = 'working';
112
+ return;
113
+ }
114
+
115
+ // Priority 4: IDE idle → idle
116
+ if (ide.ide_idle && entry.alive) {
117
+ entry.status = 'idle';
118
+ }
119
+ }
120
+
121
+ module.exports = { readIdeActivity, readStdinActivity, applyIdeActivityHint };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ function normalizeNeohiveDataDirString(raw, workspaceRoot) {
8
+ if (raw == null || typeof raw !== 'string') return null;
9
+ let d = raw.trim();
10
+ if (!d) return null;
11
+ d = d.replace(/\$\{workspaceFolder\}/gi, workspaceRoot);
12
+ return path.isAbsolute(d) ? path.resolve(d) : path.resolve(workspaceRoot, d);
13
+ }
14
+
15
+ /** Same candidate files as dashboard.js readNeohiveDataDirFromMcpConfigs */
16
+ function readNeohiveDataDirFromMcpConfigs(projectRoot) {
17
+ const candidates = [
18
+ path.join(projectRoot, '.cursor', 'mcp.json'),
19
+ path.join(projectRoot, '.mcp.json'),
20
+ path.join(projectRoot, '.gemini', 'settings.json'),
21
+ ];
22
+ for (const filePath of candidates) {
23
+ if (!fs.existsSync(filePath)) continue;
24
+ try {
25
+ const j = JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ const nh = j.mcpServers && j.mcpServers.neohive;
27
+ const raw = nh && nh.env && nh.env.NEOHIVE_DATA_DIR;
28
+ const out = normalizeNeohiveDataDirString(raw, projectRoot);
29
+ if (out) return out;
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function findDataDirByWalkingUpFrom(startDir) {
38
+ let dir = path.resolve(startDir);
39
+ const root = path.parse(dir).root;
40
+ for (let depth = 0; depth < 32 && dir !== root; depth++) {
41
+ const fromMcp = readNeohiveDataDirFromMcpConfigs(dir);
42
+ if (fromMcp) return fromMcp;
43
+ dir = path.dirname(dir);
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** User-level Cursor MCP may define neohive with an absolute data dir */
49
+ function readNeohiveDirFromUserCursorMcp() {
50
+ const userMcp = path.join(os.homedir(), '.cursor', 'mcp.json');
51
+ if (!fs.existsSync(userMcp)) return null;
52
+ try {
53
+ const j = JSON.parse(fs.readFileSync(userMcp, 'utf8'));
54
+ const nh = j.mcpServers && j.mcpServers.neohive;
55
+ const raw = nh && nh.env && nh.env.NEOHIVE_DATA_DIR;
56
+ if (raw == null || typeof raw !== 'string') return null;
57
+ const d = raw.trim();
58
+ if (!d || /\$\{workspaceFolder\}/i.test(d)) return null;
59
+ return path.resolve(d);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Neohive data directory for the MCP / CLI process.
67
+ * Cursor often spawns MCP with cwd=user home and omits NEOHIVE_DATA_DIR in the child env.
68
+ * We mirror dashboard resolution: walk ancestors of cwd for MCP configs, then package sibling,
69
+ * then ~/.cursor/mcp.json with an absolute path, then cwd/.neohive.
70
+ *
71
+ * @param {string} serverJsDir - __dirname of server.js (the agent-bridge folder)
72
+ */
73
+ function resolveDataDirForServer(serverJsDir) {
74
+ const raw = process.env.NEOHIVE_DATA_DIR || process.env.NEOHIVE_DATA;
75
+ if (raw != null && String(raw).trim() !== '') {
76
+ return path.resolve(String(raw).trim());
77
+ }
78
+
79
+ const fromWalk = findDataDirByWalkingUpFrom(process.cwd());
80
+ if (fromWalk) return fromWalk;
81
+
82
+ const parent = path.join(serverJsDir, '..');
83
+ if (fs.existsSync(path.join(parent, '.cursor', 'mcp.json'))) {
84
+ return path.join(parent, '.neohive');
85
+ }
86
+ if (fs.existsSync(path.join(serverJsDir, '.cursor', 'mcp.json'))) {
87
+ return path.join(serverJsDir, '.neohive');
88
+ }
89
+
90
+ const fromUser = readNeohiveDirFromUserCursorMcp();
91
+ if (fromUser) return fromUser;
92
+
93
+ return path.join(process.cwd(), '.neohive');
94
+ }
95
+
96
+ module.exports = { resolveDataDirForServer };