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.
package/lib/audit.js ADDED
@@ -0,0 +1,417 @@
1
+ 'use strict';
2
+
3
+ // Audit logging module for comprehensive MCP tool call tracking
4
+ // Based on research in kb:audit-logging-research
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+
10
+ // Configuration
11
+ const AUDIT_MAX_SIZE = parseInt(process.env.NEOHIVE_AUDIT_MAX_SIZE) || 10485760; // 10MB
12
+ const AUDIT_RETENTION_DAYS = parseInt(process.env.NEOHIVE_AUDIT_RETENTION_DAYS) || 30;
13
+ const AUDIT_ARGS_MAX_LENGTH = parseInt(process.env.NEOHIVE_AUDIT_ARGS_MAX_LENGTH) || 500;
14
+ const AUDIT_RESULT_MAX_LENGTH = parseInt(process.env.NEOHIVE_AUDIT_RESULT_MAX_LENGTH) || 1000;
15
+ const AUDIT_LEVEL = process.env.NEOHIVE_AUDIT_LEVEL || 'standard';
16
+
17
+ // Tool categories for classification
18
+ const TOOL_CATEGORIES = {
19
+ // Agent Lifecycle
20
+ 'register': 'agent-lifecycle',
21
+ 'list_agents': 'agent-lifecycle',
22
+
23
+ // Messaging
24
+ 'send_message': 'messaging',
25
+ 'broadcast': 'messaging',
26
+ 'listen': 'messaging',
27
+ 'listen_codex': 'messaging',
28
+ 'wait_for_reply': 'messaging',
29
+ 'check_messages': 'messaging',
30
+ 'consume_messages': 'messaging',
31
+ 'get_notifications': 'messaging',
32
+ 'ack_message': 'messaging',
33
+ 'get_history': 'messaging',
34
+ 'search_messages': 'messaging',
35
+
36
+ // Task Management
37
+ 'create_task': 'tasks',
38
+ 'update_task': 'tasks',
39
+ 'list_tasks': 'tasks',
40
+
41
+ // Workflows
42
+ 'create_workflow': 'workflows',
43
+ 'advance_workflow': 'workflows',
44
+ 'workflow_status': 'workflows',
45
+
46
+ // Knowledge Base
47
+ 'kb_write': 'knowledge',
48
+ 'kb_read': 'knowledge',
49
+ 'kb_list': 'knowledge',
50
+ 'log_decision': 'knowledge',
51
+ 'get_decisions': 'knowledge',
52
+ 'get_briefing': 'knowledge',
53
+ 'get_compressed_history': 'knowledge',
54
+ 'update_progress': 'knowledge',
55
+ 'get_progress': 'knowledge',
56
+ 'get_summary': 'knowledge',
57
+
58
+ // Governance
59
+ 'call_vote': 'governance',
60
+ 'cast_vote': 'governance',
61
+ 'vote_status': 'governance',
62
+ 'request_review': 'governance',
63
+ 'submit_review': 'governance',
64
+ 'request_push_approval': 'governance',
65
+ 'ack_push': 'governance',
66
+ 'add_rule': 'governance',
67
+ 'remove_rule': 'governance',
68
+ 'toggle_rule': 'governance',
69
+ 'log_violation': 'governance',
70
+
71
+ // File Safety
72
+ 'lock_file': 'safety',
73
+ 'unlock_file': 'safety',
74
+ 'declare_dependency': 'safety',
75
+ 'check_dependencies': 'safety',
76
+
77
+ // System
78
+ 'workspace_write': 'system',
79
+ 'workspace_read': 'system',
80
+ 'workspace_list': 'system',
81
+ 'update_profile': 'system',
82
+ 'list_branches': 'system',
83
+ 'fork_conversation': 'system',
84
+ 'switch_branch': 'system',
85
+ 'set_conversation_mode': 'system',
86
+
87
+ // Channels
88
+ 'join_channel': 'channels',
89
+ 'leave_channel': 'channels',
90
+ 'list_channels': 'channels',
91
+
92
+ // Autonomy
93
+ 'get_work': 'autonomy',
94
+ 'verify_and_advance': 'autonomy',
95
+ 'retry_with_improvement': 'autonomy',
96
+ 'start_plan': 'autonomy',
97
+ 'distribute_prompt': 'autonomy',
98
+ 'claim_manager': 'autonomy',
99
+ 'yield_floor': 'autonomy',
100
+ 'set_phase': 'autonomy',
101
+
102
+ // Utilities
103
+ 'share_file': 'utilities',
104
+ 'handoff': 'utilities',
105
+ 'reset': 'utilities',
106
+ 'get_guide': 'utilities',
107
+ 'get_reputation': 'utilities',
108
+ 'listen_group': 'utilities'
109
+ };
110
+
111
+ // Audit levels configuration
112
+ const AUDIT_LEVELS = {
113
+ minimal: ['governance'], // Only governance tools (current behavior)
114
+ standard: ['messaging', 'tasks', 'workflows', 'governance', 'safety'], // Core tools
115
+ full: Object.keys(TOOL_CATEGORIES) // All categories
116
+ };
117
+
118
+ let auditFile = null;
119
+ let pendingWrites = [];
120
+ let writeTimer = null;
121
+
122
+ function init(dataDir) {
123
+ auditFile = path.join(dataDir, 'audit_log.jsonl');
124
+
125
+ // Ensure audit file exists
126
+ if (!fs.existsSync(auditFile)) {
127
+ try {
128
+ fs.writeFileSync(auditFile, '', 'utf8');
129
+ } catch (e) {
130
+ console.error('[audit] Failed to create audit log:', e.message);
131
+ }
132
+ }
133
+
134
+ // Start cleanup timer for old archives
135
+ setInterval(cleanupOldArchives, 24 * 60 * 60 * 1000); // Daily cleanup
136
+ }
137
+
138
+ function shouldLogTool(toolName) {
139
+ if (AUDIT_LEVEL === 'disabled') return false;
140
+
141
+ const category = TOOL_CATEGORIES[toolName] || 'unknown';
142
+ const allowedCategories = AUDIT_LEVELS[AUDIT_LEVEL] || AUDIT_LEVELS.standard;
143
+
144
+ return allowedCategories.includes(category);
145
+ }
146
+
147
+ function truncateContent(content, maxLength) {
148
+ if (typeof content !== 'string') {
149
+ content = JSON.stringify(content);
150
+ }
151
+
152
+ if (content.length <= maxLength) return content;
153
+ return content.substring(0, maxLength - 3) + '...';
154
+ }
155
+
156
+ function redactSensitiveArgs(toolName, args) {
157
+ if (!args || typeof args !== 'object') return args;
158
+
159
+ const redacted = { ...args };
160
+
161
+ // Redact message content for messaging tools
162
+ if (TOOL_CATEGORIES[toolName] === 'messaging' && redacted.content) {
163
+ redacted.content = truncateContent(redacted.content, 100);
164
+ }
165
+
166
+ // Redact sensitive fields
167
+ const sensitiveFields = ['token', 'password', 'secret', 'key', 'auth'];
168
+ for (const field of sensitiveFields) {
169
+ if (redacted[field]) {
170
+ redacted[field] = '[REDACTED]';
171
+ }
172
+ }
173
+
174
+ return redacted;
175
+ }
176
+
177
+ function generateRequestId() {
178
+ return 'req_' + crypto.randomBytes(6).toString('hex');
179
+ }
180
+
181
+ function logToolCall(agent, toolName, args, result, durationMs, context = {}) {
182
+ if (!shouldLogTool(toolName)) return;
183
+
184
+ const entry = {
185
+ timestamp: new Date().toISOString(),
186
+ request_id: generateRequestId(),
187
+ agent: agent || 'unknown',
188
+ tool: toolName,
189
+ category: TOOL_CATEGORIES[toolName] || 'unknown',
190
+ args: redactSensitiveArgs(toolName, args),
191
+ result: result ? {
192
+ success: !result.error,
193
+ ...(result.error ? { error: truncateContent(result.error, 200) } : {}),
194
+ ...(result.messageId ? { messageId: result.messageId } : {}),
195
+ ...(result.success !== undefined ? { success: result.success } : {})
196
+ } : null,
197
+ duration_ms: Math.round(durationMs || 0),
198
+ context: {
199
+ ...context,
200
+ session_id: context.session_id || 'sess_' + crypto.randomBytes(4).toString('hex')
201
+ }
202
+ };
203
+
204
+ // Truncate large args/results
205
+ if (entry.args) {
206
+ entry.args = JSON.parse(truncateContent(JSON.stringify(entry.args), AUDIT_ARGS_MAX_LENGTH));
207
+ }
208
+ if (entry.result) {
209
+ entry.result = JSON.parse(truncateContent(JSON.stringify(entry.result), AUDIT_RESULT_MAX_LENGTH));
210
+ }
211
+
212
+ // Add to pending writes for batch processing
213
+ pendingWrites.push(entry);
214
+
215
+ // Schedule batch write
216
+ if (!writeTimer) {
217
+ writeTimer = setTimeout(flushPendingWrites, 100); // 100ms batch window
218
+ }
219
+ }
220
+
221
+ function flushPendingWrites() {
222
+ if (pendingWrites.length === 0) return;
223
+
224
+ const entries = [...pendingWrites];
225
+ pendingWrites = [];
226
+ writeTimer = null;
227
+
228
+ // Async write to avoid blocking MCP calls
229
+ setImmediate(() => {
230
+ try {
231
+ const lines = entries.map(entry => JSON.stringify(entry)).join('\n') + '\n';
232
+ fs.appendFileSync(auditFile, lines, 'utf8');
233
+
234
+ // Check if rotation is needed
235
+ checkRotation();
236
+ } catch (e) {
237
+ console.error('[audit] Failed to write entries:', e.message);
238
+ // Re-queue failed entries
239
+ pendingWrites.unshift(...entries);
240
+ }
241
+ });
242
+ }
243
+
244
+ function checkRotation() {
245
+ if (!auditFile || !fs.existsSync(auditFile)) return;
246
+
247
+ try {
248
+ const stats = fs.statSync(auditFile);
249
+ if (stats.size >= AUDIT_MAX_SIZE) {
250
+ rotateAuditFile();
251
+ }
252
+ } catch (e) {
253
+ console.error('[audit] Failed to check file size:', e.message);
254
+ }
255
+ }
256
+
257
+ function rotateAuditFile() {
258
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
259
+ const archiveName = `audit_log_${timestamp}.jsonl`;
260
+ const archivePath = path.join(path.dirname(auditFile), archiveName);
261
+
262
+ try {
263
+ // Move current file to archive
264
+ fs.renameSync(auditFile, archivePath);
265
+
266
+ // Create new audit file
267
+ fs.writeFileSync(auditFile, '', 'utf8');
268
+
269
+ console.log(`[audit] Rotated audit log to ${archiveName}`);
270
+
271
+ // Compress archive in background
272
+ setImmediate(() => compressArchive(archivePath));
273
+ } catch (e) {
274
+ console.error('[audit] Failed to rotate audit log:', e.message);
275
+ }
276
+ }
277
+
278
+ function compressArchive(archivePath) {
279
+ try {
280
+ const zlib = require('zlib');
281
+ const readStream = fs.createReadStream(archivePath);
282
+ const writeStream = fs.createWriteStream(archivePath + '.gz');
283
+ const gzip = zlib.createGzip();
284
+
285
+ readStream.pipe(gzip).pipe(writeStream);
286
+
287
+ writeStream.on('finish', () => {
288
+ // Remove uncompressed archive
289
+ fs.unlinkSync(archivePath);
290
+ console.log(`[audit] Compressed archive: ${path.basename(archivePath)}.gz`);
291
+ });
292
+ } catch (e) {
293
+ console.error('[audit] Failed to compress archive:', e.message);
294
+ }
295
+ }
296
+
297
+ function cleanupOldArchives() {
298
+ if (!auditFile) return;
299
+
300
+ const auditDir = path.dirname(auditFile);
301
+ const cutoffTime = Date.now() - (AUDIT_RETENTION_DAYS * 24 * 60 * 60 * 1000);
302
+
303
+ try {
304
+ const files = fs.readdirSync(auditDir);
305
+ for (const file of files) {
306
+ if (file.startsWith('audit_log_') && (file.endsWith('.jsonl') || file.endsWith('.jsonl.gz'))) {
307
+ const filePath = path.join(auditDir, file);
308
+ const stats = fs.statSync(filePath);
309
+
310
+ if (stats.mtime.getTime() < cutoffTime) {
311
+ fs.unlinkSync(filePath);
312
+ console.log(`[audit] Cleaned up old archive: ${file}`);
313
+ }
314
+ }
315
+ }
316
+ } catch (e) {
317
+ console.error('[audit] Failed to cleanup old archives:', e.message);
318
+ }
319
+ }
320
+
321
+ function readAuditLog(filters = {}) {
322
+ if (!auditFile || !fs.existsSync(auditFile)) return [];
323
+
324
+ try {
325
+ const content = fs.readFileSync(auditFile, 'utf8');
326
+ const lines = content.trim().split('\n').filter(line => line.trim());
327
+
328
+ let entries = lines.map(line => {
329
+ try {
330
+ return JSON.parse(line);
331
+ } catch {
332
+ return null;
333
+ }
334
+ }).filter(entry => entry !== null);
335
+
336
+ // Apply filters
337
+ if (filters.agent) {
338
+ entries = entries.filter(e => e.agent === filters.agent);
339
+ }
340
+ if (filters.tool) {
341
+ entries = entries.filter(e => e.tool === filters.tool);
342
+ }
343
+ if (filters.category) {
344
+ entries = entries.filter(e => e.category === filters.category);
345
+ }
346
+ if (filters.since) {
347
+ const sinceTime = new Date(filters.since).getTime();
348
+ entries = entries.filter(e => new Date(e.timestamp).getTime() >= sinceTime);
349
+ }
350
+ if (filters.until) {
351
+ const untilTime = new Date(filters.until).getTime();
352
+ entries = entries.filter(e => new Date(e.timestamp).getTime() <= untilTime);
353
+ }
354
+
355
+ // Sort by timestamp (newest first)
356
+ entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
357
+
358
+ // Apply limit
359
+ const limit = parseInt(filters.limit) || 100;
360
+ return entries.slice(0, limit);
361
+ } catch (e) {
362
+ console.error('[audit] Failed to read audit log:', e.message);
363
+ return [];
364
+ }
365
+ }
366
+
367
+ function getAuditStats(filters = {}) {
368
+ const entries = readAuditLog(filters);
369
+
370
+ const stats = {
371
+ total_calls: entries.length,
372
+ agents: {},
373
+ tools: {},
374
+ categories: {},
375
+ success_rate: 0,
376
+ avg_duration_ms: 0
377
+ };
378
+
379
+ let successCount = 0;
380
+ let totalDuration = 0;
381
+
382
+ for (const entry of entries) {
383
+ // Agent stats
384
+ stats.agents[entry.agent] = (stats.agents[entry.agent] || 0) + 1;
385
+
386
+ // Tool stats
387
+ stats.tools[entry.tool] = (stats.tools[entry.tool] || 0) + 1;
388
+
389
+ // Category stats
390
+ stats.categories[entry.category] = (stats.categories[entry.category] || 0) + 1;
391
+
392
+ // Success tracking
393
+ if (entry.result && entry.result.success !== false) {
394
+ successCount++;
395
+ }
396
+
397
+ // Duration tracking
398
+ if (entry.duration_ms) {
399
+ totalDuration += entry.duration_ms;
400
+ }
401
+ }
402
+
403
+ stats.success_rate = entries.length > 0 ? Math.round((successCount / entries.length) * 100) : 0;
404
+ stats.avg_duration_ms = entries.length > 0 ? Math.round(totalDuration / entries.length) : 0;
405
+
406
+ return stats;
407
+ }
408
+
409
+ module.exports = {
410
+ init,
411
+ logToolCall,
412
+ readAuditLog,
413
+ getAuditStats,
414
+ shouldLogTool,
415
+ TOOL_CATEGORIES,
416
+ AUDIT_LEVELS
417
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /** TOML section header for Neohive MCP in Codex config.toml */
4
+ const HEADER = '[mcp_servers.neohive]';
5
+
6
+ /**
7
+ * Insert or replace the [mcp_servers.neohive] table (up to the next [section]).
8
+ * Preserves following sections (e.g. [mcp_servers.neohive.env]).
9
+ * @param {string} config
10
+ * @param {{ command: string, serverPath: string, timeout?: number, envSection?: string }} opts
11
+ * @returns {string}
12
+ */
13
+ function upsertNeohiveMcpInToml(config, opts) {
14
+ const { command, serverPath, timeout = 300, envSection } = opts;
15
+ const blockBody =
16
+ `command = ${JSON.stringify(command)}\n` +
17
+ `args = [${JSON.stringify(serverPath)}]\n` +
18
+ `timeout = ${timeout}\n`;
19
+
20
+ const idx = config.indexOf(HEADER);
21
+ if (idx === -1) {
22
+ const sep = config.length && !config.endsWith('\n') ? '\n' : '';
23
+ let addition = `${sep}\n${HEADER}\n${blockBody}`;
24
+ if (envSection) addition += envSection.endsWith('\n') ? envSection : envSection + '\n';
25
+ return config + addition;
26
+ }
27
+
28
+ const afterHeader = idx + HEADER.length;
29
+ const nextSecIdx = config.indexOf('\n[', afterHeader);
30
+ const end = nextSecIdx === -1 ? config.length : nextSecIdx;
31
+ return config.slice(0, idx) + HEADER + '\n' + blockBody + config.slice(end);
32
+ }
33
+
34
+ module.exports = { HEADER, upsertNeohiveMcpInToml };
package/lib/compact.js CHANGED
@@ -62,7 +62,8 @@ function autoCompact() {
62
62
  const messages = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
63
63
 
64
64
  const agents = getAgents();
65
- const aliveAgentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
65
+ const allAgentNames = Object.keys(agents);
66
+ const retentionMs = (parseInt(process.env.NEOHIVE_RETENTION_HOURS) || 24) * 3600000;
66
67
  const allConsumed = new Set();
67
68
  const perAgentConsumed = {};
68
69
  if (fs.existsSync(DATA_DIR)) {
@@ -80,7 +81,9 @@ function autoCompact() {
80
81
 
81
82
  const active = messages.filter(m => {
82
83
  if (m.to === '__group__') {
83
- return !aliveAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
84
+ const msgTime = new Date(m.timestamp).getTime();
85
+ if (msgTime < Date.now() - retentionMs) return false;
86
+ return !allAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
84
87
  }
85
88
  if (!allConsumed.has(m.id)) return true;
86
89
  return false;
package/lib/config.js CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { resolveDataDirForServer } = require('./resolve-server-data-dir');
5
6
 
6
- // Data dir lives in the project where the CLI runs, not where the package is installed
7
- const DATA_DIR = process.env.NEOHIVE_DATA_DIR || path.join(process.cwd(), '.neohive');
7
+ // Same rules as server.js: env, else repo .cursor/mcp.json sibling, else cwd/.neohive
8
+ const DATA_DIR = resolveDataDirForServer(path.join(__dirname, '..'));
8
9
 
9
10
  // File paths for all shared data
10
11
  const MESSAGES_FILE = path.join(DATA_DIR, 'messages.jsonl');
@@ -35,7 +36,7 @@ const CHANNELS_FILE_PATH = path.join(DATA_DIR, 'channels.json');
35
36
  // Constants
36
37
  const MAX_CONTENT_BYTES = 1000000; // 1 MB max message size
37
38
  const CURRENT_DATA_VERSION = 1;
38
- const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', 'system', 'dashboard', 'Dashboard'];
39
+ const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', '__user__', 'system', 'dashboard', 'Dashboard'];
39
40
 
40
41
  // Config helpers
41
42
  function getConfig() {
package/lib/file-io.js CHANGED
@@ -107,7 +107,7 @@ function lockAgentsFile() {
107
107
  while (Date.now() - start < maxWait) {
108
108
  try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; }
109
109
  catch {}
110
- const wait = Date.now(); while (Date.now() - wait < backoff) {}
110
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, backoff); } catch {}
111
111
  backoff = Math.min(backoff * 2, 500);
112
112
  }
113
113
  try { fs.unlinkSync(AGENTS_LOCK); } catch {}
@@ -123,7 +123,7 @@ function lockConfigFile() {
123
123
  while (Date.now() - start < maxWait) {
124
124
  try { fs.writeFileSync(CONFIG_LOCK, String(process.pid), { flag: 'wx' }); return true; }
125
125
  catch {}
126
- const wait = Date.now(); while (Date.now() - wait < 50) {}
126
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); } catch {}
127
127
  }
128
128
  try { fs.unlinkSync(CONFIG_LOCK); } catch {}
129
129
  try { fs.writeFileSync(CONFIG_LOCK, String(process.pid), { flag: 'wx' }); return true; } catch {}
@@ -139,7 +139,7 @@ function withFileLock(filePath, fn) {
139
139
  while (Date.now() - start < maxWait) {
140
140
  try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); break; }
141
141
  catch {}
142
- const wait = Date.now(); while (Date.now() - wait < backoff) {}
142
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, backoff); } catch {}
143
143
  backoff = Math.min(backoff * 2, 500);
144
144
  if (Date.now() - start >= maxWait) {
145
145
  try {