@hasna/conversations 0.2.4 → 0.2.6
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/hook.js +14 -0
- package/bin/index.js +170 -18
- package/bin/mcp.js +170 -18
- package/dist/index.js +53 -0
- package/dist/lib/messages.d.ts +21 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/bin/hook.js
CHANGED
|
@@ -252,6 +252,20 @@ function getDb() {
|
|
|
252
252
|
if (!presenceColNames.includes("project_id")) {
|
|
253
253
|
db.exec("ALTER TABLE agent_presence ADD COLUMN project_id TEXT");
|
|
254
254
|
}
|
|
255
|
+
db.exec(`
|
|
256
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
257
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
258
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
259
|
+
mentioned_agent TEXT NOT NULL,
|
|
260
|
+
from_agent TEXT NOT NULL,
|
|
261
|
+
space TEXT,
|
|
262
|
+
notified_at TEXT,
|
|
263
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now'))
|
|
264
|
+
)
|
|
265
|
+
`);
|
|
266
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_agent ON message_mentions(mentioned_agent)");
|
|
267
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions(message_id)");
|
|
268
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_notified ON message_mentions(notified_at)");
|
|
255
269
|
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
256
270
|
if (!ftsExists) {
|
|
257
271
|
db.exec(`
|
package/bin/index.js
CHANGED
|
@@ -2117,6 +2117,20 @@ function getDb() {
|
|
|
2117
2117
|
if (!presenceColNames.includes("project_id")) {
|
|
2118
2118
|
db.exec("ALTER TABLE agent_presence ADD COLUMN project_id TEXT");
|
|
2119
2119
|
}
|
|
2120
|
+
db.exec(`
|
|
2121
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
2122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2123
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
2124
|
+
mentioned_agent TEXT NOT NULL,
|
|
2125
|
+
from_agent TEXT NOT NULL,
|
|
2126
|
+
space TEXT,
|
|
2127
|
+
notified_at TEXT,
|
|
2128
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now'))
|
|
2129
|
+
)
|
|
2130
|
+
`);
|
|
2131
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_agent ON message_mentions(mentioned_agent)");
|
|
2132
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions(message_id)");
|
|
2133
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_notified ON message_mentions(notified_at)");
|
|
2120
2134
|
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
2121
2135
|
if (!ftsExists) {
|
|
2122
2136
|
db.exec(`
|
|
@@ -2330,6 +2344,12 @@ function sendMessage(opts) {
|
|
|
2330
2344
|
db2.prepare("UPDATE messages SET attachments = ? WHERE id = ?").run(attachmentsJson, message.id);
|
|
2331
2345
|
message.attachments = attachmentInfos;
|
|
2332
2346
|
}
|
|
2347
|
+
if (opts.space) {
|
|
2348
|
+
const mentions = parseMentions(opts.content);
|
|
2349
|
+
if (mentions.length > 0) {
|
|
2350
|
+
processMentions(message.id, opts.from, opts.space, mentions, db2);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2333
2353
|
fireWebhooks(message);
|
|
2334
2354
|
return message;
|
|
2335
2355
|
}
|
|
@@ -2368,11 +2388,24 @@ function readMessages(opts = {}) {
|
|
|
2368
2388
|
if (opts.unread_only) {
|
|
2369
2389
|
conditions.push("read_at IS NULL");
|
|
2370
2390
|
}
|
|
2391
|
+
if (opts.threads_only) {
|
|
2392
|
+
conditions.push("reply_to IS NULL");
|
|
2393
|
+
}
|
|
2394
|
+
if (opts.mentions_only) {
|
|
2395
|
+
conditions.push(`id IN (SELECT message_id FROM message_mentions WHERE mentioned_agent = ?)`);
|
|
2396
|
+
params.push(opts.mentions_only.toLowerCase());
|
|
2397
|
+
}
|
|
2371
2398
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2372
2399
|
const resolvedLimit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
|
|
2373
2400
|
const order = opts.order?.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
2374
2401
|
const rows = db2.prepare(`SELECT * FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT ${resolvedLimit}`).all(...params);
|
|
2375
2402
|
let messages = rows.map(parseMessage);
|
|
2403
|
+
if (opts.include_reply_counts && messages.length > 0) {
|
|
2404
|
+
const db22 = getDb();
|
|
2405
|
+
const counts = db22.prepare(`SELECT reply_to, COUNT(*) as c FROM messages WHERE reply_to IN (${messages.map(() => "?").join(",")}) GROUP BY reply_to`).all(...messages.map((m) => m.id));
|
|
2406
|
+
const countMap = new Map(counts.map((r) => [r.reply_to, r.c]));
|
|
2407
|
+
messages = messages.map((m) => ({ ...m, reply_count: countMap.get(m.id) ?? 0 }));
|
|
2408
|
+
}
|
|
2376
2409
|
if (opts.max_content_length && opts.max_content_length > 0) {
|
|
2377
2410
|
messages = messages.map((m) => {
|
|
2378
2411
|
if (m.content.length > opts.max_content_length) {
|
|
@@ -2685,6 +2718,73 @@ function listUnreadCounts(agent) {
|
|
|
2685
2718
|
`).all();
|
|
2686
2719
|
return rows;
|
|
2687
2720
|
}
|
|
2721
|
+
function parseMentions(content) {
|
|
2722
|
+
const matches = content.match(/@([a-zA-Z0-9_-]+)/g) ?? [];
|
|
2723
|
+
return [...new Set(matches.map((m) => m.slice(1).toLowerCase()))];
|
|
2724
|
+
}
|
|
2725
|
+
async function processMentions(messageId, fromAgent, space, mentionedAgents, db2) {
|
|
2726
|
+
const stmt = db2.prepare("INSERT INTO message_mentions (message_id, mentioned_agent, from_agent, space) VALUES (?, ?, ?, ?)");
|
|
2727
|
+
for (const agent of mentionedAgents) {
|
|
2728
|
+
try {
|
|
2729
|
+
stmt.run(messageId, agent, fromAgent, space);
|
|
2730
|
+
if (agent !== fromAgent.toLowerCase()) {
|
|
2731
|
+
sendMessage({
|
|
2732
|
+
from: fromAgent,
|
|
2733
|
+
to: agent,
|
|
2734
|
+
content: `You were mentioned in #${space} by ${fromAgent} (message #${messageId})`,
|
|
2735
|
+
metadata: { type: "mention_notification", source_message_id: messageId, space }
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
} catch {}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
function listUnreadCountsWithMentions(agent) {
|
|
2742
|
+
const db2 = getDb();
|
|
2743
|
+
const rows = db2.prepare(`
|
|
2744
|
+
SELECT
|
|
2745
|
+
space,
|
|
2746
|
+
COUNT(CASE WHEN read_at IS NULL AND (to_agent = ? OR to_agent IS NULL OR to_agent = '') THEN 1 END) AS unread_count,
|
|
2747
|
+
(SELECT COUNT(*) FROM message_mentions mm WHERE mm.space = m.space AND mm.mentioned_agent = ? AND mm.notified_at IS NULL) AS mention_count,
|
|
2748
|
+
MAX(created_at) AS latest_message_at
|
|
2749
|
+
FROM messages m
|
|
2750
|
+
WHERE space IN (
|
|
2751
|
+
SELECT DISTINCT space FROM space_members WHERE agent = ?
|
|
2752
|
+
UNION
|
|
2753
|
+
SELECT DISTINCT space FROM messages WHERE to_agent = ? AND space IS NOT NULL
|
|
2754
|
+
)
|
|
2755
|
+
GROUP BY space
|
|
2756
|
+
HAVING COUNT(*) > 0
|
|
2757
|
+
ORDER BY mention_count DESC, unread_count DESC, latest_message_at DESC
|
|
2758
|
+
`).all(agent, agent, agent, agent);
|
|
2759
|
+
return rows;
|
|
2760
|
+
}
|
|
2761
|
+
function getMessagesForAgent(agent, opts) {
|
|
2762
|
+
const db2 = getDb();
|
|
2763
|
+
const conditions = ["mm.mentioned_agent = ?"];
|
|
2764
|
+
const params = [agent.toLowerCase()];
|
|
2765
|
+
if (opts?.space) {
|
|
2766
|
+
conditions.push("m.space = ?");
|
|
2767
|
+
params.push(opts.space);
|
|
2768
|
+
}
|
|
2769
|
+
if (opts?.unread_only) {
|
|
2770
|
+
conditions.push("mm.notified_at IS NULL");
|
|
2771
|
+
}
|
|
2772
|
+
const limit = opts?.limit ?? 50;
|
|
2773
|
+
const rows = db2.prepare(`SELECT m.*, mm.id AS mention_id FROM messages m
|
|
2774
|
+
JOIN message_mentions mm ON mm.message_id = m.id
|
|
2775
|
+
WHERE ${conditions.join(" AND ")}
|
|
2776
|
+
ORDER BY m.created_at DESC LIMIT ${limit}`).all(...params);
|
|
2777
|
+
return rows.map(({ mention_id, ...row }) => ({ message: parseMessage(row), mention_id }));
|
|
2778
|
+
}
|
|
2779
|
+
function markMentionsRead(agent, space) {
|
|
2780
|
+
const db2 = getDb();
|
|
2781
|
+
if (space) {
|
|
2782
|
+
const result2 = db2.prepare("UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND space = ? AND notified_at IS NULL").run(agent, space);
|
|
2783
|
+
return result2.changes;
|
|
2784
|
+
}
|
|
2785
|
+
const result = db2.prepare("UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND notified_at IS NULL").run(agent);
|
|
2786
|
+
return result.changes;
|
|
2787
|
+
}
|
|
2688
2788
|
var init_messages = __esm(() => {
|
|
2689
2789
|
init_db();
|
|
2690
2790
|
init_webhooks();
|
|
@@ -4324,7 +4424,7 @@ var init_poll = __esm(() => {
|
|
|
4324
4424
|
var require_package = __commonJS((exports, module) => {
|
|
4325
4425
|
module.exports = {
|
|
4326
4426
|
name: "@hasna/conversations",
|
|
4327
|
-
version: "0.2.
|
|
4427
|
+
version: "0.2.6",
|
|
4328
4428
|
description: "Real-time CLI messaging for AI agents",
|
|
4329
4429
|
type: "module",
|
|
4330
4430
|
bin: {
|
|
@@ -33577,7 +33677,10 @@ var init_mcp2 = __esm(() => {
|
|
|
33577
33677
|
limit: exports_external.coerce.number().optional(),
|
|
33578
33678
|
unread_only: exports_external.coerce.boolean().optional(),
|
|
33579
33679
|
mark_read: exports_external.coerce.boolean().optional(),
|
|
33580
|
-
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)")
|
|
33680
|
+
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"),
|
|
33681
|
+
threads_only: exports_external.coerce.boolean().optional().describe("Only return root messages (reply_to IS NULL) \u2014 hides thread replies"),
|
|
33682
|
+
include_reply_counts: exports_external.coerce.boolean().optional().describe("Include reply_count on each message (adds one extra query)"),
|
|
33683
|
+
mentions_only: exports_external.string().optional().describe("Only return messages that @mention this agent")
|
|
33581
33684
|
}
|
|
33582
33685
|
}, async (args) => {
|
|
33583
33686
|
const agent = resolveIdentity(args.from);
|
|
@@ -33605,7 +33708,7 @@ var init_mcp2 = __esm(() => {
|
|
|
33605
33708
|
};
|
|
33606
33709
|
});
|
|
33607
33710
|
server.registerTool("reply", {
|
|
33608
|
-
description: "Reply to a message
|
|
33711
|
+
description: "Reply to a specific message, creating a thread. Sets reply_to so it can be retrieved with get_thread_replies.",
|
|
33609
33712
|
inputSchema: {
|
|
33610
33713
|
message_id: exports_external.coerce.number(),
|
|
33611
33714
|
content: exports_external.string(),
|
|
@@ -33622,13 +33725,13 @@ var init_mcp2 = __esm(() => {
|
|
|
33622
33725
|
}
|
|
33623
33726
|
const from = resolveIdentity(fromParam);
|
|
33624
33727
|
const space = original.space || (original.session_id?.startsWith("space:") ? original.session_id.slice(6) : undefined);
|
|
33625
|
-
const to = space ? space : original.from_agent === from ? original.to_agent : original.from_agent;
|
|
33626
33728
|
const msg = sendMessage({
|
|
33627
33729
|
from,
|
|
33628
|
-
to,
|
|
33730
|
+
to: space ?? (original.from_agent === from ? original.to_agent : original.from_agent),
|
|
33629
33731
|
content,
|
|
33630
33732
|
session_id: original.session_id,
|
|
33631
|
-
space
|
|
33733
|
+
space,
|
|
33734
|
+
reply_to: message_id
|
|
33632
33735
|
});
|
|
33633
33736
|
return {
|
|
33634
33737
|
content: [{ type: "text", text: JSON.stringify(msg) }]
|
|
@@ -33749,13 +33852,42 @@ var init_mcp2 = __esm(() => {
|
|
|
33749
33852
|
server.registerTool("list_unread_counts", {
|
|
33750
33853
|
description: "Get unread message counts per space without fetching message content. Use this at session start to triage which spaces need attention before calling read_messages.",
|
|
33751
33854
|
inputSchema: {
|
|
33752
|
-
agent: exports_external.string().optional().describe("Filter to spaces the agent is a member of or has received messages in. Omit for global unread counts.")
|
|
33855
|
+
agent: exports_external.string().optional().describe("Filter to spaces the agent is a member of or has received messages in. Omit for global unread counts."),
|
|
33856
|
+
include_mentions: exports_external.coerce.boolean().optional().describe("Include mention_count per space (requires agent)")
|
|
33753
33857
|
}
|
|
33754
33858
|
}, async (args) => {
|
|
33859
|
+
if (args.agent && args.include_mentions) {
|
|
33860
|
+
const counts2 = listUnreadCountsWithMentions(args.agent);
|
|
33861
|
+
return { content: [{ type: "text", text: JSON.stringify(counts2) }] };
|
|
33862
|
+
}
|
|
33755
33863
|
const counts = listUnreadCounts(args.agent);
|
|
33756
|
-
return {
|
|
33757
|
-
|
|
33758
|
-
|
|
33864
|
+
return { content: [{ type: "text", text: JSON.stringify(counts) }] };
|
|
33865
|
+
});
|
|
33866
|
+
server.registerTool("get_mentions", {
|
|
33867
|
+
description: "Get messages that @mention a specific agent. Useful for catching up on missed pings.",
|
|
33868
|
+
inputSchema: {
|
|
33869
|
+
agent: exports_external.string().describe("Agent name to find mentions for"),
|
|
33870
|
+
space: exports_external.string().optional().describe("Filter to a specific space"),
|
|
33871
|
+
unread_only: exports_external.coerce.boolean().optional().describe("Only unread (not yet notified) mentions (default: true)"),
|
|
33872
|
+
limit: exports_external.coerce.number().optional().describe("Max results (default: 50)")
|
|
33873
|
+
}
|
|
33874
|
+
}, async (args) => {
|
|
33875
|
+
const results = getMessagesForAgent(args.agent, {
|
|
33876
|
+
space: args.space,
|
|
33877
|
+
unread_only: args.unread_only ?? true,
|
|
33878
|
+
limit: args.limit
|
|
33879
|
+
});
|
|
33880
|
+
return { content: [{ type: "text", text: JSON.stringify({ mentions: results, count: results.length }) }] };
|
|
33881
|
+
});
|
|
33882
|
+
server.registerTool("mark_mentions_read", {
|
|
33883
|
+
description: "Mark @mentions as seen for an agent. Clears unread mention counts.",
|
|
33884
|
+
inputSchema: {
|
|
33885
|
+
agent: exports_external.string().describe("Agent name"),
|
|
33886
|
+
space: exports_external.string().optional().describe("Clear only mentions in this space")
|
|
33887
|
+
}
|
|
33888
|
+
}, async (args) => {
|
|
33889
|
+
const cleared = markMentionsRead(args.agent, args.space);
|
|
33890
|
+
return { content: [{ type: "text", text: JSON.stringify({ cleared }) }] };
|
|
33759
33891
|
});
|
|
33760
33892
|
server.registerTool("send_to_space", {
|
|
33761
33893
|
description: "Post a message to a space.",
|
|
@@ -33796,11 +33928,13 @@ var init_mcp2 = __esm(() => {
|
|
|
33796
33928
|
since: exports_external.string().optional(),
|
|
33797
33929
|
limit: exports_external.coerce.number().optional(),
|
|
33798
33930
|
mark_read: exports_external.coerce.boolean().optional(),
|
|
33799
|
-
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)")
|
|
33931
|
+
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"),
|
|
33932
|
+
threads_only: exports_external.coerce.boolean().optional().describe("Only return root messages (hides thread replies)"),
|
|
33933
|
+
include_reply_counts: exports_external.coerce.boolean().optional().describe("Include reply_count on each message")
|
|
33800
33934
|
}
|
|
33801
33935
|
}, async (args) => {
|
|
33802
|
-
const { space, since, limit, mark_read, max_content_length } = args;
|
|
33803
|
-
const messages = readMessages({ space, since, limit, max_content_length });
|
|
33936
|
+
const { space, since, limit, mark_read, max_content_length, threads_only, include_reply_counts } = args;
|
|
33937
|
+
const messages = readMessages({ space, since, limit, max_content_length, threads_only, include_reply_counts });
|
|
33804
33938
|
if (mark_read !== false && messages.length > 0) {
|
|
33805
33939
|
markReadByIds(messages.map((m) => m.id));
|
|
33806
33940
|
}
|
|
@@ -34451,13 +34585,30 @@ var init_mcp2 = __esm(() => {
|
|
|
34451
34585
|
return { content: [{ type: "text", text: JSON.stringify({ released_stale_agent: stale, released_expired: expired, total: stale + expired }) }] };
|
|
34452
34586
|
});
|
|
34453
34587
|
server.registerTool("get_thread_replies", {
|
|
34454
|
-
description: "Get all replies in a thread for a given parent message ID.",
|
|
34588
|
+
description: "Get all replies in a thread for a given parent message ID. Also accessible as read_thread.",
|
|
34455
34589
|
inputSchema: {
|
|
34456
|
-
message_id: exports_external.coerce.number()
|
|
34590
|
+
message_id: exports_external.coerce.number(),
|
|
34591
|
+
limit: exports_external.coerce.number().optional()
|
|
34592
|
+
}
|
|
34593
|
+
}, async (args) => {
|
|
34594
|
+
let replies = getThreadReplies(args.message_id);
|
|
34595
|
+
if (args.limit)
|
|
34596
|
+
replies = replies.slice(0, args.limit);
|
|
34597
|
+
const parent = getMessageById(args.message_id);
|
|
34598
|
+
return { content: [{ type: "text", text: JSON.stringify({ parent, replies, reply_count: replies.length }) }] };
|
|
34599
|
+
});
|
|
34600
|
+
server.registerTool("read_thread", {
|
|
34601
|
+
description: "Alias for get_thread_replies. Read all replies to a specific message, forming a thread view.",
|
|
34602
|
+
inputSchema: {
|
|
34603
|
+
message_id: exports_external.coerce.number(),
|
|
34604
|
+
limit: exports_external.coerce.number().optional()
|
|
34457
34605
|
}
|
|
34458
34606
|
}, async (args) => {
|
|
34459
|
-
|
|
34460
|
-
|
|
34607
|
+
let replies = getThreadReplies(args.message_id);
|
|
34608
|
+
if (args.limit)
|
|
34609
|
+
replies = replies.slice(0, args.limit);
|
|
34610
|
+
const parent = getMessageById(args.message_id);
|
|
34611
|
+
return { content: [{ type: "text", text: JSON.stringify({ parent, replies, reply_count: replies.length }) }] };
|
|
34461
34612
|
});
|
|
34462
34613
|
server.registerTool("set_focus", {
|
|
34463
34614
|
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.",
|
|
@@ -34705,7 +34856,7 @@ var init_mcp2 = __esm(() => {
|
|
|
34705
34856
|
read_messages: "Read messages with filters. Optional: session_id?, from?, to?, space?, since?(ISO), limit?, unread_only?, mark_read?(default true \u2014 auto-marks returned messages as read, pass false to peek without consuming)",
|
|
34706
34857
|
read_digest: "Lightweight unread digest \u2014 preview only (no full bodies), auto-marks read, never overflows tokens. Returns { messages, total_unread, shown }. Optional: space?, session_id?, to?, since?(ISO), limit?, project_id?",
|
|
34707
34858
|
list_sessions: "List all DM sessions. Optional: agent?(filter by participant)",
|
|
34708
|
-
reply: "Reply to a message
|
|
34859
|
+
reply: "Reply to a specific message, creating a thread (sets reply_to). Use read_thread to retrieve. Required: message_id, content. Optional: from?",
|
|
34709
34860
|
mark_read: "Mark messages as read. Optional: from?, ids?(array), all?(bool \u2014 mark all unread)",
|
|
34710
34861
|
search_messages: "Full-text search messages. Required: query. Optional: space?, from?, to?, limit?",
|
|
34711
34862
|
export_messages: "Export messages as JSON or CSV. Optional: space?, session_id?, from?, since?, until?, format?(json|csv)",
|
|
@@ -34749,6 +34900,7 @@ var init_mcp2 = __esm(() => {
|
|
|
34749
34900
|
list_locks: "List active locks enriched with agent presence + time context. Optional: resource_type?, agent_id?",
|
|
34750
34901
|
clean_expired_locks: "Release expired locks + locks held by agents with stale heartbeat (>30 min). Returns {released_stale_agent, released_expired, total}",
|
|
34751
34902
|
get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
|
|
34903
|
+
read_thread: "Alias for get_thread_replies. Required: message_id. Optional: limit?",
|
|
34752
34904
|
set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
|
|
34753
34905
|
get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
|
|
34754
34906
|
unfocus: "Clear agent focus (session + DB). Optional: from?",
|
package/bin/mcp.js
CHANGED
|
@@ -6749,6 +6749,20 @@ function getDb() {
|
|
|
6749
6749
|
if (!presenceColNames.includes("project_id")) {
|
|
6750
6750
|
db.exec("ALTER TABLE agent_presence ADD COLUMN project_id TEXT");
|
|
6751
6751
|
}
|
|
6752
|
+
db.exec(`
|
|
6753
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
6754
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
6755
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
6756
|
+
mentioned_agent TEXT NOT NULL,
|
|
6757
|
+
from_agent TEXT NOT NULL,
|
|
6758
|
+
space TEXT,
|
|
6759
|
+
notified_at TEXT,
|
|
6760
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now'))
|
|
6761
|
+
)
|
|
6762
|
+
`);
|
|
6763
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_agent ON message_mentions(mentioned_agent)");
|
|
6764
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions(message_id)");
|
|
6765
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_notified ON message_mentions(notified_at)");
|
|
6752
6766
|
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
6753
6767
|
if (!ftsExists) {
|
|
6754
6768
|
db.exec(`
|
|
@@ -28839,6 +28853,12 @@ function sendMessage(opts) {
|
|
|
28839
28853
|
db2.prepare("UPDATE messages SET attachments = ? WHERE id = ?").run(attachmentsJson, message.id);
|
|
28840
28854
|
message.attachments = attachmentInfos;
|
|
28841
28855
|
}
|
|
28856
|
+
if (opts.space) {
|
|
28857
|
+
const mentions = parseMentions(opts.content);
|
|
28858
|
+
if (mentions.length > 0) {
|
|
28859
|
+
processMentions(message.id, opts.from, opts.space, mentions, db2);
|
|
28860
|
+
}
|
|
28861
|
+
}
|
|
28842
28862
|
fireWebhooks(message);
|
|
28843
28863
|
return message;
|
|
28844
28864
|
}
|
|
@@ -28877,11 +28897,24 @@ function readMessages(opts = {}) {
|
|
|
28877
28897
|
if (opts.unread_only) {
|
|
28878
28898
|
conditions.push("read_at IS NULL");
|
|
28879
28899
|
}
|
|
28900
|
+
if (opts.threads_only) {
|
|
28901
|
+
conditions.push("reply_to IS NULL");
|
|
28902
|
+
}
|
|
28903
|
+
if (opts.mentions_only) {
|
|
28904
|
+
conditions.push(`id IN (SELECT message_id FROM message_mentions WHERE mentioned_agent = ?)`);
|
|
28905
|
+
params.push(opts.mentions_only.toLowerCase());
|
|
28906
|
+
}
|
|
28880
28907
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
28881
28908
|
const resolvedLimit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
|
|
28882
28909
|
const order = opts.order?.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
28883
28910
|
const rows = db2.prepare(`SELECT * FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT ${resolvedLimit}`).all(...params);
|
|
28884
28911
|
let messages = rows.map(parseMessage);
|
|
28912
|
+
if (opts.include_reply_counts && messages.length > 0) {
|
|
28913
|
+
const db22 = getDb();
|
|
28914
|
+
const counts = db22.prepare(`SELECT reply_to, COUNT(*) as c FROM messages WHERE reply_to IN (${messages.map(() => "?").join(",")}) GROUP BY reply_to`).all(...messages.map((m) => m.id));
|
|
28915
|
+
const countMap = new Map(counts.map((r) => [r.reply_to, r.c]));
|
|
28916
|
+
messages = messages.map((m) => ({ ...m, reply_count: countMap.get(m.id) ?? 0 }));
|
|
28917
|
+
}
|
|
28885
28918
|
if (opts.max_content_length && opts.max_content_length > 0) {
|
|
28886
28919
|
messages = messages.map((m) => {
|
|
28887
28920
|
if (m.content.length > opts.max_content_length) {
|
|
@@ -29182,6 +29215,73 @@ function listUnreadCounts(agent) {
|
|
|
29182
29215
|
`).all();
|
|
29183
29216
|
return rows;
|
|
29184
29217
|
}
|
|
29218
|
+
function parseMentions(content) {
|
|
29219
|
+
const matches = content.match(/@([a-zA-Z0-9_-]+)/g) ?? [];
|
|
29220
|
+
return [...new Set(matches.map((m) => m.slice(1).toLowerCase()))];
|
|
29221
|
+
}
|
|
29222
|
+
async function processMentions(messageId, fromAgent, space, mentionedAgents, db2) {
|
|
29223
|
+
const stmt = db2.prepare("INSERT INTO message_mentions (message_id, mentioned_agent, from_agent, space) VALUES (?, ?, ?, ?)");
|
|
29224
|
+
for (const agent of mentionedAgents) {
|
|
29225
|
+
try {
|
|
29226
|
+
stmt.run(messageId, agent, fromAgent, space);
|
|
29227
|
+
if (agent !== fromAgent.toLowerCase()) {
|
|
29228
|
+
sendMessage({
|
|
29229
|
+
from: fromAgent,
|
|
29230
|
+
to: agent,
|
|
29231
|
+
content: `You were mentioned in #${space} by ${fromAgent} (message #${messageId})`,
|
|
29232
|
+
metadata: { type: "mention_notification", source_message_id: messageId, space }
|
|
29233
|
+
});
|
|
29234
|
+
}
|
|
29235
|
+
} catch {}
|
|
29236
|
+
}
|
|
29237
|
+
}
|
|
29238
|
+
function listUnreadCountsWithMentions(agent) {
|
|
29239
|
+
const db2 = getDb();
|
|
29240
|
+
const rows = db2.prepare(`
|
|
29241
|
+
SELECT
|
|
29242
|
+
space,
|
|
29243
|
+
COUNT(CASE WHEN read_at IS NULL AND (to_agent = ? OR to_agent IS NULL OR to_agent = '') THEN 1 END) AS unread_count,
|
|
29244
|
+
(SELECT COUNT(*) FROM message_mentions mm WHERE mm.space = m.space AND mm.mentioned_agent = ? AND mm.notified_at IS NULL) AS mention_count,
|
|
29245
|
+
MAX(created_at) AS latest_message_at
|
|
29246
|
+
FROM messages m
|
|
29247
|
+
WHERE space IN (
|
|
29248
|
+
SELECT DISTINCT space FROM space_members WHERE agent = ?
|
|
29249
|
+
UNION
|
|
29250
|
+
SELECT DISTINCT space FROM messages WHERE to_agent = ? AND space IS NOT NULL
|
|
29251
|
+
)
|
|
29252
|
+
GROUP BY space
|
|
29253
|
+
HAVING COUNT(*) > 0
|
|
29254
|
+
ORDER BY mention_count DESC, unread_count DESC, latest_message_at DESC
|
|
29255
|
+
`).all(agent, agent, agent, agent);
|
|
29256
|
+
return rows;
|
|
29257
|
+
}
|
|
29258
|
+
function getMessagesForAgent(agent, opts) {
|
|
29259
|
+
const db2 = getDb();
|
|
29260
|
+
const conditions = ["mm.mentioned_agent = ?"];
|
|
29261
|
+
const params = [agent.toLowerCase()];
|
|
29262
|
+
if (opts?.space) {
|
|
29263
|
+
conditions.push("m.space = ?");
|
|
29264
|
+
params.push(opts.space);
|
|
29265
|
+
}
|
|
29266
|
+
if (opts?.unread_only) {
|
|
29267
|
+
conditions.push("mm.notified_at IS NULL");
|
|
29268
|
+
}
|
|
29269
|
+
const limit = opts?.limit ?? 50;
|
|
29270
|
+
const rows = db2.prepare(`SELECT m.*, mm.id AS mention_id FROM messages m
|
|
29271
|
+
JOIN message_mentions mm ON mm.message_id = m.id
|
|
29272
|
+
WHERE ${conditions.join(" AND ")}
|
|
29273
|
+
ORDER BY m.created_at DESC LIMIT ${limit}`).all(...params);
|
|
29274
|
+
return rows.map(({ mention_id, ...row }) => ({ message: parseMessage(row), mention_id }));
|
|
29275
|
+
}
|
|
29276
|
+
function markMentionsRead(agent, space) {
|
|
29277
|
+
const db2 = getDb();
|
|
29278
|
+
if (space) {
|
|
29279
|
+
const result2 = db2.prepare("UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND space = ? AND notified_at IS NULL").run(agent, space);
|
|
29280
|
+
return result2.changes;
|
|
29281
|
+
}
|
|
29282
|
+
const result = db2.prepare("UPDATE message_mentions SET notified_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE mentioned_agent = ? AND notified_at IS NULL").run(agent);
|
|
29283
|
+
return result.changes;
|
|
29284
|
+
}
|
|
29185
29285
|
|
|
29186
29286
|
// src/lib/sessions.ts
|
|
29187
29287
|
init_db();
|
|
@@ -30808,7 +30908,7 @@ function getGraphStats() {
|
|
|
30808
30908
|
// package.json
|
|
30809
30909
|
var package_default = {
|
|
30810
30910
|
name: "@hasna/conversations",
|
|
30811
|
-
version: "0.2.
|
|
30911
|
+
version: "0.2.6",
|
|
30812
30912
|
description: "Real-time CLI messaging for AI agents",
|
|
30813
30913
|
type: "module",
|
|
30814
30914
|
bin: {
|
|
@@ -30940,7 +31040,10 @@ server.registerTool("read_messages", {
|
|
|
30940
31040
|
limit: exports_external.coerce.number().optional(),
|
|
30941
31041
|
unread_only: exports_external.coerce.boolean().optional(),
|
|
30942
31042
|
mark_read: exports_external.coerce.boolean().optional(),
|
|
30943
|
-
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)")
|
|
31043
|
+
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"),
|
|
31044
|
+
threads_only: exports_external.coerce.boolean().optional().describe("Only return root messages (reply_to IS NULL) \u2014 hides thread replies"),
|
|
31045
|
+
include_reply_counts: exports_external.coerce.boolean().optional().describe("Include reply_count on each message (adds one extra query)"),
|
|
31046
|
+
mentions_only: exports_external.string().optional().describe("Only return messages that @mention this agent")
|
|
30944
31047
|
}
|
|
30945
31048
|
}, async (args) => {
|
|
30946
31049
|
const agent = resolveIdentity(args.from);
|
|
@@ -30968,7 +31071,7 @@ server.registerTool("list_sessions", {
|
|
|
30968
31071
|
};
|
|
30969
31072
|
});
|
|
30970
31073
|
server.registerTool("reply", {
|
|
30971
|
-
description: "Reply to a message
|
|
31074
|
+
description: "Reply to a specific message, creating a thread. Sets reply_to so it can be retrieved with get_thread_replies.",
|
|
30972
31075
|
inputSchema: {
|
|
30973
31076
|
message_id: exports_external.coerce.number(),
|
|
30974
31077
|
content: exports_external.string(),
|
|
@@ -30985,13 +31088,13 @@ server.registerTool("reply", {
|
|
|
30985
31088
|
}
|
|
30986
31089
|
const from = resolveIdentity(fromParam);
|
|
30987
31090
|
const space = original.space || (original.session_id?.startsWith("space:") ? original.session_id.slice(6) : undefined);
|
|
30988
|
-
const to = space ? space : original.from_agent === from ? original.to_agent : original.from_agent;
|
|
30989
31091
|
const msg = sendMessage({
|
|
30990
31092
|
from,
|
|
30991
|
-
to,
|
|
31093
|
+
to: space ?? (original.from_agent === from ? original.to_agent : original.from_agent),
|
|
30992
31094
|
content,
|
|
30993
31095
|
session_id: original.session_id,
|
|
30994
|
-
space
|
|
31096
|
+
space,
|
|
31097
|
+
reply_to: message_id
|
|
30995
31098
|
});
|
|
30996
31099
|
return {
|
|
30997
31100
|
content: [{ type: "text", text: JSON.stringify(msg) }]
|
|
@@ -31112,13 +31215,42 @@ server.registerTool("list_spaces", {
|
|
|
31112
31215
|
server.registerTool("list_unread_counts", {
|
|
31113
31216
|
description: "Get unread message counts per space without fetching message content. Use this at session start to triage which spaces need attention before calling read_messages.",
|
|
31114
31217
|
inputSchema: {
|
|
31115
|
-
agent: exports_external.string().optional().describe("Filter to spaces the agent is a member of or has received messages in. Omit for global unread counts.")
|
|
31218
|
+
agent: exports_external.string().optional().describe("Filter to spaces the agent is a member of or has received messages in. Omit for global unread counts."),
|
|
31219
|
+
include_mentions: exports_external.coerce.boolean().optional().describe("Include mention_count per space (requires agent)")
|
|
31116
31220
|
}
|
|
31117
31221
|
}, async (args) => {
|
|
31222
|
+
if (args.agent && args.include_mentions) {
|
|
31223
|
+
const counts2 = listUnreadCountsWithMentions(args.agent);
|
|
31224
|
+
return { content: [{ type: "text", text: JSON.stringify(counts2) }] };
|
|
31225
|
+
}
|
|
31118
31226
|
const counts = listUnreadCounts(args.agent);
|
|
31119
|
-
return {
|
|
31120
|
-
|
|
31121
|
-
|
|
31227
|
+
return { content: [{ type: "text", text: JSON.stringify(counts) }] };
|
|
31228
|
+
});
|
|
31229
|
+
server.registerTool("get_mentions", {
|
|
31230
|
+
description: "Get messages that @mention a specific agent. Useful for catching up on missed pings.",
|
|
31231
|
+
inputSchema: {
|
|
31232
|
+
agent: exports_external.string().describe("Agent name to find mentions for"),
|
|
31233
|
+
space: exports_external.string().optional().describe("Filter to a specific space"),
|
|
31234
|
+
unread_only: exports_external.coerce.boolean().optional().describe("Only unread (not yet notified) mentions (default: true)"),
|
|
31235
|
+
limit: exports_external.coerce.number().optional().describe("Max results (default: 50)")
|
|
31236
|
+
}
|
|
31237
|
+
}, async (args) => {
|
|
31238
|
+
const results = getMessagesForAgent(args.agent, {
|
|
31239
|
+
space: args.space,
|
|
31240
|
+
unread_only: args.unread_only ?? true,
|
|
31241
|
+
limit: args.limit
|
|
31242
|
+
});
|
|
31243
|
+
return { content: [{ type: "text", text: JSON.stringify({ mentions: results, count: results.length }) }] };
|
|
31244
|
+
});
|
|
31245
|
+
server.registerTool("mark_mentions_read", {
|
|
31246
|
+
description: "Mark @mentions as seen for an agent. Clears unread mention counts.",
|
|
31247
|
+
inputSchema: {
|
|
31248
|
+
agent: exports_external.string().describe("Agent name"),
|
|
31249
|
+
space: exports_external.string().optional().describe("Clear only mentions in this space")
|
|
31250
|
+
}
|
|
31251
|
+
}, async (args) => {
|
|
31252
|
+
const cleared = markMentionsRead(args.agent, args.space);
|
|
31253
|
+
return { content: [{ type: "text", text: JSON.stringify({ cleared }) }] };
|
|
31122
31254
|
});
|
|
31123
31255
|
server.registerTool("send_to_space", {
|
|
31124
31256
|
description: "Post a message to a space.",
|
|
@@ -31159,11 +31291,13 @@ server.registerTool("read_space", {
|
|
|
31159
31291
|
since: exports_external.string().optional(),
|
|
31160
31292
|
limit: exports_external.coerce.number().optional(),
|
|
31161
31293
|
mark_read: exports_external.coerce.boolean().optional(),
|
|
31162
|
-
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)")
|
|
31294
|
+
max_content_length: exports_external.coerce.number().optional().describe("Truncate each message content to N chars (adds truncated:true flag)"),
|
|
31295
|
+
threads_only: exports_external.coerce.boolean().optional().describe("Only return root messages (hides thread replies)"),
|
|
31296
|
+
include_reply_counts: exports_external.coerce.boolean().optional().describe("Include reply_count on each message")
|
|
31163
31297
|
}
|
|
31164
31298
|
}, async (args) => {
|
|
31165
|
-
const { space, since, limit, mark_read, max_content_length } = args;
|
|
31166
|
-
const messages = readMessages({ space, since, limit, max_content_length });
|
|
31299
|
+
const { space, since, limit, mark_read, max_content_length, threads_only, include_reply_counts } = args;
|
|
31300
|
+
const messages = readMessages({ space, since, limit, max_content_length, threads_only, include_reply_counts });
|
|
31167
31301
|
if (mark_read !== false && messages.length > 0) {
|
|
31168
31302
|
markReadByIds(messages.map((m) => m.id));
|
|
31169
31303
|
}
|
|
@@ -31814,13 +31948,30 @@ server.registerTool("clean_expired_locks", {
|
|
|
31814
31948
|
return { content: [{ type: "text", text: JSON.stringify({ released_stale_agent: stale, released_expired: expired, total: stale + expired }) }] };
|
|
31815
31949
|
});
|
|
31816
31950
|
server.registerTool("get_thread_replies", {
|
|
31817
|
-
description: "Get all replies in a thread for a given parent message ID.",
|
|
31951
|
+
description: "Get all replies in a thread for a given parent message ID. Also accessible as read_thread.",
|
|
31818
31952
|
inputSchema: {
|
|
31819
|
-
message_id: exports_external.coerce.number()
|
|
31953
|
+
message_id: exports_external.coerce.number(),
|
|
31954
|
+
limit: exports_external.coerce.number().optional()
|
|
31955
|
+
}
|
|
31956
|
+
}, async (args) => {
|
|
31957
|
+
let replies = getThreadReplies(args.message_id);
|
|
31958
|
+
if (args.limit)
|
|
31959
|
+
replies = replies.slice(0, args.limit);
|
|
31960
|
+
const parent = getMessageById(args.message_id);
|
|
31961
|
+
return { content: [{ type: "text", text: JSON.stringify({ parent, replies, reply_count: replies.length }) }] };
|
|
31962
|
+
});
|
|
31963
|
+
server.registerTool("read_thread", {
|
|
31964
|
+
description: "Alias for get_thread_replies. Read all replies to a specific message, forming a thread view.",
|
|
31965
|
+
inputSchema: {
|
|
31966
|
+
message_id: exports_external.coerce.number(),
|
|
31967
|
+
limit: exports_external.coerce.number().optional()
|
|
31820
31968
|
}
|
|
31821
31969
|
}, async (args) => {
|
|
31822
|
-
|
|
31823
|
-
|
|
31970
|
+
let replies = getThreadReplies(args.message_id);
|
|
31971
|
+
if (args.limit)
|
|
31972
|
+
replies = replies.slice(0, args.limit);
|
|
31973
|
+
const parent = getMessageById(args.message_id);
|
|
31974
|
+
return { content: [{ type: "text", text: JSON.stringify({ parent, replies, reply_count: replies.length }) }] };
|
|
31824
31975
|
});
|
|
31825
31976
|
server.registerTool("set_focus", {
|
|
31826
31977
|
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.",
|
|
@@ -32068,7 +32219,7 @@ server.registerTool("describe_tools", {
|
|
|
32068
32219
|
read_messages: "Read messages with filters. Optional: session_id?, from?, to?, space?, since?(ISO), limit?, unread_only?, mark_read?(default true \u2014 auto-marks returned messages as read, pass false to peek without consuming)",
|
|
32069
32220
|
read_digest: "Lightweight unread digest \u2014 preview only (no full bodies), auto-marks read, never overflows tokens. Returns { messages, total_unread, shown }. Optional: space?, session_id?, to?, since?(ISO), limit?, project_id?",
|
|
32070
32221
|
list_sessions: "List all DM sessions. Optional: agent?(filter by participant)",
|
|
32071
|
-
reply: "Reply to a message
|
|
32222
|
+
reply: "Reply to a specific message, creating a thread (sets reply_to). Use read_thread to retrieve. Required: message_id, content. Optional: from?",
|
|
32072
32223
|
mark_read: "Mark messages as read. Optional: from?, ids?(array), all?(bool \u2014 mark all unread)",
|
|
32073
32224
|
search_messages: "Full-text search messages. Required: query. Optional: space?, from?, to?, limit?",
|
|
32074
32225
|
export_messages: "Export messages as JSON or CSV. Optional: space?, session_id?, from?, since?, until?, format?(json|csv)",
|
|
@@ -32112,6 +32263,7 @@ server.registerTool("describe_tools", {
|
|
|
32112
32263
|
list_locks: "List active locks enriched with agent presence + time context. Optional: resource_type?, agent_id?",
|
|
32113
32264
|
clean_expired_locks: "Release expired locks + locks held by agents with stale heartbeat (>30 min). Returns {released_stale_agent, released_expired, total}",
|
|
32114
32265
|
get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
|
|
32266
|
+
read_thread: "Alias for get_thread_replies. Required: message_id. Optional: limit?",
|
|
32115
32267
|
set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
|
|
32116
32268
|
get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
|
|
32117
32269
|
unfocus: "Clear agent focus (session + DB). Optional: from?",
|
package/dist/index.js
CHANGED
|
@@ -276,6 +276,20 @@ function getDb() {
|
|
|
276
276
|
if (!presenceColNames.includes("project_id")) {
|
|
277
277
|
db.exec("ALTER TABLE agent_presence ADD COLUMN project_id TEXT");
|
|
278
278
|
}
|
|
279
|
+
db.exec(`
|
|
280
|
+
CREATE TABLE IF NOT EXISTS message_mentions (
|
|
281
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
282
|
+
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
283
|
+
mentioned_agent TEXT NOT NULL,
|
|
284
|
+
from_agent TEXT NOT NULL,
|
|
285
|
+
space TEXT,
|
|
286
|
+
notified_at TEXT,
|
|
287
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now'))
|
|
288
|
+
)
|
|
289
|
+
`);
|
|
290
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_agent ON message_mentions(mentioned_agent)");
|
|
291
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_message ON message_mentions(message_id)");
|
|
292
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_mentions_notified ON message_mentions(notified_at)");
|
|
279
293
|
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='messages_fts'").get();
|
|
280
294
|
if (!ftsExists) {
|
|
281
295
|
db.exec(`
|
|
@@ -2304,6 +2318,12 @@ function sendMessage(opts) {
|
|
|
2304
2318
|
db2.prepare("UPDATE messages SET attachments = ? WHERE id = ?").run(attachmentsJson, message.id);
|
|
2305
2319
|
message.attachments = attachmentInfos;
|
|
2306
2320
|
}
|
|
2321
|
+
if (opts.space) {
|
|
2322
|
+
const mentions = parseMentions(opts.content);
|
|
2323
|
+
if (mentions.length > 0) {
|
|
2324
|
+
processMentions(message.id, opts.from, opts.space, mentions, db2);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2307
2327
|
fireWebhooks(message);
|
|
2308
2328
|
return message;
|
|
2309
2329
|
}
|
|
@@ -2342,11 +2362,24 @@ function readMessages(opts = {}) {
|
|
|
2342
2362
|
if (opts.unread_only) {
|
|
2343
2363
|
conditions.push("read_at IS NULL");
|
|
2344
2364
|
}
|
|
2365
|
+
if (opts.threads_only) {
|
|
2366
|
+
conditions.push("reply_to IS NULL");
|
|
2367
|
+
}
|
|
2368
|
+
if (opts.mentions_only) {
|
|
2369
|
+
conditions.push(`id IN (SELECT message_id FROM message_mentions WHERE mentioned_agent = ?)`);
|
|
2370
|
+
params.push(opts.mentions_only.toLowerCase());
|
|
2371
|
+
}
|
|
2345
2372
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2346
2373
|
const resolvedLimit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
|
|
2347
2374
|
const order = opts.order?.toLowerCase() === "desc" ? "DESC" : "ASC";
|
|
2348
2375
|
const rows = db2.prepare(`SELECT * FROM messages ${where} ORDER BY created_at ${order}, id ${order} LIMIT ${resolvedLimit}`).all(...params);
|
|
2349
2376
|
let messages = rows.map(parseMessage);
|
|
2377
|
+
if (opts.include_reply_counts && messages.length > 0) {
|
|
2378
|
+
const db22 = getDb();
|
|
2379
|
+
const counts = db22.prepare(`SELECT reply_to, COUNT(*) as c FROM messages WHERE reply_to IN (${messages.map(() => "?").join(",")}) GROUP BY reply_to`).all(...messages.map((m) => m.id));
|
|
2380
|
+
const countMap = new Map(counts.map((r) => [r.reply_to, r.c]));
|
|
2381
|
+
messages = messages.map((m) => ({ ...m, reply_count: countMap.get(m.id) ?? 0 }));
|
|
2382
|
+
}
|
|
2350
2383
|
if (opts.max_content_length && opts.max_content_length > 0) {
|
|
2351
2384
|
messages = messages.map((m) => {
|
|
2352
2385
|
if (m.content.length > opts.max_content_length) {
|
|
@@ -2574,6 +2607,26 @@ function searchMessages(opts) {
|
|
|
2574
2607
|
return { ...msg, snippet: null, relevance_score: 0 };
|
|
2575
2608
|
});
|
|
2576
2609
|
}
|
|
2610
|
+
function parseMentions(content) {
|
|
2611
|
+
const matches = content.match(/@([a-zA-Z0-9_-]+)/g) ?? [];
|
|
2612
|
+
return [...new Set(matches.map((m) => m.slice(1).toLowerCase()))];
|
|
2613
|
+
}
|
|
2614
|
+
async function processMentions(messageId, fromAgent, space, mentionedAgents, db2) {
|
|
2615
|
+
const stmt = db2.prepare("INSERT INTO message_mentions (message_id, mentioned_agent, from_agent, space) VALUES (?, ?, ?, ?)");
|
|
2616
|
+
for (const agent of mentionedAgents) {
|
|
2617
|
+
try {
|
|
2618
|
+
stmt.run(messageId, agent, fromAgent, space);
|
|
2619
|
+
if (agent !== fromAgent.toLowerCase()) {
|
|
2620
|
+
sendMessage({
|
|
2621
|
+
from: fromAgent,
|
|
2622
|
+
to: agent,
|
|
2623
|
+
content: `You were mentioned in #${space} by ${fromAgent} (message #${messageId})`,
|
|
2624
|
+
metadata: { type: "mention_notification", source_message_id: messageId, space }
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
} catch {}
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2577
2630
|
// src/lib/sessions.ts
|
|
2578
2631
|
init_db();
|
|
2579
2632
|
function listSessions(agent) {
|
package/dist/lib/messages.d.ts
CHANGED
|
@@ -67,3 +67,24 @@ export interface UnreadCount {
|
|
|
67
67
|
* If agent is omitted, returns counts for all spaces.
|
|
68
68
|
*/
|
|
69
69
|
export declare function listUnreadCounts(agent?: string): UnreadCount[];
|
|
70
|
+
/** Extract @agentname mentions from message content. Returns unique agent names (lowercase). */
|
|
71
|
+
export declare function parseMentions(content: string): string[];
|
|
72
|
+
export interface MentionCount {
|
|
73
|
+
space: string;
|
|
74
|
+
unread_count: number;
|
|
75
|
+
mention_count: number;
|
|
76
|
+
latest_message_at: string | null;
|
|
77
|
+
}
|
|
78
|
+
/** Get unread counts AND mention counts per space for an agent. */
|
|
79
|
+
export declare function listUnreadCountsWithMentions(agent: string): MentionCount[];
|
|
80
|
+
/** Get messages that mention a specific agent. */
|
|
81
|
+
export declare function getMessagesForAgent(agent: string, opts?: {
|
|
82
|
+
space?: string;
|
|
83
|
+
unread_only?: boolean;
|
|
84
|
+
limit?: number;
|
|
85
|
+
}): Array<{
|
|
86
|
+
message: Message;
|
|
87
|
+
mention_id: number;
|
|
88
|
+
}>;
|
|
89
|
+
/** Mark mentions as notified (agent has seen them). */
|
|
90
|
+
export declare function markMentionsRead(agent: string, space?: string): number;
|
package/dist/types.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface Message {
|
|
|
19
19
|
blocking: boolean;
|
|
20
20
|
attachments: Attachment[] | null;
|
|
21
21
|
reply_to: number | null;
|
|
22
|
+
reply_count?: number;
|
|
22
23
|
truncated?: boolean;
|
|
23
24
|
}
|
|
24
25
|
export interface Reaction {
|
|
@@ -108,6 +109,9 @@ export interface ReadMessagesOptions {
|
|
|
108
109
|
order?: "asc" | "desc";
|
|
109
110
|
compact?: boolean;
|
|
110
111
|
max_content_length?: number;
|
|
112
|
+
threads_only?: boolean;
|
|
113
|
+
include_reply_counts?: boolean;
|
|
114
|
+
mentions_only?: string;
|
|
111
115
|
}
|
|
112
116
|
export interface SearchMessagesOptions {
|
|
113
117
|
query: string;
|