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,301 @@
1
+ 'use strict';
2
+
3
+ // Knowledge tools: KB, decisions, compressed history, briefing, summary, progress.
4
+ // Extracted from server.js as part of modular tool architecture.
5
+
6
+ const fs = require('fs');
7
+
8
+ module.exports = function (ctx) {
9
+ const { state, helpers, files } = ctx;
10
+
11
+ const {
12
+ getDecisions, getKB, getProgressData, getCompressed, getLocks, getConfig,
13
+ generateId, writeJsonFile, readJsonFile, touchActivity, tailReadJsonl,
14
+ getHistoryFile, getAgents, isPidAlive, getProfiles, getTasks, cachedRead,
15
+ } = helpers;
16
+
17
+ const { DECISIONS_FILE, KB_FILE, PROGRESS_FILE, COMPRESSED_FILE } = files;
18
+
19
+ // --- Decisions ---
20
+
21
+ function toolLogDecision(decision, reasoning, topic) {
22
+ if (!state.registeredName) return { error: 'You must call register() first' };
23
+ if (typeof decision !== 'string' || decision.length < 1 || decision.length > 500) return { error: 'Decision must be 1-500 chars' };
24
+
25
+ const decisions = getDecisions();
26
+ const entry = {
27
+ id: 'dec_' + generateId(),
28
+ decision,
29
+ reasoning: (reasoning || '').substring(0, 1000),
30
+ topic: (topic || 'general').substring(0, 50),
31
+ decided_by: state.registeredName,
32
+ decided_at: new Date().toISOString(),
33
+ };
34
+ decisions.push(entry);
35
+ if (decisions.length > 200) decisions.splice(0, decisions.length - 200);
36
+ writeJsonFile(DECISIONS_FILE, decisions);
37
+ touchActivity();
38
+ return { success: true, decision_id: entry.id, message: 'Decision logged. Other agents can see it via get_decisions() or get_briefing().' };
39
+ }
40
+
41
+ function toolGetDecisions(topic) {
42
+ let decisions = getDecisions();
43
+ if (topic) decisions = decisions.filter(d => d.topic === topic);
44
+ return { count: decisions.length, decisions: decisions.slice(-30) };
45
+ }
46
+
47
+ // --- Knowledge Base ---
48
+
49
+ function toolKBWrite(key, content) {
50
+ if (!state.registeredName) return { error: 'You must call register() first' };
51
+ if (typeof key !== 'string' || key.length < 1 || key.length > 50) return { error: 'Key must be 1-50 chars' };
52
+ if (!/^[a-zA-Z0-9_\-\.]+$/.test(key)) return { error: 'Key must be alphanumeric/underscore/hyphen/dot' };
53
+ if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > 102400) return { error: 'Content exceeds 100KB' };
54
+
55
+ const kb = getKB();
56
+ kb[key] = { content, updated_by: state.registeredName, updated_at: new Date().toISOString() };
57
+ if (Object.keys(kb).length > 100) return { error: 'Knowledge base full (max 100 keys)' };
58
+ writeJsonFile(KB_FILE, kb);
59
+ touchActivity();
60
+ return { success: true, key, size: content.length, total_keys: Object.keys(kb).length };
61
+ }
62
+
63
+ function toolKBRead(key) {
64
+ const kb = getKB();
65
+ if (key) {
66
+ if (!kb[key]) return { error: `Key "${key}" not found in knowledge base` };
67
+ return { key, content: kb[key].content, updated_by: kb[key].updated_by, updated_at: kb[key].updated_at };
68
+ }
69
+ const entries = {};
70
+ for (const [k, v] of Object.entries(kb)) {
71
+ entries[k] = { content: v.content, updated_by: v.updated_by, updated_at: v.updated_at };
72
+ }
73
+ return { entries, total_keys: Object.keys(kb).length };
74
+ }
75
+
76
+ function toolKBList() {
77
+ const kb = getKB();
78
+ return {
79
+ keys: Object.keys(kb).map(k => ({ key: k, updated_by: kb[k].updated_by, updated_at: kb[k].updated_at, size: kb[k].content.length })),
80
+ total: Object.keys(kb).length,
81
+ };
82
+ }
83
+
84
+ // --- Progress ---
85
+
86
+ function toolUpdateProgress(feature, percent, notes) {
87
+ if (!state.registeredName) return { error: 'You must call register() first' };
88
+ if (typeof feature !== 'string' || feature.length < 1 || feature.length > 100) return { error: 'Feature name must be 1-100 chars' };
89
+ if (typeof percent !== 'number' || percent < 0 || percent > 100) return { error: 'Percent must be 0-100' };
90
+
91
+ const progress = getProgressData();
92
+ progress[feature] = {
93
+ percent,
94
+ notes: (notes || '').substring(0, 500),
95
+ updated_by: state.registeredName,
96
+ updated_at: new Date().toISOString(),
97
+ };
98
+ writeJsonFile(PROGRESS_FILE, progress);
99
+ touchActivity();
100
+ return { success: true, feature, percent, message: `Progress updated: ${feature} is ${percent}% complete.` };
101
+ }
102
+
103
+ function toolGetProgress() {
104
+ const progress = getProgressData();
105
+ const features = Object.entries(progress).map(([name, p]) => ({
106
+ feature: name, percent: p.percent, notes: p.notes, updated_by: p.updated_by, updated_at: p.updated_at,
107
+ }));
108
+ const avg = features.length > 0 ? Math.round(features.reduce((s, f) => s + f.percent, 0) / features.length) : 0;
109
+ return { features, overall_percent: avg, feature_count: features.length };
110
+ }
111
+
112
+ // --- Compressed History ---
113
+
114
+ function toolGetCompressedHistory() {
115
+ if (!state.registeredName) return { error: 'You must call register() first' };
116
+
117
+ const compressed = getCompressed();
118
+ const recent = tailReadJsonl(getHistoryFile(state.currentBranch), 20);
119
+
120
+ return {
121
+ compressed_segments: compressed.segments.slice(-20).map(s => ({
122
+ time_range: s.from_time + ' to ' + s.to_time,
123
+ speakers: s.speakers,
124
+ message_count: s.message_count,
125
+ summary: s.summary,
126
+ })),
127
+ recent_messages: recent.map(m => ({
128
+ id: m.id, from: m.from, to: m.to,
129
+ content: m.content.substring(0, 300),
130
+ timestamp: m.timestamp,
131
+ })),
132
+ total_messages: compressed.segments.reduce((s, seg) => s + seg.message_count, 0) + recent.length,
133
+ compressed_count: compressed.segments.reduce((s, seg) => s + seg.message_count, 0),
134
+ recent_count: recent.length,
135
+ hint: 'Compressed segments summarize older messages. Recent messages are shown verbatim.',
136
+ };
137
+ }
138
+
139
+ // --- Summary ---
140
+
141
+ function toolGetSummary(lastN) {
142
+ lastN = Math.min(Math.max(1, lastN || 20), 500);
143
+ const recent = tailReadJsonl(getHistoryFile(state.currentBranch), lastN);
144
+ if (recent.length === 0) {
145
+ return { summary: 'No messages in conversation yet.', message_count: 0 };
146
+ }
147
+
148
+ const agentsData = getAgents();
149
+ const agents = Object.keys(agentsData);
150
+ const threads = [...new Set(recent.filter(m => m.thread_id).map(m => m.thread_id))];
151
+
152
+ const lines = recent.map(m => {
153
+ const preview = m.content.length > 150 ? m.content.substring(0, 150) + '...' : m.content;
154
+ return `[${m.from} → ${m.to}]: ${preview}`;
155
+ });
156
+
157
+ return {
158
+ total_messages: recent.length,
159
+ showing_last: recent.length,
160
+ agents_involved: agents,
161
+ thread_count: threads.length,
162
+ first_message: recent[0].timestamp,
163
+ last_message: recent[recent.length - 1].timestamp,
164
+ summary: lines.join('\n'),
165
+ };
166
+ }
167
+
168
+ // --- Briefing ---
169
+
170
+ function toolGetBriefing() {
171
+ if (!state.registeredName) return { error: 'You must call register() first' };
172
+
173
+ const agents = getAgents();
174
+ const profiles = getProfiles();
175
+ const tasks = getTasks();
176
+ const decisions = getDecisions();
177
+ const kb = getKB();
178
+ const progress = getProgressData();
179
+ const history = tailReadJsonl(getHistoryFile(state.currentBranch), 30);
180
+ const locks = getLocks();
181
+ const config = getConfig();
182
+
183
+ const roster = {};
184
+ for (const [name, info] of Object.entries(agents)) {
185
+ const alive = isPidAlive(info.pid, info.last_activity);
186
+ const profile = profiles[name] || {};
187
+ roster[name] = {
188
+ status: !alive ? 'offline' : info.listening_since ? 'listening' : 'working',
189
+ role: profile.role || '',
190
+ provider: info.provider || 'unknown',
191
+ };
192
+ }
193
+
194
+ const recentMsgs = history.slice(-15).map(m => ({
195
+ from: m.from, to: m.to,
196
+ preview: m.content.substring(0, 150),
197
+ timestamp: m.timestamp,
198
+ }));
199
+
200
+ const activeTasks = tasks.filter(t => t.status !== 'done').map(t => ({
201
+ id: t.id, title: t.title, status: t.status, assignee: t.assignee, created_by: t.created_by,
202
+ }));
203
+ const doneTasks = tasks.filter(t => t.status === 'done').length;
204
+
205
+ const lockedFiles = {};
206
+ for (const [fp, lock] of Object.entries(locks)) {
207
+ lockedFiles[fp] = { locked_by: lock.agent, since: lock.since };
208
+ }
209
+
210
+ const myActiveTasks = tasks.filter(t => t.status !== 'done' && t.assignee === state.registeredName);
211
+ const myCompletedCount = tasks.filter(t => t.status === 'done' && t.assignee === state.registeredName).length;
212
+
213
+ return {
214
+ briefing: true,
215
+ conversation_mode: config.conversation_mode || 'direct',
216
+ agents: roster,
217
+ your_name: state.registeredName,
218
+ recent_messages: recentMsgs,
219
+ tasks: { active: activeTasks, completed_count: doneTasks, total: tasks.length },
220
+ decisions: decisions.slice(-5).map(d => ({ decision: d.decision, topic: d.topic })),
221
+ knowledge_base_keys: Object.keys(kb),
222
+ locked_files: lockedFiles,
223
+ progress,
224
+ your_tasks: myActiveTasks.map(t => ({ id: t.id, title: t.title, status: t.status })),
225
+ your_completed: myCompletedCount,
226
+ hint: myActiveTasks.length > 0
227
+ ? `You have ${myActiveTasks.length} active task(s). Continue working.`
228
+ : 'You are now briefed. Check active tasks and start contributing.',
229
+ };
230
+ }
231
+
232
+ // --- MCP tool definitions ---
233
+
234
+ const definitions = [
235
+ {
236
+ name: 'get_summary',
237
+ description: 'Get a condensed summary of the conversation so far. Useful when context is getting long and you need a quick recap of what was discussed.',
238
+ inputSchema: { type: 'object', properties: { last_n: { type: 'number', description: 'Number of recent messages to summarize (default 20, max 500)' } }, additionalProperties: false },
239
+ },
240
+ {
241
+ name: 'get_briefing',
242
+ description: 'Get a full project briefing: who is online, active tasks, recent decisions, knowledge base, locked files, progress, and project files. Call this when joining a project or after being away. One call = fully onboarded.',
243
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
244
+ },
245
+ {
246
+ name: 'log_decision',
247
+ description: 'Record a team decision for future reference. Decisions persist across compaction and appear in briefings.',
248
+ inputSchema: { type: 'object', properties: { decision: { type: 'string', description: 'The decision made (1-500 chars)' }, reasoning: { type: 'string', description: 'Why this was decided (optional, max 1000 chars)' }, topic: { type: 'string', description: 'Topic category (optional, e.g., "architecture", "deployment")' } }, required: ['decision'], additionalProperties: false },
249
+ },
250
+ {
251
+ name: 'get_decisions',
252
+ description: 'View logged team decisions, optionally filtered by topic.',
253
+ inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Filter by topic (optional)' } }, additionalProperties: false },
254
+ },
255
+ {
256
+ name: 'kb_write',
257
+ description: 'Write to the shared knowledge base. Keys persist across compaction. Max 100 keys, 100KB per value.',
258
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key (1-50 alphanumeric/underscore/hyphen/dot chars)' }, content: { type: 'string', description: 'Content to store (max 100KB)' } }, required: ['key', 'content'], additionalProperties: false },
259
+ },
260
+ {
261
+ name: 'kb_read',
262
+ description: 'Read from the shared knowledge base. Omit key to read all entries.',
263
+ inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key to read (optional — omit for all)' } }, additionalProperties: false },
264
+ },
265
+ {
266
+ name: 'kb_list',
267
+ description: 'List all knowledge base keys with metadata (who updated, when, size).',
268
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
269
+ },
270
+ {
271
+ name: 'update_progress',
272
+ description: 'Update progress on a feature or milestone. Shown in dashboard and briefings.',
273
+ inputSchema: { type: 'object', properties: { feature: { type: 'string', description: 'Feature or milestone name (1-100 chars)' }, percent: { type: 'number', description: 'Completion percentage (0-100)' }, notes: { type: 'string', description: 'Optional progress notes' } }, required: ['feature', 'percent'], additionalProperties: false },
274
+ },
275
+ {
276
+ name: 'get_progress',
277
+ description: 'Get progress on all tracked features/milestones.',
278
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
279
+ },
280
+ {
281
+ name: 'get_compressed_history',
282
+ description: 'Get conversation history with automatic compression. Old messages are summarized into segments, recent messages shown verbatim. Use this when the conversation is long and you need to catch up without overflowing your context.',
283
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
284
+ },
285
+ ];
286
+
287
+ const handlers = {
288
+ log_decision: function (args) { return toolLogDecision(args.decision, args.reasoning, args.topic); },
289
+ get_decisions: function (args) { return toolGetDecisions(args.topic); },
290
+ kb_write: function (args) { return toolKBWrite(args.key, args.content); },
291
+ kb_read: function (args) { return toolKBRead(args.key); },
292
+ kb_list: function () { return toolKBList(); },
293
+ update_progress: function (args) { return toolUpdateProgress(args.feature, args.percent, args.notes); },
294
+ get_progress: function () { return toolGetProgress(); },
295
+ get_compressed_history: function () { return toolGetCompressedHistory(); },
296
+ get_summary: function (args) { return toolGetSummary(args.last_n); },
297
+ get_briefing: function () { return toolGetBriefing(); },
298
+ };
299
+
300
+ return { definitions, handlers };
301
+ };
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ // Messaging tools (read-oriented): check, consume, ack, history, notifications, search.
4
+ // Extracted from server.js as part of modular tool architecture.
5
+ // Note: send_message, broadcast, handoff, share_file remain in server.js (deeply stateful).
6
+
7
+ const fs = require('fs');
8
+
9
+ module.exports = function (ctx) {
10
+ const { state, helpers, files } = ctx;
11
+
12
+ const {
13
+ getUnconsumedMessages, getConsumedIds, saveConsumedIds, markAsRead,
14
+ getNotifications, saveNotifications, getAcks, getPermissions,
15
+ getAgents, isPidAlive, getConfig, touchActivity,
16
+ tailReadJsonl, readJsonl, getMessagesFile, getHistoryFile,
17
+ getAgentChannels, getChannelHistoryFile,
18
+ withFileLock,
19
+ } = helpers;
20
+
21
+ const { ACKS_FILE } = files;
22
+
23
+ // --- Check Messages (peek, non-consuming) ---
24
+
25
+ function toolCheckMessages(from) {
26
+ if (!state.registeredName) return { error: 'You must call register() first' };
27
+
28
+ const unconsumed = getUnconsumedMessages(state.registeredName, from || null);
29
+
30
+ const senders = {};
31
+ let addressedCount = 0;
32
+ for (const m of unconsumed) {
33
+ senders[m.from] = (senders[m.from] || 0) + 1;
34
+ if (m.addressed_to && m.addressed_to.includes(state.registeredName)) addressedCount++;
35
+ }
36
+
37
+ const allNotifs = getNotifications();
38
+ const unreadNotifs = allNotifs.filter(n => !n.read_by.includes(state.registeredName));
39
+
40
+ const result = {
41
+ count: unconsumed.length,
42
+ pending_notifications: unreadNotifs.length,
43
+ messages: unconsumed.map(m => ({
44
+ id: m.id,
45
+ from: m.from,
46
+ preview: m.content.substring(0, 120),
47
+ timestamp: m.timestamp,
48
+ ...(m.addressed_to && { addressed_to: m.addressed_to }),
49
+ })),
50
+ };
51
+
52
+ if (unconsumed.length > 0) {
53
+ result.senders = senders;
54
+ result.addressed_to_you = addressedCount;
55
+ const latest = unconsumed[unconsumed.length - 1];
56
+ result.preview = `${latest.from}: "${latest.content.substring(0, 80).replace(/\n/g, ' ')}..."`;
57
+ const oldestAge = Math.round((Date.now() - new Date(unconsumed[0].timestamp).getTime()) / 1000);
58
+ result.urgency = oldestAge > 120 ? 'critical' : oldestAge > 30 ? 'urgent' : 'normal';
59
+ result.action_required = 'You have unread messages. Call listen() to receive and process them. Do NOT call check_messages() again — it does not consume messages and you will see the same messages repeatedly.';
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ // --- Consume Messages (grab all + mark read) ---
66
+
67
+ function toolConsumeMessages(from, limit) {
68
+ if (!state.registeredName) return { error: 'You must call register() first' };
69
+
70
+ let unconsumed = getUnconsumedMessages(state.registeredName, from || null);
71
+ if (limit && limit > 0 && unconsumed.length > limit) {
72
+ unconsumed = unconsumed.slice(0, limit);
73
+ }
74
+
75
+ if (unconsumed.length === 0) {
76
+ return { success: true, count: 0, messages: [] };
77
+ }
78
+
79
+ const consumed = getConsumedIds(state.registeredName);
80
+ for (const msg of unconsumed) {
81
+ consumed.add(msg.id);
82
+ markAsRead(state.registeredName, msg.id);
83
+ }
84
+ saveConsumedIds(state.registeredName, consumed);
85
+
86
+ const msgFile = getMessagesFile(state.currentBranch);
87
+ if (fs.existsSync(msgFile)) {
88
+ state.lastReadOffset = fs.statSync(msgFile).size;
89
+ }
90
+
91
+ touchActivity();
92
+
93
+ const remaining = getUnconsumedMessages(state.registeredName, null);
94
+ const agents = getAgents();
95
+ const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid, info.last_activity)).length;
96
+
97
+ return {
98
+ success: true,
99
+ count: unconsumed.length,
100
+ messages: unconsumed.map(m => ({
101
+ id: m.id,
102
+ from: m.from,
103
+ content: m.content,
104
+ timestamp: m.timestamp,
105
+ ...(m.reply_to && { reply_to: m.reply_to }),
106
+ ...(m.thread_id && { thread_id: m.thread_id }),
107
+ ...(m.addressed_to && { addressed_to: m.addressed_to }),
108
+ })),
109
+ remaining: remaining.length,
110
+ agents_online: agentsOnline,
111
+ coordinator_mode: getConfig().coordinator_mode || 'responsive',
112
+ };
113
+ }
114
+
115
+ // --- Ack Message ---
116
+
117
+ function toolAckMessage(messageId) {
118
+ if (!state.registeredName) return { error: 'You must call register() first' };
119
+
120
+ const history = tailReadJsonl(getHistoryFile(state.currentBranch), 100);
121
+ const msg = history.find(m => m.id === messageId);
122
+ if (msg && msg.to !== state.registeredName) {
123
+ return { error: 'Can only acknowledge messages addressed to you' };
124
+ }
125
+
126
+ withFileLock(ACKS_FILE, () => {
127
+ const acks = getAcks();
128
+ acks[messageId] = {
129
+ acked_by: state.registeredName,
130
+ acked_at: new Date().toISOString(),
131
+ };
132
+ fs.writeFileSync(ACKS_FILE, JSON.stringify(acks));
133
+ });
134
+ touchActivity();
135
+
136
+ return { success: true, message: `Message ${messageId} acknowledged` };
137
+ }
138
+
139
+ // --- Get History ---
140
+
141
+ function toolGetHistory(limit, thread_id) {
142
+ limit = Math.min(Math.max(1, limit || 50), 500);
143
+ let history = tailReadJsonl(getHistoryFile(state.currentBranch), limit * 2);
144
+ if (thread_id) {
145
+ history = history.filter(m => m.thread_id === thread_id || m.id === thread_id);
146
+ }
147
+ if (state.registeredName) {
148
+ const perms = getPermissions();
149
+ if (perms[state.registeredName] && perms[state.registeredName].can_read) {
150
+ const allowed = perms[state.registeredName].can_read;
151
+ if (allowed !== '*' && Array.isArray(allowed)) {
152
+ history = history.filter(m => m.from === state.registeredName || m.to === state.registeredName || allowed.includes(m.from));
153
+ }
154
+ }
155
+ }
156
+ const recent = history.slice(-limit);
157
+ const acks = getAcks();
158
+
159
+ return {
160
+ count: recent.length,
161
+ total: history.length,
162
+ messages: recent.map(m => ({
163
+ id: m.id,
164
+ from: m.from,
165
+ to: m.to,
166
+ content: m.content,
167
+ timestamp: m.timestamp,
168
+ acked: !!acks[m.id],
169
+ ...(m.reply_to && { reply_to: m.reply_to }),
170
+ ...(m.thread_id && { thread_id: m.thread_id }),
171
+ })),
172
+ };
173
+ }
174
+
175
+ // --- Get Notifications ---
176
+
177
+ function toolGetNotifications(since, type) {
178
+ if (!state.registeredName) return { error: 'You must call register() first' };
179
+ let notifs = getNotifications();
180
+ notifs = notifs.filter(n => !n.read_by.includes(state.registeredName));
181
+ if (since) {
182
+ const sinceTs = new Date(since).getTime();
183
+ notifs = notifs.filter(n => new Date(n.timestamp).getTime() > sinceTs);
184
+ }
185
+ if (type) {
186
+ notifs = notifs.filter(n => n.type === type);
187
+ }
188
+ if (notifs.length > 0) {
189
+ const allNotifs = getNotifications();
190
+ const readIds = new Set(notifs.map(n => n.id));
191
+ for (const n of allNotifs) {
192
+ if (readIds.has(n.id) && !n.read_by.includes(state.registeredName)) {
193
+ n.read_by.push(state.registeredName);
194
+ }
195
+ }
196
+ saveNotifications(allNotifs);
197
+ }
198
+ return {
199
+ count: notifs.length,
200
+ notifications: notifs.map(n => ({
201
+ id: n.id,
202
+ type: n.type,
203
+ source_agent: n.source_agent,
204
+ related_id: n.related_id,
205
+ summary: n.summary,
206
+ timestamp: n.timestamp,
207
+ })),
208
+ };
209
+ }
210
+
211
+ // --- Search Messages ---
212
+
213
+ function toolSearchMessages(query, from, limit) {
214
+ if (!state.registeredName) return { error: 'You must call register() first' };
215
+ if (typeof query !== 'string' || query.length < 2) return { error: 'Query must be at least 2 characters' };
216
+ limit = Math.min(Math.max(1, limit || 20), 50);
217
+ from = from || null;
218
+
219
+ const queryLower = query.toLowerCase();
220
+ let allMessages = tailReadJsonl(getHistoryFile(state.currentBranch), 500);
221
+ try {
222
+ const myChannels = getAgentChannels(state.registeredName);
223
+ for (const ch of myChannels) {
224
+ if (ch === 'general') continue;
225
+ const chFile = getChannelHistoryFile(ch);
226
+ if (fs.existsSync(chFile)) {
227
+ allMessages = allMessages.concat(tailReadJsonl(chFile, 100));
228
+ }
229
+ }
230
+ } catch (e) { /* channel read failed */ }
231
+ allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
232
+
233
+ const results = [];
234
+ for (let i = 0; i < allMessages.length && results.length < limit; i++) {
235
+ const m = allMessages[i];
236
+ if (from && m.from !== from) continue;
237
+ if (m.content && m.content.toLowerCase().includes(queryLower)) {
238
+ results.push({
239
+ id: m.id, from: m.from, to: m.to,
240
+ preview: m.content.substring(0, 200),
241
+ timestamp: m.timestamp,
242
+ ...(m.channel && { channel: m.channel }),
243
+ });
244
+ }
245
+ }
246
+ // Fall back to full read if tail search found nothing
247
+ if (results.length === 0) {
248
+ allMessages = readJsonl(getHistoryFile(state.currentBranch));
249
+ try {
250
+ const myChannels = getAgentChannels(state.registeredName);
251
+ for (const ch of myChannels) {
252
+ if (ch === 'general') continue;
253
+ const chFile = getChannelHistoryFile(ch);
254
+ if (fs.existsSync(chFile)) {
255
+ allMessages = allMessages.concat(readJsonl(chFile));
256
+ }
257
+ }
258
+ } catch (e) { /* channel read failed */ }
259
+ allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
260
+ for (let i = 0; i < allMessages.length && results.length < limit; i++) {
261
+ const m = allMessages[i];
262
+ if (from && m.from !== from) continue;
263
+ if (m.content && m.content.toLowerCase().includes(queryLower)) {
264
+ results.push({
265
+ id: m.id, from: m.from, to: m.to,
266
+ preview: m.content.substring(0, 200),
267
+ timestamp: m.timestamp,
268
+ ...(m.channel && { channel: m.channel }),
269
+ });
270
+ }
271
+ }
272
+ }
273
+ return { query, results_count: results.length, results, searched: allMessages.length };
274
+ }
275
+
276
+ // --- MCP tool definitions ---
277
+
278
+ const definitions = [
279
+ {
280
+ name: 'check_messages',
281
+ description: 'Non-blocking PEEK at your inbox — shows message previews but does NOT consume them. Use listen() to actually receive and process messages. Do NOT call this in a loop — it wastes tokens returning the same messages repeatedly. Use listen() instead which blocks efficiently and consumes messages.',
282
+ inputSchema: { type: 'object', properties: { from: { type: 'string', description: 'Only check messages from this agent (optional)' } }, additionalProperties: false },
283
+ },
284
+ {
285
+ name: 'consume_messages',
286
+ description: 'Non-blocking check that returns ALL unconsumed messages with full content AND marks them as consumed. Unlike check_messages (peek-only) or listen (blocking), this is a one-shot "grab everything and mark it read" call. Ideal for agents that need to process a batch of messages without blocking.',
287
+ inputSchema: { type: 'object', properties: { from: { type: 'string', description: 'Only consume from this agent (optional)' }, limit: { type: 'number', description: 'Max messages to consume (optional)' } }, additionalProperties: false },
288
+ },
289
+ {
290
+ name: 'ack_message',
291
+ description: 'Acknowledge a message — marks it as seen/received in the history. Appears as a read receipt in the dashboard.',
292
+ inputSchema: { type: 'object', properties: { message_id: { type: 'string', description: 'ID of the message to acknowledge' } }, required: ['message_id'], additionalProperties: false },
293
+ },
294
+ {
295
+ name: 'get_history',
296
+ description: 'Get recent conversation history. Returns messages with acknowledgment status. Filter by thread_id to view a specific conversation thread.',
297
+ inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Number of recent messages (default: 50, max: 500)' }, thread_id: { type: 'string', description: 'Filter by thread ID (optional)' } }, additionalProperties: false },
298
+ },
299
+ {
300
+ name: 'get_notifications',
301
+ description: 'Get unread notifications (task completions, workflow advances, agent status changes). Returns and marks as read. Non-blocking — use this instead of listen() when you need a quick status update without waiting.',
302
+ inputSchema: { type: 'object', properties: { since: { type: 'string', description: 'ISO timestamp — only notifications after this time (optional)' }, type: { type: 'string', description: 'Filter by type: task_done, workflow_advanced, agent_join, etc. (optional)' } }, additionalProperties: false },
303
+ },
304
+ {
305
+ name: 'search_messages',
306
+ description: 'Search conversation history by keyword. Returns matching messages with previews. Useful for finding past discussions, decisions, or code references.',
307
+ inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search term (min 2 chars)' }, from: { type: 'string', description: 'Filter by sender agent name (optional)' }, limit: { type: 'number', description: 'Max results (default: 20, max: 50)' } }, required: ['query'], additionalProperties: false },
308
+ },
309
+ ];
310
+
311
+ const handlers = {
312
+ check_messages: function (args) { return toolCheckMessages(args.from); },
313
+ consume_messages: function (args) { return toolConsumeMessages(args.from, args.limit); },
314
+ ack_message: function (args) { return toolAckMessage(args.message_id); },
315
+ get_history: function (args) { return toolGetHistory(args.limit, args.thread_id); },
316
+ get_notifications: function (args) { return toolGetNotifications(args.since, args.type); },
317
+ search_messages: function (args) { return toolSearchMessages(args.query, args.from, args.limit); },
318
+ };
319
+
320
+ return { definitions, handlers };
321
+ };