@hasna/conversations 0.1.32 → 0.2.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/bin/mcp.js CHANGED
@@ -28939,15 +28939,26 @@ function getUnreadBlockers(agent) {
28939
28939
  `).all(agent, agent);
28940
28940
  return rows.map(parseMessage);
28941
28941
  }
28942
+ function getThreadReplies(messageId) {
28943
+ const db2 = getDb();
28944
+ const rows = db2.prepare("SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC, id ASC").all(messageId);
28945
+ return rows.map(parseMessage);
28946
+ }
28942
28947
  function searchMessages(opts) {
28943
28948
  const db2 = getDb();
28944
28949
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
28950
+ const sortByRelevance = opts.sort !== "recent";
28951
+ const priorityWeights = { urgent: 10, high: 5, normal: 1, low: 0.5 };
28945
28952
  try {
28946
- const ftsConditions = [];
28947
28953
  const ftsParams = [];
28948
- const words = opts.query.trim().split(/\s+/).filter(Boolean);
28949
- const ftsQuery = words.map((w) => `"${w.replace(/"/g, '""')}"`).join(" ");
28950
- ftsConditions.push("messages_fts MATCH ?");
28954
+ const query = opts.query.trim();
28955
+ let ftsQuery;
28956
+ if (query.startsWith('"') && query.endsWith('"')) {
28957
+ ftsQuery = query;
28958
+ } else {
28959
+ const words = query.split(/\s+/).filter(Boolean);
28960
+ ftsQuery = words.map((w) => `"${w.replace(/"/g, '""')}"`).join(" ");
28961
+ }
28951
28962
  ftsParams.push(ftsQuery);
28952
28963
  let extraWhere = "";
28953
28964
  if (opts.space) {
@@ -28962,11 +28973,23 @@ function searchMessages(opts) {
28962
28973
  extraWhere += " AND m.to_agent = ?";
28963
28974
  ftsParams.push(opts.to);
28964
28975
  }
28965
- const rows2 = db2.prepare(`SELECT m.* FROM messages m
28976
+ const orderClause = sortByRelevance ? "ORDER BY rank" : "ORDER BY m.created_at DESC, m.id DESC";
28977
+ const rows2 = db2.prepare(`SELECT m.*, rank,
28978
+ snippet(messages_fts, 0, '**', '**', '...', 20) as snippet
28979
+ FROM messages m
28966
28980
  JOIN messages_fts ON messages_fts.rowid = m.id
28967
- WHERE ${ftsConditions.join(" AND ")}${extraWhere}
28968
- ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit}`).all(...ftsParams);
28969
- return rows2.map(parseMessage);
28981
+ WHERE messages_fts MATCH ?${extraWhere}
28982
+ ${orderClause} LIMIT ${limit}`).all(...ftsParams);
28983
+ const maxRank = rows2.reduce((max, r) => Math.max(max, Math.abs(r.rank || 0)), 0) || 1;
28984
+ return rows2.map((row) => {
28985
+ const msg = parseMessage(row);
28986
+ const ftsScore = maxRank > 0 ? Math.abs(row.rank || 0) / maxRank * 100 : 50;
28987
+ const priorityBoost = priorityWeights[msg.priority] || 1;
28988
+ const pinnedBoost = msg.pinned_at ? 20 : 0;
28989
+ const blockingBoost = msg.blocking ? 15 : 0;
28990
+ const relevance_score = Math.round((ftsScore * priorityBoost + pinnedBoost + blockingBoost) * 100) / 100;
28991
+ return { ...msg, snippet: row.snippet || null, relevance_score };
28992
+ });
28970
28993
  } catch {}
28971
28994
  const conditions = ["content LIKE ?"];
28972
28995
  const params = [`%${opts.query}%`];
@@ -28984,7 +29007,10 @@ function searchMessages(opts) {
28984
29007
  }
28985
29008
  const where = `WHERE ${conditions.join(" AND ")}`;
28986
29009
  const rows = db2.prepare(`SELECT * FROM messages ${where} ORDER BY created_at DESC, id DESC LIMIT ${limit}`).all(...params);
28987
- return rows.map(parseMessage);
29010
+ return rows.map((row) => {
29011
+ const msg = parseMessage(row);
29012
+ return { ...msg, snippet: null, relevance_score: 0 };
29013
+ });
28988
29014
  }
28989
29015
 
28990
29016
  // src/lib/sessions.ts
@@ -29017,6 +29043,32 @@ function listSessions(agent) {
29017
29043
  };
29018
29044
  });
29019
29045
  }
29046
+ function getSessionActivity(sessionId) {
29047
+ const db2 = getDb();
29048
+ const exists = db2.prepare("SELECT 1 FROM messages WHERE session_id = ? LIMIT 1").get(sessionId);
29049
+ if (!exists)
29050
+ return null;
29051
+ const msgsLast1h = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 hour')").get(sessionId).c;
29052
+ const msgsLast24h = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-24 hours')").get(sessionId).c;
29053
+ const uniqueAgents = db2.prepare("SELECT COUNT(DISTINCT from_agent) as c FROM messages WHERE session_id = ?").get(sessionId).c;
29054
+ const totalMsgs = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ?").get(sessionId).c;
29055
+ const replyCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND reply_to IS NOT NULL").get(sessionId).c;
29056
+ const replyRatio = totalMsgs > 0 ? Math.round(replyCount / totalMsgs * 100) / 100 : 0;
29057
+ const priorityRow = db2.prepare("SELECT priority, COUNT(*) as c FROM messages WHERE session_id = ? GROUP BY priority ORDER BY c DESC LIMIT 1").get(sessionId);
29058
+ const reactionCount = db2.prepare("SELECT COUNT(*) as c FROM reactions r JOIN messages m ON r.message_id = m.id WHERE m.session_id = ?").get(sessionId).c;
29059
+ const agentsLast1h = db2.prepare("SELECT COUNT(DISTINCT from_agent) as c FROM messages WHERE session_id = ? AND created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 hour')").get(sessionId).c;
29060
+ const isTrending = msgsLast1h >= 5 || agentsLast1h >= 3;
29061
+ return {
29062
+ session_id: sessionId,
29063
+ msgs_last_1h: msgsLast1h,
29064
+ msgs_last_24h: msgsLast24h,
29065
+ unique_agents: uniqueAgents,
29066
+ reply_ratio: replyRatio,
29067
+ avg_priority: priorityRow?.priority ?? "normal",
29068
+ reaction_count: reactionCount,
29069
+ is_trending: isTrending
29070
+ };
29071
+ }
29020
29072
 
29021
29073
  // src/lib/spaces.ts
29022
29074
  init_db();
@@ -29898,10 +29950,606 @@ function renameAgent(oldName, newName) {
29898
29950
  db2.prepare("UPDATE agent_presence SET agent = ? WHERE LOWER(agent) = ?").run(normalizedNew, normalizedOld);
29899
29951
  return true;
29900
29952
  }
29953
+
29954
+ // src/lib/reactions.ts
29955
+ init_db();
29956
+ function addReaction(messageId, agent, emoji3) {
29957
+ const db2 = getDb();
29958
+ const stmt = db2.prepare(`
29959
+ INSERT INTO reactions (message_id, agent, emoji)
29960
+ VALUES (?, ?, ?)
29961
+ ON CONFLICT (message_id, agent, emoji) DO UPDATE SET agent = agent
29962
+ RETURNING *
29963
+ `);
29964
+ const row = stmt.get(messageId, agent, emoji3);
29965
+ return row;
29966
+ }
29967
+ function removeReaction(messageId, agent, emoji3) {
29968
+ const db2 = getDb();
29969
+ const stmt = db2.prepare("DELETE FROM reactions WHERE message_id = ? AND agent = ? AND emoji = ?");
29970
+ const result = stmt.run(messageId, agent, emoji3);
29971
+ return result.changes > 0;
29972
+ }
29973
+ function getReactions(messageId) {
29974
+ const db2 = getDb();
29975
+ const rows = db2.prepare("SELECT * FROM reactions WHERE message_id = ? ORDER BY created_at ASC, id ASC").all(messageId);
29976
+ return rows;
29977
+ }
29978
+ function getReactionSummary(messageId) {
29979
+ const db2 = getDb();
29980
+ const rows = db2.prepare(`
29981
+ SELECT emoji, GROUP_CONCAT(agent) as agents, COUNT(*) as count
29982
+ FROM reactions
29983
+ WHERE message_id = ?
29984
+ GROUP BY emoji
29985
+ ORDER BY count DESC, MIN(created_at) ASC
29986
+ `).all(messageId);
29987
+ return rows.map((row) => ({
29988
+ emoji: row.emoji,
29989
+ count: row.count,
29990
+ agents: row.agents.split(",")
29991
+ }));
29992
+ }
29993
+
29994
+ // src/lib/locks.ts
29995
+ init_db();
29996
+ var DEFAULT_LOCK_EXPIRY_MS = 5 * 60 * 1000;
29997
+ function acquireLock(resourceType, resourceId, agentId, lockType = "advisory", expiryMs = DEFAULT_LOCK_EXPIRY_MS) {
29998
+ const db2 = getDb();
29999
+ return db2.transaction(() => {
30000
+ cleanExpiredLocks();
30001
+ const existing = db2.prepare(`
30002
+ SELECT * FROM resource_locks
30003
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
30004
+ `).get(resourceType, resourceId, lockType);
30005
+ if (existing) {
30006
+ if (existing.agent_id !== agentId) {
30007
+ return { acquired: false, lock: null, held_by: existing.agent_id };
30008
+ }
30009
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().replace("T", "T").replace("Z", "");
30010
+ db2.prepare(`
30011
+ UPDATE resource_locks SET expires_at = ?, locked_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')
30012
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
30013
+ `).run(expiresAt, resourceType, resourceId, lockType);
30014
+ } else {
30015
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().slice(0, -1);
30016
+ db2.prepare(`
30017
+ INSERT INTO resource_locks (resource_type, resource_id, agent_id, lock_type, locked_at, expires_at)
30018
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'), ?)
30019
+ `).run(resourceType, resourceId, agentId, lockType, expiresAt);
30020
+ }
30021
+ const lock = db2.prepare(`
30022
+ SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
30023
+ `).get(resourceType, resourceId, lockType);
30024
+ return { acquired: true, lock };
30025
+ }).immediate();
30026
+ }
30027
+ function releaseLock(resourceType, resourceId, agentId) {
30028
+ const db2 = getDb();
30029
+ const result = db2.prepare(`
30030
+ DELETE FROM resource_locks
30031
+ WHERE resource_type = ? AND resource_id = ? AND agent_id = ?
30032
+ `).run(resourceType, resourceId, agentId);
30033
+ return result.changes > 0;
30034
+ }
30035
+ function checkLock(resourceType, resourceId) {
30036
+ const db2 = getDb();
30037
+ cleanExpiredLocks();
30038
+ return db2.prepare(`
30039
+ SELECT * FROM resource_locks
30040
+ WHERE resource_type = ? AND resource_id = ?
30041
+ ORDER BY locked_at ASC
30042
+ LIMIT 1
30043
+ `).get(resourceType, resourceId);
30044
+ }
30045
+ function cleanExpiredLocks() {
30046
+ const db2 = getDb();
30047
+ const result = db2.prepare(`
30048
+ DELETE FROM resource_locks WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%f', 'now')
30049
+ `).run();
30050
+ return result.changes;
30051
+ }
30052
+ function listLocks(opts) {
30053
+ const db2 = getDb();
30054
+ cleanExpiredLocks();
30055
+ let query = "SELECT * FROM resource_locks WHERE 1=1";
30056
+ const params = [];
30057
+ if (opts?.resource_type) {
30058
+ query += " AND resource_type = ?";
30059
+ params.push(opts.resource_type);
30060
+ }
30061
+ if (opts?.agent_id) {
30062
+ query += " AND agent_id = ?";
30063
+ params.push(opts.agent_id);
30064
+ }
30065
+ query += " ORDER BY locked_at ASC";
30066
+ return db2.prepare(query).all(...params);
30067
+ }
30068
+
30069
+ // src/lib/hot.ts
30070
+ init_db();
30071
+ function computeHotness(sessionId) {
30072
+ const db2 = getDb();
30073
+ const base = db2.prepare(`
30074
+ SELECT session_id,
30075
+ GROUP_CONCAT(DISTINCT from_agent) as agents,
30076
+ MAX(space) as space,
30077
+ MAX(created_at) as last_message_at,
30078
+ COUNT(*) as message_count
30079
+ FROM messages WHERE session_id = ?
30080
+ GROUP BY session_id
30081
+ `).get(sessionId);
30082
+ if (!base)
30083
+ return null;
30084
+ const msgsLast1h = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-1 hour')").get(sessionId).c;
30085
+ const msgsLast24h = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-24 hours')").get(sessionId).c;
30086
+ const uniqueAgents = db2.prepare("SELECT COUNT(DISTINCT from_agent) as c FROM messages WHERE session_id = ?").get(sessionId).c;
30087
+ const reactionCount = db2.prepare("SELECT COUNT(*) as c FROM reactions r JOIN messages m ON r.message_id = m.id WHERE m.session_id = ?").get(sessionId).c;
30088
+ const replyCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND reply_to IS NOT NULL").get(sessionId).c;
30089
+ const highPriorityCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND priority IN ('high', 'urgent')").get(sessionId).c;
30090
+ const blockerCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND blocking = 1").get(sessionId).c;
30091
+ const lastMsgMs = new Date(base.last_message_at + "Z").getTime();
30092
+ const hoursSinceLast = Math.max(0, (Date.now() - lastMsgMs) / 3600000);
30093
+ const hotness_score = Math.round(msgsLast1h * 3 + uniqueAgents * 5 + reactionCount * 2 + replyCount * 4 + highPriorityCount * 10 + blockerCount * 20 - hoursSinceLast * 2);
30094
+ return {
30095
+ session_id: base.session_id,
30096
+ participants: base.agents.split(","),
30097
+ space: base.space,
30098
+ last_message_at: base.last_message_at,
30099
+ message_count: base.message_count,
30100
+ hotness_score,
30101
+ metrics: {
30102
+ msgs_last_1h: msgsLast1h,
30103
+ msgs_last_24h: msgsLast24h,
30104
+ unique_agents: uniqueAgents,
30105
+ reaction_count: reactionCount,
30106
+ reply_count: replyCount,
30107
+ high_priority_count: highPriorityCount,
30108
+ blocker_count: blockerCount,
30109
+ hours_since_last: Math.round(hoursSinceLast * 10) / 10
30110
+ }
30111
+ };
30112
+ }
30113
+ function listHotSessions(opts) {
30114
+ const db2 = getDb();
30115
+ const limit = opts?.limit ?? 20;
30116
+ const minScore = opts?.min_score ?? 0;
30117
+ let where = "";
30118
+ const params = [];
30119
+ if (opts?.space) {
30120
+ where = " WHERE space = ?";
30121
+ params.push(opts.space);
30122
+ } else if (opts?.project_id) {
30123
+ where = " WHERE project_id = ?";
30124
+ params.push(opts.project_id);
30125
+ }
30126
+ const sessions = db2.prepare(`SELECT session_id, MAX(created_at) as last_at FROM messages${where} GROUP BY session_id ORDER BY last_at DESC LIMIT 100`).all(...params);
30127
+ const hotSessions = [];
30128
+ for (const { session_id } of sessions) {
30129
+ const hot = computeHotness(session_id);
30130
+ if (hot && hot.hotness_score >= minScore) {
30131
+ hotSessions.push(hot);
30132
+ }
30133
+ }
30134
+ hotSessions.sort((a, b) => b.hotness_score - a.hotness_score);
30135
+ return hotSessions.slice(0, limit);
30136
+ }
30137
+
30138
+ // src/lib/topics.ts
30139
+ init_db();
30140
+ var STOPWORDS = new Set([
30141
+ "a",
30142
+ "an",
30143
+ "the",
30144
+ "and",
30145
+ "or",
30146
+ "but",
30147
+ "in",
30148
+ "on",
30149
+ "at",
30150
+ "to",
30151
+ "for",
30152
+ "of",
30153
+ "with",
30154
+ "by",
30155
+ "from",
30156
+ "is",
30157
+ "it",
30158
+ "this",
30159
+ "that",
30160
+ "are",
30161
+ "was",
30162
+ "were",
30163
+ "be",
30164
+ "been",
30165
+ "being",
30166
+ "have",
30167
+ "has",
30168
+ "had",
30169
+ "do",
30170
+ "does",
30171
+ "did",
30172
+ "will",
30173
+ "would",
30174
+ "could",
30175
+ "should",
30176
+ "may",
30177
+ "might",
30178
+ "shall",
30179
+ "can",
30180
+ "need",
30181
+ "not",
30182
+ "no",
30183
+ "so",
30184
+ "if",
30185
+ "then",
30186
+ "than",
30187
+ "too",
30188
+ "very",
30189
+ "just",
30190
+ "about",
30191
+ "up",
30192
+ "out",
30193
+ "all",
30194
+ "also",
30195
+ "as",
30196
+ "into",
30197
+ "only",
30198
+ "other",
30199
+ "each",
30200
+ "every",
30201
+ "both",
30202
+ "few",
30203
+ "more",
30204
+ "most",
30205
+ "some",
30206
+ "such",
30207
+ "any",
30208
+ "over",
30209
+ "after",
30210
+ "before",
30211
+ "between",
30212
+ "under",
30213
+ "above",
30214
+ "here",
30215
+ "there",
30216
+ "when",
30217
+ "where",
30218
+ "how",
30219
+ "what",
30220
+ "which",
30221
+ "who",
30222
+ "whom",
30223
+ "why",
30224
+ "its",
30225
+ "my",
30226
+ "your",
30227
+ "his",
30228
+ "her",
30229
+ "our",
30230
+ "their",
30231
+ "we",
30232
+ "you",
30233
+ "he",
30234
+ "she",
30235
+ "they",
30236
+ "i",
30237
+ "me",
30238
+ "him",
30239
+ "us",
30240
+ "them",
30241
+ "now",
30242
+ "new",
30243
+ "get",
30244
+ "got",
30245
+ "go",
30246
+ "going",
30247
+ "done",
30248
+ "make",
30249
+ "made",
30250
+ "see",
30251
+ "know",
30252
+ "think",
30253
+ "want",
30254
+ "one",
30255
+ "two",
30256
+ "like",
30257
+ "still",
30258
+ "back",
30259
+ "even"
30260
+ ]);
30261
+ function extractTopics(text, topN = 10) {
30262
+ const cleaned = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]+`/g, " ").replace(/https?:\/\/\S+/g, " ").replace(/[#*_~|>\[\](){}]/g, " ").replace(/\d+/g, " ").toLowerCase();
30263
+ const words = cleaned.split(/\s+/).filter((w) => w.length >= 3 && !STOPWORDS.has(w) && /^[a-z]/.test(w));
30264
+ const freq = new Map;
30265
+ for (const w of words) {
30266
+ freq.set(w, (freq.get(w) || 0) + 1);
30267
+ }
30268
+ const totalWords = words.length || 1;
30269
+ return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN).map(([topic, count]) => ({
30270
+ topic,
30271
+ weight: Math.round(count / totalWords * 1000) / 1000,
30272
+ count
30273
+ }));
30274
+ }
30275
+ function getSpaceTopics(spaceName, opts) {
30276
+ const db2 = getDb();
30277
+ const limit = opts?.limit ?? 100;
30278
+ const sinceClause = opts?.since ? "AND created_at > ?" : "";
30279
+ const params = [spaceName];
30280
+ if (opts?.since)
30281
+ params.push(opts.since);
30282
+ const rows = db2.prepare(`SELECT content FROM messages WHERE space = ? ${sinceClause} ORDER BY created_at DESC LIMIT ${limit}`).all(...params);
30283
+ const combined = rows.map((r) => r.content).join(`
30284
+ `);
30285
+ return extractTopics(combined, 15);
30286
+ }
30287
+ function getSessionTopics(sessionId, opts) {
30288
+ const db2 = getDb();
30289
+ const limit = opts?.limit ?? 100;
30290
+ const rows = db2.prepare(`SELECT content FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ${limit}`).all(sessionId);
30291
+ const combined = rows.map((r) => r.content).join(`
30292
+ `);
30293
+ return extractTopics(combined, 15);
30294
+ }
30295
+ function getTrendingTopics(opts) {
30296
+ const db2 = getDb();
30297
+ const hours = opts?.hours ?? 24;
30298
+ const topN = opts?.top_n ?? 20;
30299
+ let where = `WHERE created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-${hours} hours')`;
30300
+ const params = [];
30301
+ if (opts?.project_id) {
30302
+ where += " AND project_id = ?";
30303
+ params.push(opts.project_id);
30304
+ }
30305
+ const rows = db2.prepare(`SELECT content FROM messages ${where} ORDER BY created_at DESC LIMIT 500`).all(...params);
30306
+ const combined = rows.map((r) => r.content).join(`
30307
+ `);
30308
+ return extractTopics(combined, topN);
30309
+ }
30310
+
30311
+ // src/lib/summary.ts
30312
+ init_db();
30313
+ function getConversationSummary(sessionOrSpace, opts) {
30314
+ const db2 = getDb();
30315
+ const limit = opts?.limit ?? 50;
30316
+ const isSpace = sessionOrSpace.startsWith("space:") || db2.prepare("SELECT 1 FROM spaces WHERE name = ?").get(sessionOrSpace);
30317
+ const filterCol = isSpace ? "space" : "session_id";
30318
+ const filterVal = isSpace && !sessionOrSpace.startsWith("space:") ? sessionOrSpace : sessionOrSpace;
30319
+ const messages = db2.prepare(`SELECT * FROM messages WHERE ${filterCol} = ? ORDER BY created_at DESC LIMIT ${limit}`).all(filterVal);
30320
+ if (messages.length === 0)
30321
+ return null;
30322
+ const agents = new Set;
30323
+ for (const m of messages) {
30324
+ agents.add(m.from_agent);
30325
+ if (m.to_agent)
30326
+ agents.add(m.to_agent);
30327
+ }
30328
+ const dates = messages.map((m) => m.created_at).sort();
30329
+ const dateRange = { first: dates[0], last: dates[dates.length - 1] };
30330
+ const allContent = messages.map((m) => m.content).join(`
30331
+ `);
30332
+ const topics = extractTopics(allContent, 10);
30333
+ const keyMessages = [];
30334
+ for (const m of messages) {
30335
+ const priority = m.priority;
30336
+ if (priority === "high" || priority === "urgent") {
30337
+ keyMessages.push({
30338
+ id: m.id,
30339
+ from: m.from_agent,
30340
+ content: m.content.slice(0, 200),
30341
+ reason: `${priority} priority`
30342
+ });
30343
+ }
30344
+ if (m.blocking) {
30345
+ keyMessages.push({
30346
+ id: m.id,
30347
+ from: m.from_agent,
30348
+ content: m.content.slice(0, 200),
30349
+ reason: "blocking message"
30350
+ });
30351
+ }
30352
+ }
30353
+ for (const m of messages) {
30354
+ if (m.pinned_at) {
30355
+ keyMessages.push({
30356
+ id: m.id,
30357
+ from: m.from_agent,
30358
+ content: m.content.slice(0, 200),
30359
+ reason: "pinned"
30360
+ });
30361
+ }
30362
+ }
30363
+ const msgIds = messages.map((m) => m.id);
30364
+ if (msgIds.length > 0) {
30365
+ const placeholders = msgIds.map(() => "?").join(",");
30366
+ const reacted = db2.prepare(`SELECT message_id, COUNT(*) as c FROM reactions WHERE message_id IN (${placeholders}) GROUP BY message_id ORDER BY c DESC LIMIT 3`).all(...msgIds);
30367
+ for (const r of reacted) {
30368
+ const m = messages.find((msg) => msg.id === r.message_id);
30369
+ if (m) {
30370
+ keyMessages.push({
30371
+ id: r.message_id,
30372
+ from: m.from_agent,
30373
+ content: m.content.slice(0, 200),
30374
+ reason: `${r.c} reaction(s)`
30375
+ });
30376
+ }
30377
+ }
30378
+ }
30379
+ const seen = new Set;
30380
+ const uniqueKey = keyMessages.filter((k) => {
30381
+ if (seen.has(k.id))
30382
+ return false;
30383
+ seen.add(k.id);
30384
+ return true;
30385
+ }).slice(0, 10);
30386
+ const blockers = messages.filter((m) => m.blocking && !m.read_at).map((m) => ({
30387
+ id: m.id,
30388
+ from: m.from_agent,
30389
+ content: m.content.slice(0, 200),
30390
+ created_at: m.created_at
30391
+ }));
30392
+ const replyCount = messages.filter((m) => m.reply_to).length;
30393
+ const reactionCount = msgIds.length > 0 ? db2.prepare(`SELECT COUNT(*) as c FROM reactions WHERE message_id IN (${msgIds.map(() => "?").join(",")})`).get(...msgIds).c : 0;
30394
+ const priorityCounts = {};
30395
+ for (const m of messages) {
30396
+ const p = m.priority;
30397
+ priorityCounts[p] = (priorityCounts[p] || 0) + 1;
30398
+ }
30399
+ const avgPriority = Object.entries(priorityCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "normal";
30400
+ return {
30401
+ session_id: sessionOrSpace,
30402
+ participants: [...agents].filter((a) => a !== sessionOrSpace),
30403
+ message_count: messages.length,
30404
+ date_range: dateRange,
30405
+ topics,
30406
+ key_messages: uniqueKey,
30407
+ unresolved_blockers: blockers,
30408
+ activity: {
30409
+ reply_count: replyCount,
30410
+ reaction_count: reactionCount,
30411
+ avg_priority: avgPriority
30412
+ }
30413
+ };
30414
+ }
30415
+
30416
+ // src/lib/graph.ts
30417
+ init_db();
30418
+ function ensureGraphTable() {
30419
+ const db2 = getDb();
30420
+ db2.exec(`
30421
+ CREATE TABLE IF NOT EXISTS graph_edges (
30422
+ from_type TEXT NOT NULL,
30423
+ from_id TEXT NOT NULL,
30424
+ to_type TEXT NOT NULL,
30425
+ to_id TEXT NOT NULL,
30426
+ relation TEXT NOT NULL,
30427
+ weight REAL NOT NULL DEFAULT 1,
30428
+ metadata TEXT,
30429
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
30430
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
30431
+ UNIQUE(from_type, from_id, to_type, to_id, relation)
30432
+ )
30433
+ `);
30434
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_from ON graph_edges(from_type, from_id)");
30435
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_to ON graph_edges(to_type, to_id)");
30436
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_relation ON graph_edges(relation)");
30437
+ }
30438
+ function buildGraph() {
30439
+ const db2 = getDb();
30440
+ ensureGraphTable();
30441
+ let created = 0;
30442
+ let updated = 0;
30443
+ const upsert = db2.prepare(`
30444
+ INSERT INTO graph_edges (from_type, from_id, to_type, to_id, relation, weight, updated_at)
30445
+ VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))
30446
+ ON CONFLICT(from_type, from_id, to_type, to_id, relation) DO UPDATE SET
30447
+ weight = excluded.weight,
30448
+ updated_at = excluded.updated_at
30449
+ `);
30450
+ const insertOrUpdate = db2.transaction(() => {
30451
+ const dmPairs = db2.prepare(`
30452
+ SELECT from_agent, to_agent, COUNT(*) as cnt, MAX(created_at) as last_at
30453
+ FROM messages WHERE space IS NULL AND from_agent != to_agent
30454
+ GROUP BY from_agent, to_agent
30455
+ `).all();
30456
+ for (const pair of dmPairs) {
30457
+ const existing = db2.prepare("SELECT 1 FROM graph_edges WHERE from_type='agent' AND from_id=? AND to_type='agent' AND to_id=? AND relation='communicates_with'").get(pair.from_agent, pair.to_agent);
30458
+ upsert.run("agent", pair.from_agent, "agent", pair.to_agent, "communicates_with", pair.cnt);
30459
+ if (existing)
30460
+ updated++;
30461
+ else
30462
+ created++;
30463
+ }
30464
+ const spacePosts = db2.prepare(`
30465
+ SELECT from_agent, space, COUNT(*) as cnt
30466
+ FROM messages WHERE space IS NOT NULL
30467
+ GROUP BY from_agent, space
30468
+ `).all();
30469
+ for (const sp of spacePosts) {
30470
+ const existing = db2.prepare("SELECT 1 FROM graph_edges WHERE from_type='agent' AND from_id=? AND to_type='space' AND to_id=? AND relation='posts_in'").get(sp.from_agent, sp.space);
30471
+ upsert.run("agent", sp.from_agent, "space", sp.space, "posts_in", sp.cnt);
30472
+ if (existing)
30473
+ updated++;
30474
+ else
30475
+ created++;
30476
+ }
30477
+ const members = db2.prepare("SELECT agent, space FROM space_members").all();
30478
+ for (const m of members) {
30479
+ const existing = db2.prepare("SELECT 1 FROM graph_edges WHERE from_type='agent' AND from_id=? AND to_type='space' AND to_id=? AND relation='member_of'").get(m.agent, m.space);
30480
+ upsert.run("agent", m.agent, "space", m.space, "member_of", 1);
30481
+ if (existing)
30482
+ updated++;
30483
+ else
30484
+ created++;
30485
+ }
30486
+ const spaceProjects = db2.prepare("SELECT name, project_id FROM spaces WHERE project_id IS NOT NULL").all();
30487
+ for (const sp of spaceProjects) {
30488
+ const existing = db2.prepare("SELECT 1 FROM graph_edges WHERE from_type='space' AND from_id=? AND to_type='project' AND to_id=? AND relation='belongs_to'").get(sp.name, sp.project_id);
30489
+ upsert.run("space", sp.name, "project", sp.project_id, "belongs_to", 1);
30490
+ if (existing)
30491
+ updated++;
30492
+ else
30493
+ created++;
30494
+ }
30495
+ });
30496
+ insertOrUpdate();
30497
+ return { edges_created: created, edges_updated: updated };
30498
+ }
30499
+ function getRelated(entityType, entityId) {
30500
+ const db2 = getDb();
30501
+ ensureGraphTable();
30502
+ const outgoing = db2.prepare(`
30503
+ SELECT to_type as type, to_id as id, relation, weight FROM graph_edges
30504
+ WHERE from_type = ? AND from_id = ? ORDER BY weight DESC
30505
+ `).all(entityType, entityId);
30506
+ const incoming = db2.prepare(`
30507
+ SELECT from_type as type, from_id as id, relation, weight FROM graph_edges
30508
+ WHERE to_type = ? AND to_id = ? ORDER BY weight DESC
30509
+ `).all(entityType, entityId);
30510
+ return [...outgoing, ...incoming];
30511
+ }
30512
+ function getAgentNetwork(agent) {
30513
+ const db2 = getDb();
30514
+ ensureGraphTable();
30515
+ const comms = db2.prepare(`
30516
+ SELECT to_id as agent, weight as message_count,
30517
+ (SELECT MAX(created_at) FROM messages WHERE from_agent = ? AND to_agent = ge.to_id AND space IS NULL) as last_at
30518
+ FROM graph_edges ge
30519
+ WHERE from_type = 'agent' AND from_id = ? AND relation = 'communicates_with'
30520
+ ORDER BY weight DESC LIMIT 20
30521
+ `).all(agent, agent);
30522
+ const spaces = db2.prepare(`
30523
+ SELECT to_id as space, weight as message_count FROM graph_edges
30524
+ WHERE from_type = 'agent' AND from_id = ? AND relation = 'posts_in'
30525
+ ORDER BY weight DESC LIMIT 20
30526
+ `).all(agent);
30527
+ const projects = db2.prepare(`
30528
+ SELECT DISTINCT g2.to_id FROM graph_edges g1
30529
+ JOIN graph_edges g2 ON g1.to_type = 'space' AND g1.to_id = g2.from_id AND g2.relation = 'belongs_to'
30530
+ WHERE g1.from_type = 'agent' AND g1.from_id = ? AND g1.relation IN ('member_of', 'posts_in')
30531
+ `).all(agent);
30532
+ return {
30533
+ agent,
30534
+ communicates_with: comms,
30535
+ spaces,
30536
+ projects: projects.map((p) => p.to_id)
30537
+ };
30538
+ }
30539
+ function getGraphStats() {
30540
+ const db2 = getDb();
30541
+ ensureGraphTable();
30542
+ const total = db2.prepare("SELECT COUNT(*) as c FROM graph_edges").get().c;
30543
+ const byRelation = db2.prepare("SELECT relation, COUNT(*) as c FROM graph_edges GROUP BY relation ORDER BY c DESC").all();
30544
+ const map3 = {};
30545
+ for (const r of byRelation)
30546
+ map3[r.relation] = r.c;
30547
+ return { total_edges: total, by_relation: map3 };
30548
+ }
29901
30549
  // package.json
29902
30550
  var package_default = {
29903
30551
  name: "@hasna/conversations",
29904
- version: "0.1.32",
30552
+ version: "0.2.0",
29905
30553
  description: "Real-time CLI messaging for AI agents",
29906
30554
  type: "module",
29907
30555
  bin: {
@@ -30630,6 +31278,207 @@ server.registerTool("get_pinned_messages", {
30630
31278
  content: [{ type: "text", text: JSON.stringify(messages) }]
30631
31279
  };
30632
31280
  });
31281
+ server.registerTool("build_graph", {
31282
+ description: "Build/rebuild the knowledge graph from messages, spaces, and projects. Creates relationship edges between agents, spaces, and projects.",
31283
+ inputSchema: {}
31284
+ }, async () => {
31285
+ const result = buildGraph();
31286
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
31287
+ });
31288
+ server.registerTool("get_related", {
31289
+ description: "Find all entities related to a given entity in the knowledge graph.",
31290
+ inputSchema: {
31291
+ entity_type: exports_external.string(),
31292
+ entity_id: exports_external.string()
31293
+ }
31294
+ }, async (args) => {
31295
+ const related = getRelated(args.entity_type, args.entity_id);
31296
+ return { content: [{ type: "text", text: JSON.stringify(related) }] };
31297
+ });
31298
+ server.registerTool("get_agent_network", {
31299
+ description: "Get an agent's communication network: who they talk to, spaces, projects.",
31300
+ inputSchema: {
31301
+ agent: exports_external.string()
31302
+ }
31303
+ }, async (args) => {
31304
+ const network = getAgentNetwork(args.agent);
31305
+ return { content: [{ type: "text", text: JSON.stringify(network) }] };
31306
+ });
31307
+ server.registerTool("graph_stats", {
31308
+ description: "Get knowledge graph statistics: total edges and counts by relation type.",
31309
+ inputSchema: {}
31310
+ }, async () => {
31311
+ const stats = getGraphStats();
31312
+ return { content: [{ type: "text", text: JSON.stringify(stats) }] };
31313
+ });
31314
+ server.registerTool("get_summary", {
31315
+ description: "Get a structured summary of a conversation (session or space): participants, topics, key messages, blockers, activity.",
31316
+ inputSchema: {
31317
+ session_id: exports_external.string().optional(),
31318
+ space: exports_external.string().optional(),
31319
+ limit: exports_external.coerce.number().optional()
31320
+ }
31321
+ }, async (args) => {
31322
+ const target = args.space || args.session_id;
31323
+ if (!target)
31324
+ return { content: [{ type: "text", text: "session_id or space required" }], isError: true };
31325
+ const summary = getConversationSummary(target, { limit: args.limit });
31326
+ if (!summary)
31327
+ return { content: [{ type: "text", text: `No messages found for "${target}"` }], isError: true };
31328
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
31329
+ });
31330
+ server.registerTool("get_topics", {
31331
+ description: "Extract topics from a space or session. Returns weighted keyword list.",
31332
+ inputSchema: {
31333
+ space: exports_external.string().optional(),
31334
+ session_id: exports_external.string().optional(),
31335
+ limit: exports_external.coerce.number().optional()
31336
+ }
31337
+ }, async (args) => {
31338
+ const topics = args.space ? getSpaceTopics(args.space, { limit: args.limit }) : args.session_id ? getSessionTopics(args.session_id, { limit: args.limit }) : getTrendingTopics({ top_n: args.limit });
31339
+ return { content: [{ type: "text", text: JSON.stringify(topics) }] };
31340
+ });
31341
+ server.registerTool("trending_topics", {
31342
+ description: "Get trending topics across all messages in the last N hours.",
31343
+ inputSchema: {
31344
+ hours: exports_external.coerce.number().optional(),
31345
+ project_id: exports_external.string().optional(),
31346
+ top_n: exports_external.coerce.number().optional()
31347
+ }
31348
+ }, async (args) => {
31349
+ const topics = getTrendingTopics({ hours: args.hours, project_id: args.project_id, top_n: args.top_n });
31350
+ return { content: [{ type: "text", text: JSON.stringify(topics) }] };
31351
+ });
31352
+ server.registerTool("get_session_activity", {
31353
+ description: "Get activity metrics for a session: message velocity, unique agents, reply ratio, reaction count, trending status.",
31354
+ inputSchema: {
31355
+ session_id: exports_external.string()
31356
+ }
31357
+ }, async (args) => {
31358
+ const activity = getSessionActivity(args.session_id);
31359
+ if (!activity) {
31360
+ return { content: [{ type: "text", text: `session "${args.session_id}" not found` }], isError: true };
31361
+ }
31362
+ return { content: [{ type: "text", text: JSON.stringify(activity) }] };
31363
+ });
31364
+ server.registerTool("hot_sessions", {
31365
+ description: "List conversations ranked by activity hotness (message velocity, reactions, replies, priority, blockers).",
31366
+ inputSchema: {
31367
+ limit: exports_external.coerce.number().optional(),
31368
+ min_score: exports_external.coerce.number().optional(),
31369
+ space: exports_external.string().optional(),
31370
+ project_id: exports_external.string().optional()
31371
+ }
31372
+ }, async (args) => {
31373
+ const sessions = listHotSessions({
31374
+ limit: args.limit,
31375
+ min_score: args.min_score,
31376
+ space: args.space,
31377
+ project_id: args.project_id
31378
+ });
31379
+ return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
31380
+ });
31381
+ server.registerTool("add_reaction", {
31382
+ description: "Add an emoji reaction to a message.",
31383
+ inputSchema: {
31384
+ message_id: exports_external.coerce.number(),
31385
+ emoji: exports_external.string(),
31386
+ from: exports_external.string().optional()
31387
+ }
31388
+ }, async (args) => {
31389
+ const { message_id, emoji: emoji3, from: fromParam } = args;
31390
+ const agent = resolveIdentity(fromParam);
31391
+ const reaction = addReaction(message_id, agent, emoji3);
31392
+ return { content: [{ type: "text", text: JSON.stringify(reaction) }] };
31393
+ });
31394
+ server.registerTool("remove_reaction", {
31395
+ description: "Remove an emoji reaction from a message.",
31396
+ inputSchema: {
31397
+ message_id: exports_external.coerce.number(),
31398
+ emoji: exports_external.string(),
31399
+ from: exports_external.string().optional()
31400
+ }
31401
+ }, async (args) => {
31402
+ const { message_id, emoji: emoji3, from: fromParam } = args;
31403
+ const agent = resolveIdentity(fromParam);
31404
+ const removed = removeReaction(message_id, agent, emoji3);
31405
+ return { content: [{ type: "text", text: JSON.stringify({ removed }) }] };
31406
+ });
31407
+ server.registerTool("get_reactions", {
31408
+ description: "Get all reactions for a message.",
31409
+ inputSchema: {
31410
+ message_id: exports_external.coerce.number()
31411
+ }
31412
+ }, async (args) => {
31413
+ const reactions = getReactions(args.message_id);
31414
+ return { content: [{ type: "text", text: JSON.stringify(reactions) }] };
31415
+ });
31416
+ server.registerTool("get_reaction_summary", {
31417
+ description: "Get emoji reaction counts and agent lists for a message.",
31418
+ inputSchema: {
31419
+ message_id: exports_external.coerce.number()
31420
+ }
31421
+ }, async (args) => {
31422
+ const summary = getReactionSummary(args.message_id);
31423
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
31424
+ });
31425
+ server.registerTool("acquire_lock", {
31426
+ description: "Acquire an advisory or exclusive lock on a resource. Returns conflict info if another agent holds the lock.",
31427
+ inputSchema: {
31428
+ resource_type: exports_external.string(),
31429
+ resource_id: exports_external.string(),
31430
+ lock_type: exports_external.enum(["advisory", "exclusive"]).optional(),
31431
+ expiry_ms: exports_external.coerce.number().optional(),
31432
+ from: exports_external.string().optional()
31433
+ }
31434
+ }, async (args) => {
31435
+ const { resource_type, resource_id, lock_type, expiry_ms, from: fromParam } = args;
31436
+ const agent = resolveIdentity(fromParam);
31437
+ const result = acquireLock(resource_type, resource_id, agent, lock_type ?? "advisory", expiry_ms);
31438
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
31439
+ });
31440
+ server.registerTool("release_lock", {
31441
+ description: "Release a lock held by the agent on a resource.",
31442
+ inputSchema: {
31443
+ resource_type: exports_external.string(),
31444
+ resource_id: exports_external.string(),
31445
+ from: exports_external.string().optional()
31446
+ }
31447
+ }, async (args) => {
31448
+ const { resource_type, resource_id, from: fromParam } = args;
31449
+ const agent = resolveIdentity(fromParam);
31450
+ const released = releaseLock(resource_type, resource_id, agent);
31451
+ return { content: [{ type: "text", text: JSON.stringify({ released }) }] };
31452
+ });
31453
+ server.registerTool("check_lock", {
31454
+ description: "Check if a resource is currently locked and who holds it.",
31455
+ inputSchema: {
31456
+ resource_type: exports_external.string(),
31457
+ resource_id: exports_external.string()
31458
+ }
31459
+ }, async (args) => {
31460
+ const lock = checkLock(args.resource_type, args.resource_id);
31461
+ return { content: [{ type: "text", text: JSON.stringify(lock ?? { locked: false }) }] };
31462
+ });
31463
+ server.registerTool("list_locks", {
31464
+ description: "List all active (non-expired) locks. Filter by resource_type or agent.",
31465
+ inputSchema: {
31466
+ resource_type: exports_external.string().optional(),
31467
+ agent_id: exports_external.string().optional()
31468
+ }
31469
+ }, async (args) => {
31470
+ const locks = listLocks({ resource_type: args.resource_type, agent_id: args.agent_id });
31471
+ return { content: [{ type: "text", text: JSON.stringify(locks) }] };
31472
+ });
31473
+ server.registerTool("get_thread_replies", {
31474
+ description: "Get all replies in a thread for a given parent message ID.",
31475
+ inputSchema: {
31476
+ message_id: exports_external.coerce.number()
31477
+ }
31478
+ }, async (args) => {
31479
+ const replies = getThreadReplies(args.message_id);
31480
+ return { content: [{ type: "text", text: JSON.stringify(replies) }] };
31481
+ });
30633
31482
  server.registerTool("set_focus", {
30634
31483
  description: "Set agent focus to a project. All read-heavy tools will default to this project scope. Stores in MCP session memory AND updates agent_presence.project_id in DB.",
30635
31484
  inputSchema: {
@@ -30828,6 +31677,24 @@ server.registerTool("search_tools", {
30828
31677
  "pin_message",
30829
31678
  "unpin_message",
30830
31679
  "get_pinned_messages",
31680
+ "build_graph",
31681
+ "get_related",
31682
+ "get_agent_network",
31683
+ "graph_stats",
31684
+ "get_summary",
31685
+ "get_topics",
31686
+ "trending_topics",
31687
+ "get_session_activity",
31688
+ "hot_sessions",
31689
+ "add_reaction",
31690
+ "remove_reaction",
31691
+ "get_reactions",
31692
+ "get_reaction_summary",
31693
+ "acquire_lock",
31694
+ "release_lock",
31695
+ "check_lock",
31696
+ "list_locks",
31697
+ "get_thread_replies",
30831
31698
  "set_focus",
30832
31699
  "get_focus",
30833
31700
  "unfocus",
@@ -30877,6 +31744,24 @@ server.registerTool("describe_tools", {
30877
31744
  pin_message: "Pin a message. Required: id",
30878
31745
  unpin_message: "Unpin a message. Required: id",
30879
31746
  get_pinned_messages: "Get pinned messages. Optional: space?, session_id?, limit?",
31747
+ build_graph: "Build/rebuild knowledge graph from messages, spaces, projects. Returns edge counts.",
31748
+ get_related: "Find entities related to a given entity. Required: entity_type, entity_id",
31749
+ get_agent_network: "Agent's communication network: contacts, spaces, projects. Required: agent",
31750
+ graph_stats: "Knowledge graph stats: total edges, by relation type",
31751
+ get_summary: "Structured conversation summary: participants, topics, key messages, blockers. Required: session_id? or space?. Optional: limit?",
31752
+ get_topics: "Extract topics from space or session. Optional: space?, session_id?, limit?",
31753
+ trending_topics: "Trending topics across all messages. Optional: hours?, project_id?, top_n?",
31754
+ get_session_activity: "Get activity metrics for a session: velocity, agents, reply ratio, reactions, trending. Required: session_id",
31755
+ hot_sessions: "List conversations by hotness score (velocity, reactions, replies, priority, blockers). Optional: limit?, min_score?, space?, project_id?",
31756
+ add_reaction: "Add emoji reaction to a message. Required: message_id, emoji. Optional: from?",
31757
+ remove_reaction: "Remove emoji reaction from a message. Required: message_id, emoji. Optional: from?",
31758
+ get_reactions: "Get all reactions for a message. Required: message_id",
31759
+ get_reaction_summary: "Get emoji counts + agent lists for a message. Required: message_id",
31760
+ acquire_lock: "Acquire advisory/exclusive lock on a resource. Required: resource_type, resource_id. Optional: lock_type?(advisory|exclusive), expiry_ms?, from?",
31761
+ release_lock: "Release lock held by agent. Required: resource_type, resource_id. Optional: from?",
31762
+ check_lock: "Check if resource is locked and who holds it. Required: resource_type, resource_id",
31763
+ list_locks: "List active locks. Optional: resource_type?, agent_id?",
31764
+ get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
30880
31765
  set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
30881
31766
  get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
30882
31767
  unfocus: "Clear agent focus (session + DB). Optional: from?",