@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/index.js CHANGED
@@ -2498,15 +2498,26 @@ function getUnreadBlockers(agent) {
2498
2498
  `).all(agent, agent);
2499
2499
  return rows.map(parseMessage);
2500
2500
  }
2501
+ function getThreadReplies(messageId) {
2502
+ const db2 = getDb();
2503
+ const rows = db2.prepare("SELECT * FROM messages WHERE reply_to = ? ORDER BY created_at ASC, id ASC").all(messageId);
2504
+ return rows.map(parseMessage);
2505
+ }
2501
2506
  function searchMessages(opts) {
2502
2507
  const db2 = getDb();
2503
2508
  const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
2509
+ const sortByRelevance = opts.sort !== "recent";
2510
+ const priorityWeights = { urgent: 10, high: 5, normal: 1, low: 0.5 };
2504
2511
  try {
2505
- const ftsConditions = [];
2506
2512
  const ftsParams = [];
2507
- const words = opts.query.trim().split(/\s+/).filter(Boolean);
2508
- const ftsQuery = words.map((w) => `"${w.replace(/"/g, '""')}"`).join(" ");
2509
- ftsConditions.push("messages_fts MATCH ?");
2513
+ const query = opts.query.trim();
2514
+ let ftsQuery;
2515
+ if (query.startsWith('"') && query.endsWith('"')) {
2516
+ ftsQuery = query;
2517
+ } else {
2518
+ const words = query.split(/\s+/).filter(Boolean);
2519
+ ftsQuery = words.map((w) => `"${w.replace(/"/g, '""')}"`).join(" ");
2520
+ }
2510
2521
  ftsParams.push(ftsQuery);
2511
2522
  let extraWhere = "";
2512
2523
  if (opts.space) {
@@ -2521,11 +2532,23 @@ function searchMessages(opts) {
2521
2532
  extraWhere += " AND m.to_agent = ?";
2522
2533
  ftsParams.push(opts.to);
2523
2534
  }
2524
- const rows2 = db2.prepare(`SELECT m.* FROM messages m
2535
+ const orderClause = sortByRelevance ? "ORDER BY rank" : "ORDER BY m.created_at DESC, m.id DESC";
2536
+ const rows2 = db2.prepare(`SELECT m.*, rank,
2537
+ snippet(messages_fts, 0, '**', '**', '...', 20) as snippet
2538
+ FROM messages m
2525
2539
  JOIN messages_fts ON messages_fts.rowid = m.id
2526
- WHERE ${ftsConditions.join(" AND ")}${extraWhere}
2527
- ORDER BY m.created_at DESC, m.id DESC LIMIT ${limit}`).all(...ftsParams);
2528
- return rows2.map(parseMessage);
2540
+ WHERE messages_fts MATCH ?${extraWhere}
2541
+ ${orderClause} LIMIT ${limit}`).all(...ftsParams);
2542
+ const maxRank = rows2.reduce((max, r) => Math.max(max, Math.abs(r.rank || 0)), 0) || 1;
2543
+ return rows2.map((row) => {
2544
+ const msg = parseMessage(row);
2545
+ const ftsScore = maxRank > 0 ? Math.abs(row.rank || 0) / maxRank * 100 : 50;
2546
+ const priorityBoost = priorityWeights[msg.priority] || 1;
2547
+ const pinnedBoost = msg.pinned_at ? 20 : 0;
2548
+ const blockingBoost = msg.blocking ? 15 : 0;
2549
+ const relevance_score = Math.round((ftsScore * priorityBoost + pinnedBoost + blockingBoost) * 100) / 100;
2550
+ return { ...msg, snippet: row.snippet || null, relevance_score };
2551
+ });
2529
2552
  } catch {}
2530
2553
  const conditions = ["content LIKE ?"];
2531
2554
  const params = [`%${opts.query}%`];
@@ -2543,7 +2566,10 @@ function searchMessages(opts) {
2543
2566
  }
2544
2567
  const where = `WHERE ${conditions.join(" AND ")}`;
2545
2568
  const rows = db2.prepare(`SELECT * FROM messages ${where} ORDER BY created_at DESC, id DESC LIMIT ${limit}`).all(...params);
2546
- return rows.map(parseMessage);
2569
+ return rows.map((row) => {
2570
+ const msg = parseMessage(row);
2571
+ return { ...msg, snippet: null, relevance_score: 0 };
2572
+ });
2547
2573
  }
2548
2574
  var init_messages = __esm(() => {
2549
2575
  init_db();
@@ -2579,6 +2605,32 @@ function listSessions(agent) {
2579
2605
  };
2580
2606
  });
2581
2607
  }
2608
+ function getSessionActivity(sessionId) {
2609
+ const db2 = getDb();
2610
+ const exists = db2.prepare("SELECT 1 FROM messages WHERE session_id = ? LIMIT 1").get(sessionId);
2611
+ if (!exists)
2612
+ return null;
2613
+ 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;
2614
+ 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;
2615
+ const uniqueAgents = db2.prepare("SELECT COUNT(DISTINCT from_agent) as c FROM messages WHERE session_id = ?").get(sessionId).c;
2616
+ const totalMsgs = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ?").get(sessionId).c;
2617
+ const replyCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND reply_to IS NOT NULL").get(sessionId).c;
2618
+ const replyRatio = totalMsgs > 0 ? Math.round(replyCount / totalMsgs * 100) / 100 : 0;
2619
+ 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);
2620
+ 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;
2621
+ 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;
2622
+ const isTrending = msgsLast1h >= 5 || agentsLast1h >= 3;
2623
+ return {
2624
+ session_id: sessionId,
2625
+ msgs_last_1h: msgsLast1h,
2626
+ msgs_last_24h: msgsLast24h,
2627
+ unique_agents: uniqueAgents,
2628
+ reply_ratio: replyRatio,
2629
+ avg_priority: priorityRow?.priority ?? "normal",
2630
+ reaction_count: reactionCount,
2631
+ is_trending: isTrending
2632
+ };
2633
+ }
2582
2634
  var init_sessions = __esm(() => {
2583
2635
  init_db();
2584
2636
  });
@@ -3478,6 +3530,539 @@ var init_presence = __esm(() => {
3478
3530
  CONFLICT_THRESHOLD_SECONDS = 30 * 60;
3479
3531
  });
3480
3532
 
3533
+ // src/lib/reactions.ts
3534
+ function addReaction(messageId, agent, emoji) {
3535
+ const db2 = getDb();
3536
+ const stmt = db2.prepare(`
3537
+ INSERT INTO reactions (message_id, agent, emoji)
3538
+ VALUES (?, ?, ?)
3539
+ ON CONFLICT (message_id, agent, emoji) DO UPDATE SET agent = agent
3540
+ RETURNING *
3541
+ `);
3542
+ const row = stmt.get(messageId, agent, emoji);
3543
+ return row;
3544
+ }
3545
+ function removeReaction(messageId, agent, emoji) {
3546
+ const db2 = getDb();
3547
+ const stmt = db2.prepare("DELETE FROM reactions WHERE message_id = ? AND agent = ? AND emoji = ?");
3548
+ const result = stmt.run(messageId, agent, emoji);
3549
+ return result.changes > 0;
3550
+ }
3551
+ function getReactions(messageId) {
3552
+ const db2 = getDb();
3553
+ const rows = db2.prepare("SELECT * FROM reactions WHERE message_id = ? ORDER BY created_at ASC, id ASC").all(messageId);
3554
+ return rows;
3555
+ }
3556
+ function getReactionSummary(messageId) {
3557
+ const db2 = getDb();
3558
+ const rows = db2.prepare(`
3559
+ SELECT emoji, GROUP_CONCAT(agent) as agents, COUNT(*) as count
3560
+ FROM reactions
3561
+ WHERE message_id = ?
3562
+ GROUP BY emoji
3563
+ ORDER BY count DESC, MIN(created_at) ASC
3564
+ `).all(messageId);
3565
+ return rows.map((row) => ({
3566
+ emoji: row.emoji,
3567
+ count: row.count,
3568
+ agents: row.agents.split(",")
3569
+ }));
3570
+ }
3571
+ var init_reactions = __esm(() => {
3572
+ init_db();
3573
+ });
3574
+
3575
+ // src/lib/hot.ts
3576
+ function computeHotness(sessionId) {
3577
+ const db2 = getDb();
3578
+ const base = db2.prepare(`
3579
+ SELECT session_id,
3580
+ GROUP_CONCAT(DISTINCT from_agent) as agents,
3581
+ MAX(space) as space,
3582
+ MAX(created_at) as last_message_at,
3583
+ COUNT(*) as message_count
3584
+ FROM messages WHERE session_id = ?
3585
+ GROUP BY session_id
3586
+ `).get(sessionId);
3587
+ if (!base)
3588
+ return null;
3589
+ 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;
3590
+ 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;
3591
+ const uniqueAgents = db2.prepare("SELECT COUNT(DISTINCT from_agent) as c FROM messages WHERE session_id = ?").get(sessionId).c;
3592
+ 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;
3593
+ const replyCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND reply_to IS NOT NULL").get(sessionId).c;
3594
+ const highPriorityCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND priority IN ('high', 'urgent')").get(sessionId).c;
3595
+ const blockerCount = db2.prepare("SELECT COUNT(*) as c FROM messages WHERE session_id = ? AND blocking = 1").get(sessionId).c;
3596
+ const lastMsgMs = new Date(base.last_message_at + "Z").getTime();
3597
+ const hoursSinceLast = Math.max(0, (Date.now() - lastMsgMs) / 3600000);
3598
+ const hotness_score = Math.round(msgsLast1h * 3 + uniqueAgents * 5 + reactionCount * 2 + replyCount * 4 + highPriorityCount * 10 + blockerCount * 20 - hoursSinceLast * 2);
3599
+ return {
3600
+ session_id: base.session_id,
3601
+ participants: base.agents.split(","),
3602
+ space: base.space,
3603
+ last_message_at: base.last_message_at,
3604
+ message_count: base.message_count,
3605
+ hotness_score,
3606
+ metrics: {
3607
+ msgs_last_1h: msgsLast1h,
3608
+ msgs_last_24h: msgsLast24h,
3609
+ unique_agents: uniqueAgents,
3610
+ reaction_count: reactionCount,
3611
+ reply_count: replyCount,
3612
+ high_priority_count: highPriorityCount,
3613
+ blocker_count: blockerCount,
3614
+ hours_since_last: Math.round(hoursSinceLast * 10) / 10
3615
+ }
3616
+ };
3617
+ }
3618
+ function listHotSessions(opts) {
3619
+ const db2 = getDb();
3620
+ const limit = opts?.limit ?? 20;
3621
+ const minScore = opts?.min_score ?? 0;
3622
+ let where = "";
3623
+ const params = [];
3624
+ if (opts?.space) {
3625
+ where = " WHERE space = ?";
3626
+ params.push(opts.space);
3627
+ } else if (opts?.project_id) {
3628
+ where = " WHERE project_id = ?";
3629
+ params.push(opts.project_id);
3630
+ }
3631
+ 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);
3632
+ const hotSessions = [];
3633
+ for (const { session_id } of sessions) {
3634
+ const hot = computeHotness(session_id);
3635
+ if (hot && hot.hotness_score >= minScore) {
3636
+ hotSessions.push(hot);
3637
+ }
3638
+ }
3639
+ hotSessions.sort((a, b) => b.hotness_score - a.hotness_score);
3640
+ return hotSessions.slice(0, limit);
3641
+ }
3642
+ var init_hot = __esm(() => {
3643
+ init_db();
3644
+ });
3645
+
3646
+ // src/lib/topics.ts
3647
+ function extractTopics(text, topN = 10) {
3648
+ const cleaned = text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]+`/g, " ").replace(/https?:\/\/\S+/g, " ").replace(/[#*_~|>\[\](){}]/g, " ").replace(/\d+/g, " ").toLowerCase();
3649
+ const words = cleaned.split(/\s+/).filter((w) => w.length >= 3 && !STOPWORDS.has(w) && /^[a-z]/.test(w));
3650
+ const freq = new Map;
3651
+ for (const w of words) {
3652
+ freq.set(w, (freq.get(w) || 0) + 1);
3653
+ }
3654
+ const totalWords = words.length || 1;
3655
+ return [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN).map(([topic, count]) => ({
3656
+ topic,
3657
+ weight: Math.round(count / totalWords * 1000) / 1000,
3658
+ count
3659
+ }));
3660
+ }
3661
+ function getSpaceTopics(spaceName, opts) {
3662
+ const db2 = getDb();
3663
+ const limit = opts?.limit ?? 100;
3664
+ const sinceClause = opts?.since ? "AND created_at > ?" : "";
3665
+ const params = [spaceName];
3666
+ if (opts?.since)
3667
+ params.push(opts.since);
3668
+ const rows = db2.prepare(`SELECT content FROM messages WHERE space = ? ${sinceClause} ORDER BY created_at DESC LIMIT ${limit}`).all(...params);
3669
+ const combined = rows.map((r) => r.content).join(`
3670
+ `);
3671
+ return extractTopics(combined, 15);
3672
+ }
3673
+ function getSessionTopics(sessionId, opts) {
3674
+ const db2 = getDb();
3675
+ const limit = opts?.limit ?? 100;
3676
+ const rows = db2.prepare(`SELECT content FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ${limit}`).all(sessionId);
3677
+ const combined = rows.map((r) => r.content).join(`
3678
+ `);
3679
+ return extractTopics(combined, 15);
3680
+ }
3681
+ function getTrendingTopics(opts) {
3682
+ const db2 = getDb();
3683
+ const hours = opts?.hours ?? 24;
3684
+ const topN = opts?.top_n ?? 20;
3685
+ let where = `WHERE created_at > strftime('%Y-%m-%dT%H:%M:%f', 'now', '-${hours} hours')`;
3686
+ const params = [];
3687
+ if (opts?.project_id) {
3688
+ where += " AND project_id = ?";
3689
+ params.push(opts.project_id);
3690
+ }
3691
+ const rows = db2.prepare(`SELECT content FROM messages ${where} ORDER BY created_at DESC LIMIT 500`).all(...params);
3692
+ const combined = rows.map((r) => r.content).join(`
3693
+ `);
3694
+ return extractTopics(combined, topN);
3695
+ }
3696
+ var STOPWORDS;
3697
+ var init_topics = __esm(() => {
3698
+ init_db();
3699
+ STOPWORDS = new Set([
3700
+ "a",
3701
+ "an",
3702
+ "the",
3703
+ "and",
3704
+ "or",
3705
+ "but",
3706
+ "in",
3707
+ "on",
3708
+ "at",
3709
+ "to",
3710
+ "for",
3711
+ "of",
3712
+ "with",
3713
+ "by",
3714
+ "from",
3715
+ "is",
3716
+ "it",
3717
+ "this",
3718
+ "that",
3719
+ "are",
3720
+ "was",
3721
+ "were",
3722
+ "be",
3723
+ "been",
3724
+ "being",
3725
+ "have",
3726
+ "has",
3727
+ "had",
3728
+ "do",
3729
+ "does",
3730
+ "did",
3731
+ "will",
3732
+ "would",
3733
+ "could",
3734
+ "should",
3735
+ "may",
3736
+ "might",
3737
+ "shall",
3738
+ "can",
3739
+ "need",
3740
+ "not",
3741
+ "no",
3742
+ "so",
3743
+ "if",
3744
+ "then",
3745
+ "than",
3746
+ "too",
3747
+ "very",
3748
+ "just",
3749
+ "about",
3750
+ "up",
3751
+ "out",
3752
+ "all",
3753
+ "also",
3754
+ "as",
3755
+ "into",
3756
+ "only",
3757
+ "other",
3758
+ "each",
3759
+ "every",
3760
+ "both",
3761
+ "few",
3762
+ "more",
3763
+ "most",
3764
+ "some",
3765
+ "such",
3766
+ "any",
3767
+ "over",
3768
+ "after",
3769
+ "before",
3770
+ "between",
3771
+ "under",
3772
+ "above",
3773
+ "here",
3774
+ "there",
3775
+ "when",
3776
+ "where",
3777
+ "how",
3778
+ "what",
3779
+ "which",
3780
+ "who",
3781
+ "whom",
3782
+ "why",
3783
+ "its",
3784
+ "my",
3785
+ "your",
3786
+ "his",
3787
+ "her",
3788
+ "our",
3789
+ "their",
3790
+ "we",
3791
+ "you",
3792
+ "he",
3793
+ "she",
3794
+ "they",
3795
+ "i",
3796
+ "me",
3797
+ "him",
3798
+ "us",
3799
+ "them",
3800
+ "now",
3801
+ "new",
3802
+ "get",
3803
+ "got",
3804
+ "go",
3805
+ "going",
3806
+ "done",
3807
+ "make",
3808
+ "made",
3809
+ "see",
3810
+ "know",
3811
+ "think",
3812
+ "want",
3813
+ "one",
3814
+ "two",
3815
+ "like",
3816
+ "still",
3817
+ "back",
3818
+ "even"
3819
+ ]);
3820
+ });
3821
+
3822
+ // src/lib/summary.ts
3823
+ function getConversationSummary(sessionOrSpace, opts) {
3824
+ const db2 = getDb();
3825
+ const limit = opts?.limit ?? 50;
3826
+ const isSpace = sessionOrSpace.startsWith("space:") || db2.prepare("SELECT 1 FROM spaces WHERE name = ?").get(sessionOrSpace);
3827
+ const filterCol = isSpace ? "space" : "session_id";
3828
+ const filterVal = isSpace && !sessionOrSpace.startsWith("space:") ? sessionOrSpace : sessionOrSpace;
3829
+ const messages = db2.prepare(`SELECT * FROM messages WHERE ${filterCol} = ? ORDER BY created_at DESC LIMIT ${limit}`).all(filterVal);
3830
+ if (messages.length === 0)
3831
+ return null;
3832
+ const agents = new Set;
3833
+ for (const m of messages) {
3834
+ agents.add(m.from_agent);
3835
+ if (m.to_agent)
3836
+ agents.add(m.to_agent);
3837
+ }
3838
+ const dates = messages.map((m) => m.created_at).sort();
3839
+ const dateRange = { first: dates[0], last: dates[dates.length - 1] };
3840
+ const allContent = messages.map((m) => m.content).join(`
3841
+ `);
3842
+ const topics = extractTopics(allContent, 10);
3843
+ const keyMessages = [];
3844
+ for (const m of messages) {
3845
+ const priority = m.priority;
3846
+ if (priority === "high" || priority === "urgent") {
3847
+ keyMessages.push({
3848
+ id: m.id,
3849
+ from: m.from_agent,
3850
+ content: m.content.slice(0, 200),
3851
+ reason: `${priority} priority`
3852
+ });
3853
+ }
3854
+ if (m.blocking) {
3855
+ keyMessages.push({
3856
+ id: m.id,
3857
+ from: m.from_agent,
3858
+ content: m.content.slice(0, 200),
3859
+ reason: "blocking message"
3860
+ });
3861
+ }
3862
+ }
3863
+ for (const m of messages) {
3864
+ if (m.pinned_at) {
3865
+ keyMessages.push({
3866
+ id: m.id,
3867
+ from: m.from_agent,
3868
+ content: m.content.slice(0, 200),
3869
+ reason: "pinned"
3870
+ });
3871
+ }
3872
+ }
3873
+ const msgIds = messages.map((m) => m.id);
3874
+ if (msgIds.length > 0) {
3875
+ const placeholders = msgIds.map(() => "?").join(",");
3876
+ 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);
3877
+ for (const r of reacted) {
3878
+ const m = messages.find((msg) => msg.id === r.message_id);
3879
+ if (m) {
3880
+ keyMessages.push({
3881
+ id: r.message_id,
3882
+ from: m.from_agent,
3883
+ content: m.content.slice(0, 200),
3884
+ reason: `${r.c} reaction(s)`
3885
+ });
3886
+ }
3887
+ }
3888
+ }
3889
+ const seen = new Set;
3890
+ const uniqueKey = keyMessages.filter((k) => {
3891
+ if (seen.has(k.id))
3892
+ return false;
3893
+ seen.add(k.id);
3894
+ return true;
3895
+ }).slice(0, 10);
3896
+ const blockers = messages.filter((m) => m.blocking && !m.read_at).map((m) => ({
3897
+ id: m.id,
3898
+ from: m.from_agent,
3899
+ content: m.content.slice(0, 200),
3900
+ created_at: m.created_at
3901
+ }));
3902
+ const replyCount = messages.filter((m) => m.reply_to).length;
3903
+ const reactionCount = msgIds.length > 0 ? db2.prepare(`SELECT COUNT(*) as c FROM reactions WHERE message_id IN (${msgIds.map(() => "?").join(",")})`).get(...msgIds).c : 0;
3904
+ const priorityCounts = {};
3905
+ for (const m of messages) {
3906
+ const p = m.priority;
3907
+ priorityCounts[p] = (priorityCounts[p] || 0) + 1;
3908
+ }
3909
+ const avgPriority = Object.entries(priorityCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "normal";
3910
+ return {
3911
+ session_id: sessionOrSpace,
3912
+ participants: [...agents].filter((a) => a !== sessionOrSpace),
3913
+ message_count: messages.length,
3914
+ date_range: dateRange,
3915
+ topics,
3916
+ key_messages: uniqueKey,
3917
+ unresolved_blockers: blockers,
3918
+ activity: {
3919
+ reply_count: replyCount,
3920
+ reaction_count: reactionCount,
3921
+ avg_priority: avgPriority
3922
+ }
3923
+ };
3924
+ }
3925
+ var init_summary = __esm(() => {
3926
+ init_db();
3927
+ init_topics();
3928
+ });
3929
+
3930
+ // src/lib/graph.ts
3931
+ function ensureGraphTable() {
3932
+ const db2 = getDb();
3933
+ db2.exec(`
3934
+ CREATE TABLE IF NOT EXISTS graph_edges (
3935
+ from_type TEXT NOT NULL,
3936
+ from_id TEXT NOT NULL,
3937
+ to_type TEXT NOT NULL,
3938
+ to_id TEXT NOT NULL,
3939
+ relation TEXT NOT NULL,
3940
+ weight REAL NOT NULL DEFAULT 1,
3941
+ metadata TEXT,
3942
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
3943
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
3944
+ UNIQUE(from_type, from_id, to_type, to_id, relation)
3945
+ )
3946
+ `);
3947
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_from ON graph_edges(from_type, from_id)");
3948
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_to ON graph_edges(to_type, to_id)");
3949
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_graph_relation ON graph_edges(relation)");
3950
+ }
3951
+ function buildGraph() {
3952
+ const db2 = getDb();
3953
+ ensureGraphTable();
3954
+ let created = 0;
3955
+ let updated = 0;
3956
+ const upsert = db2.prepare(`
3957
+ INSERT INTO graph_edges (from_type, from_id, to_type, to_id, relation, weight, updated_at)
3958
+ VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))
3959
+ ON CONFLICT(from_type, from_id, to_type, to_id, relation) DO UPDATE SET
3960
+ weight = excluded.weight,
3961
+ updated_at = excluded.updated_at
3962
+ `);
3963
+ const insertOrUpdate = db2.transaction(() => {
3964
+ const dmPairs = db2.prepare(`
3965
+ SELECT from_agent, to_agent, COUNT(*) as cnt, MAX(created_at) as last_at
3966
+ FROM messages WHERE space IS NULL AND from_agent != to_agent
3967
+ GROUP BY from_agent, to_agent
3968
+ `).all();
3969
+ for (const pair of dmPairs) {
3970
+ 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);
3971
+ upsert.run("agent", pair.from_agent, "agent", pair.to_agent, "communicates_with", pair.cnt);
3972
+ if (existing)
3973
+ updated++;
3974
+ else
3975
+ created++;
3976
+ }
3977
+ const spacePosts = db2.prepare(`
3978
+ SELECT from_agent, space, COUNT(*) as cnt
3979
+ FROM messages WHERE space IS NOT NULL
3980
+ GROUP BY from_agent, space
3981
+ `).all();
3982
+ for (const sp of spacePosts) {
3983
+ 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);
3984
+ upsert.run("agent", sp.from_agent, "space", sp.space, "posts_in", sp.cnt);
3985
+ if (existing)
3986
+ updated++;
3987
+ else
3988
+ created++;
3989
+ }
3990
+ const members = db2.prepare("SELECT agent, space FROM space_members").all();
3991
+ for (const m of members) {
3992
+ 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);
3993
+ upsert.run("agent", m.agent, "space", m.space, "member_of", 1);
3994
+ if (existing)
3995
+ updated++;
3996
+ else
3997
+ created++;
3998
+ }
3999
+ const spaceProjects = db2.prepare("SELECT name, project_id FROM spaces WHERE project_id IS NOT NULL").all();
4000
+ for (const sp of spaceProjects) {
4001
+ 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);
4002
+ upsert.run("space", sp.name, "project", sp.project_id, "belongs_to", 1);
4003
+ if (existing)
4004
+ updated++;
4005
+ else
4006
+ created++;
4007
+ }
4008
+ });
4009
+ insertOrUpdate();
4010
+ return { edges_created: created, edges_updated: updated };
4011
+ }
4012
+ function getRelated(entityType, entityId) {
4013
+ const db2 = getDb();
4014
+ ensureGraphTable();
4015
+ const outgoing = db2.prepare(`
4016
+ SELECT to_type as type, to_id as id, relation, weight FROM graph_edges
4017
+ WHERE from_type = ? AND from_id = ? ORDER BY weight DESC
4018
+ `).all(entityType, entityId);
4019
+ const incoming = db2.prepare(`
4020
+ SELECT from_type as type, from_id as id, relation, weight FROM graph_edges
4021
+ WHERE to_type = ? AND to_id = ? ORDER BY weight DESC
4022
+ `).all(entityType, entityId);
4023
+ return [...outgoing, ...incoming];
4024
+ }
4025
+ function getAgentNetwork(agent) {
4026
+ const db2 = getDb();
4027
+ ensureGraphTable();
4028
+ const comms = db2.prepare(`
4029
+ SELECT to_id as agent, weight as message_count,
4030
+ (SELECT MAX(created_at) FROM messages WHERE from_agent = ? AND to_agent = ge.to_id AND space IS NULL) as last_at
4031
+ FROM graph_edges ge
4032
+ WHERE from_type = 'agent' AND from_id = ? AND relation = 'communicates_with'
4033
+ ORDER BY weight DESC LIMIT 20
4034
+ `).all(agent, agent);
4035
+ const spaces = db2.prepare(`
4036
+ SELECT to_id as space, weight as message_count FROM graph_edges
4037
+ WHERE from_type = 'agent' AND from_id = ? AND relation = 'posts_in'
4038
+ ORDER BY weight DESC LIMIT 20
4039
+ `).all(agent);
4040
+ const projects = db2.prepare(`
4041
+ SELECT DISTINCT g2.to_id FROM graph_edges g1
4042
+ JOIN graph_edges g2 ON g1.to_type = 'space' AND g1.to_id = g2.from_id AND g2.relation = 'belongs_to'
4043
+ WHERE g1.from_type = 'agent' AND g1.from_id = ? AND g1.relation IN ('member_of', 'posts_in')
4044
+ `).all(agent);
4045
+ return {
4046
+ agent,
4047
+ communicates_with: comms,
4048
+ spaces,
4049
+ projects: projects.map((p) => p.to_id)
4050
+ };
4051
+ }
4052
+ function getGraphStats() {
4053
+ const db2 = getDb();
4054
+ ensureGraphTable();
4055
+ const total = db2.prepare("SELECT COUNT(*) as c FROM graph_edges").get().c;
4056
+ const byRelation = db2.prepare("SELECT relation, COUNT(*) as c FROM graph_edges GROUP BY relation ORDER BY c DESC").all();
4057
+ const map = {};
4058
+ for (const r of byRelation)
4059
+ map[r.relation] = r.c;
4060
+ return { total_edges: total, by_relation: map };
4061
+ }
4062
+ var init_graph = __esm(() => {
4063
+ init_db();
4064
+ });
4065
+
3481
4066
  // src/lib/terminal-markdown.ts
3482
4067
  var exports_terminal_markdown = {};
3483
4068
  __export(exports_terminal_markdown, {
@@ -3622,7 +4207,7 @@ var init_poll = __esm(() => {
3622
4207
  var require_package = __commonJS((exports, module) => {
3623
4208
  module.exports = {
3624
4209
  name: "@hasna/conversations",
3625
- version: "0.1.32",
4210
+ version: "0.2.0",
3626
4211
  description: "Real-time CLI messaging for AI agents",
3627
4212
  type: "module",
3628
4213
  bin: {
@@ -32566,6 +33151,84 @@ var init_stdio2 = __esm(() => {
32566
33151
  init_stdio();
32567
33152
  });
32568
33153
 
33154
+ // src/lib/locks.ts
33155
+ function acquireLock(resourceType, resourceId, agentId, lockType = "advisory", expiryMs = DEFAULT_LOCK_EXPIRY_MS) {
33156
+ const db2 = getDb();
33157
+ return db2.transaction(() => {
33158
+ cleanExpiredLocks();
33159
+ const existing = db2.prepare(`
33160
+ SELECT * FROM resource_locks
33161
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
33162
+ `).get(resourceType, resourceId, lockType);
33163
+ if (existing) {
33164
+ if (existing.agent_id !== agentId) {
33165
+ return { acquired: false, lock: null, held_by: existing.agent_id };
33166
+ }
33167
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().replace("T", "T").replace("Z", "");
33168
+ db2.prepare(`
33169
+ UPDATE resource_locks SET expires_at = ?, locked_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')
33170
+ WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
33171
+ `).run(expiresAt, resourceType, resourceId, lockType);
33172
+ } else {
33173
+ const expiresAt = new Date(Date.now() + expiryMs).toISOString().slice(0, -1);
33174
+ db2.prepare(`
33175
+ INSERT INTO resource_locks (resource_type, resource_id, agent_id, lock_type, locked_at, expires_at)
33176
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'), ?)
33177
+ `).run(resourceType, resourceId, agentId, lockType, expiresAt);
33178
+ }
33179
+ const lock = db2.prepare(`
33180
+ SELECT * FROM resource_locks WHERE resource_type = ? AND resource_id = ? AND lock_type = ?
33181
+ `).get(resourceType, resourceId, lockType);
33182
+ return { acquired: true, lock };
33183
+ }).immediate();
33184
+ }
33185
+ function releaseLock(resourceType, resourceId, agentId) {
33186
+ const db2 = getDb();
33187
+ const result = db2.prepare(`
33188
+ DELETE FROM resource_locks
33189
+ WHERE resource_type = ? AND resource_id = ? AND agent_id = ?
33190
+ `).run(resourceType, resourceId, agentId);
33191
+ return result.changes > 0;
33192
+ }
33193
+ function checkLock(resourceType, resourceId) {
33194
+ const db2 = getDb();
33195
+ cleanExpiredLocks();
33196
+ return db2.prepare(`
33197
+ SELECT * FROM resource_locks
33198
+ WHERE resource_type = ? AND resource_id = ?
33199
+ ORDER BY locked_at ASC
33200
+ LIMIT 1
33201
+ `).get(resourceType, resourceId);
33202
+ }
33203
+ function cleanExpiredLocks() {
33204
+ const db2 = getDb();
33205
+ const result = db2.prepare(`
33206
+ DELETE FROM resource_locks WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%f', 'now')
33207
+ `).run();
33208
+ return result.changes;
33209
+ }
33210
+ function listLocks(opts) {
33211
+ const db2 = getDb();
33212
+ cleanExpiredLocks();
33213
+ let query = "SELECT * FROM resource_locks WHERE 1=1";
33214
+ const params = [];
33215
+ if (opts?.resource_type) {
33216
+ query += " AND resource_type = ?";
33217
+ params.push(opts.resource_type);
33218
+ }
33219
+ if (opts?.agent_id) {
33220
+ query += " AND agent_id = ?";
33221
+ params.push(opts.agent_id);
33222
+ }
33223
+ query += " ORDER BY locked_at ASC";
33224
+ return db2.prepare(query).all(...params);
33225
+ }
33226
+ var DEFAULT_LOCK_EXPIRY_MS;
33227
+ var init_locks = __esm(() => {
33228
+ init_db();
33229
+ DEFAULT_LOCK_EXPIRY_MS = 5 * 60 * 1000;
33230
+ });
33231
+
32569
33232
  // src/mcp/index.ts
32570
33233
  var exports_mcp = {};
32571
33234
  __export(exports_mcp, {
@@ -32599,6 +33262,12 @@ var init_mcp2 = __esm(() => {
32599
33262
  init_projects();
32600
33263
  init_identity();
32601
33264
  init_presence();
33265
+ init_reactions();
33266
+ init_locks();
33267
+ init_hot();
33268
+ init_topics();
33269
+ init_summary();
33270
+ init_graph();
32602
33271
  import__package = __toESM(require_package(), 1);
32603
33272
  server = new McpServer({
32604
33273
  name: "conversations",
@@ -33239,6 +33908,207 @@ var init_mcp2 = __esm(() => {
33239
33908
  content: [{ type: "text", text: JSON.stringify(messages) }]
33240
33909
  };
33241
33910
  });
33911
+ server.registerTool("build_graph", {
33912
+ description: "Build/rebuild the knowledge graph from messages, spaces, and projects. Creates relationship edges between agents, spaces, and projects.",
33913
+ inputSchema: {}
33914
+ }, async () => {
33915
+ const result = buildGraph();
33916
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
33917
+ });
33918
+ server.registerTool("get_related", {
33919
+ description: "Find all entities related to a given entity in the knowledge graph.",
33920
+ inputSchema: {
33921
+ entity_type: exports_external.string(),
33922
+ entity_id: exports_external.string()
33923
+ }
33924
+ }, async (args) => {
33925
+ const related = getRelated(args.entity_type, args.entity_id);
33926
+ return { content: [{ type: "text", text: JSON.stringify(related) }] };
33927
+ });
33928
+ server.registerTool("get_agent_network", {
33929
+ description: "Get an agent's communication network: who they talk to, spaces, projects.",
33930
+ inputSchema: {
33931
+ agent: exports_external.string()
33932
+ }
33933
+ }, async (args) => {
33934
+ const network = getAgentNetwork(args.agent);
33935
+ return { content: [{ type: "text", text: JSON.stringify(network) }] };
33936
+ });
33937
+ server.registerTool("graph_stats", {
33938
+ description: "Get knowledge graph statistics: total edges and counts by relation type.",
33939
+ inputSchema: {}
33940
+ }, async () => {
33941
+ const stats = getGraphStats();
33942
+ return { content: [{ type: "text", text: JSON.stringify(stats) }] };
33943
+ });
33944
+ server.registerTool("get_summary", {
33945
+ description: "Get a structured summary of a conversation (session or space): participants, topics, key messages, blockers, activity.",
33946
+ inputSchema: {
33947
+ session_id: exports_external.string().optional(),
33948
+ space: exports_external.string().optional(),
33949
+ limit: exports_external.coerce.number().optional()
33950
+ }
33951
+ }, async (args) => {
33952
+ const target = args.space || args.session_id;
33953
+ if (!target)
33954
+ return { content: [{ type: "text", text: "session_id or space required" }], isError: true };
33955
+ const summary = getConversationSummary(target, { limit: args.limit });
33956
+ if (!summary)
33957
+ return { content: [{ type: "text", text: `No messages found for "${target}"` }], isError: true };
33958
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
33959
+ });
33960
+ server.registerTool("get_topics", {
33961
+ description: "Extract topics from a space or session. Returns weighted keyword list.",
33962
+ inputSchema: {
33963
+ space: exports_external.string().optional(),
33964
+ session_id: exports_external.string().optional(),
33965
+ limit: exports_external.coerce.number().optional()
33966
+ }
33967
+ }, async (args) => {
33968
+ 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 });
33969
+ return { content: [{ type: "text", text: JSON.stringify(topics) }] };
33970
+ });
33971
+ server.registerTool("trending_topics", {
33972
+ description: "Get trending topics across all messages in the last N hours.",
33973
+ inputSchema: {
33974
+ hours: exports_external.coerce.number().optional(),
33975
+ project_id: exports_external.string().optional(),
33976
+ top_n: exports_external.coerce.number().optional()
33977
+ }
33978
+ }, async (args) => {
33979
+ const topics = getTrendingTopics({ hours: args.hours, project_id: args.project_id, top_n: args.top_n });
33980
+ return { content: [{ type: "text", text: JSON.stringify(topics) }] };
33981
+ });
33982
+ server.registerTool("get_session_activity", {
33983
+ description: "Get activity metrics for a session: message velocity, unique agents, reply ratio, reaction count, trending status.",
33984
+ inputSchema: {
33985
+ session_id: exports_external.string()
33986
+ }
33987
+ }, async (args) => {
33988
+ const activity = getSessionActivity(args.session_id);
33989
+ if (!activity) {
33990
+ return { content: [{ type: "text", text: `session "${args.session_id}" not found` }], isError: true };
33991
+ }
33992
+ return { content: [{ type: "text", text: JSON.stringify(activity) }] };
33993
+ });
33994
+ server.registerTool("hot_sessions", {
33995
+ description: "List conversations ranked by activity hotness (message velocity, reactions, replies, priority, blockers).",
33996
+ inputSchema: {
33997
+ limit: exports_external.coerce.number().optional(),
33998
+ min_score: exports_external.coerce.number().optional(),
33999
+ space: exports_external.string().optional(),
34000
+ project_id: exports_external.string().optional()
34001
+ }
34002
+ }, async (args) => {
34003
+ const sessions = listHotSessions({
34004
+ limit: args.limit,
34005
+ min_score: args.min_score,
34006
+ space: args.space,
34007
+ project_id: args.project_id
34008
+ });
34009
+ return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
34010
+ });
34011
+ server.registerTool("add_reaction", {
34012
+ description: "Add an emoji reaction to a message.",
34013
+ inputSchema: {
34014
+ message_id: exports_external.coerce.number(),
34015
+ emoji: exports_external.string(),
34016
+ from: exports_external.string().optional()
34017
+ }
34018
+ }, async (args) => {
34019
+ const { message_id, emoji: emoji3, from: fromParam } = args;
34020
+ const agent = resolveIdentity(fromParam);
34021
+ const reaction = addReaction(message_id, agent, emoji3);
34022
+ return { content: [{ type: "text", text: JSON.stringify(reaction) }] };
34023
+ });
34024
+ server.registerTool("remove_reaction", {
34025
+ description: "Remove an emoji reaction from a message.",
34026
+ inputSchema: {
34027
+ message_id: exports_external.coerce.number(),
34028
+ emoji: exports_external.string(),
34029
+ from: exports_external.string().optional()
34030
+ }
34031
+ }, async (args) => {
34032
+ const { message_id, emoji: emoji3, from: fromParam } = args;
34033
+ const agent = resolveIdentity(fromParam);
34034
+ const removed = removeReaction(message_id, agent, emoji3);
34035
+ return { content: [{ type: "text", text: JSON.stringify({ removed }) }] };
34036
+ });
34037
+ server.registerTool("get_reactions", {
34038
+ description: "Get all reactions for a message.",
34039
+ inputSchema: {
34040
+ message_id: exports_external.coerce.number()
34041
+ }
34042
+ }, async (args) => {
34043
+ const reactions = getReactions(args.message_id);
34044
+ return { content: [{ type: "text", text: JSON.stringify(reactions) }] };
34045
+ });
34046
+ server.registerTool("get_reaction_summary", {
34047
+ description: "Get emoji reaction counts and agent lists for a message.",
34048
+ inputSchema: {
34049
+ message_id: exports_external.coerce.number()
34050
+ }
34051
+ }, async (args) => {
34052
+ const summary = getReactionSummary(args.message_id);
34053
+ return { content: [{ type: "text", text: JSON.stringify(summary) }] };
34054
+ });
34055
+ server.registerTool("acquire_lock", {
34056
+ description: "Acquire an advisory or exclusive lock on a resource. Returns conflict info if another agent holds the lock.",
34057
+ inputSchema: {
34058
+ resource_type: exports_external.string(),
34059
+ resource_id: exports_external.string(),
34060
+ lock_type: exports_external.enum(["advisory", "exclusive"]).optional(),
34061
+ expiry_ms: exports_external.coerce.number().optional(),
34062
+ from: exports_external.string().optional()
34063
+ }
34064
+ }, async (args) => {
34065
+ const { resource_type, resource_id, lock_type, expiry_ms, from: fromParam } = args;
34066
+ const agent = resolveIdentity(fromParam);
34067
+ const result = acquireLock(resource_type, resource_id, agent, lock_type ?? "advisory", expiry_ms);
34068
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
34069
+ });
34070
+ server.registerTool("release_lock", {
34071
+ description: "Release a lock held by the agent on a resource.",
34072
+ inputSchema: {
34073
+ resource_type: exports_external.string(),
34074
+ resource_id: exports_external.string(),
34075
+ from: exports_external.string().optional()
34076
+ }
34077
+ }, async (args) => {
34078
+ const { resource_type, resource_id, from: fromParam } = args;
34079
+ const agent = resolveIdentity(fromParam);
34080
+ const released = releaseLock(resource_type, resource_id, agent);
34081
+ return { content: [{ type: "text", text: JSON.stringify({ released }) }] };
34082
+ });
34083
+ server.registerTool("check_lock", {
34084
+ description: "Check if a resource is currently locked and who holds it.",
34085
+ inputSchema: {
34086
+ resource_type: exports_external.string(),
34087
+ resource_id: exports_external.string()
34088
+ }
34089
+ }, async (args) => {
34090
+ const lock = checkLock(args.resource_type, args.resource_id);
34091
+ return { content: [{ type: "text", text: JSON.stringify(lock ?? { locked: false }) }] };
34092
+ });
34093
+ server.registerTool("list_locks", {
34094
+ description: "List all active (non-expired) locks. Filter by resource_type or agent.",
34095
+ inputSchema: {
34096
+ resource_type: exports_external.string().optional(),
34097
+ agent_id: exports_external.string().optional()
34098
+ }
34099
+ }, async (args) => {
34100
+ const locks = listLocks({ resource_type: args.resource_type, agent_id: args.agent_id });
34101
+ return { content: [{ type: "text", text: JSON.stringify(locks) }] };
34102
+ });
34103
+ server.registerTool("get_thread_replies", {
34104
+ description: "Get all replies in a thread for a given parent message ID.",
34105
+ inputSchema: {
34106
+ message_id: exports_external.coerce.number()
34107
+ }
34108
+ }, async (args) => {
34109
+ const replies = getThreadReplies(args.message_id);
34110
+ return { content: [{ type: "text", text: JSON.stringify(replies) }] };
34111
+ });
33242
34112
  server.registerTool("set_focus", {
33243
34113
  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.",
33244
34114
  inputSchema: {
@@ -33437,6 +34307,24 @@ var init_mcp2 = __esm(() => {
33437
34307
  "pin_message",
33438
34308
  "unpin_message",
33439
34309
  "get_pinned_messages",
34310
+ "build_graph",
34311
+ "get_related",
34312
+ "get_agent_network",
34313
+ "graph_stats",
34314
+ "get_summary",
34315
+ "get_topics",
34316
+ "trending_topics",
34317
+ "get_session_activity",
34318
+ "hot_sessions",
34319
+ "add_reaction",
34320
+ "remove_reaction",
34321
+ "get_reactions",
34322
+ "get_reaction_summary",
34323
+ "acquire_lock",
34324
+ "release_lock",
34325
+ "check_lock",
34326
+ "list_locks",
34327
+ "get_thread_replies",
33440
34328
  "set_focus",
33441
34329
  "get_focus",
33442
34330
  "unfocus",
@@ -33486,6 +34374,24 @@ var init_mcp2 = __esm(() => {
33486
34374
  pin_message: "Pin a message. Required: id",
33487
34375
  unpin_message: "Unpin a message. Required: id",
33488
34376
  get_pinned_messages: "Get pinned messages. Optional: space?, session_id?, limit?",
34377
+ build_graph: "Build/rebuild knowledge graph from messages, spaces, projects. Returns edge counts.",
34378
+ get_related: "Find entities related to a given entity. Required: entity_type, entity_id",
34379
+ get_agent_network: "Agent's communication network: contacts, spaces, projects. Required: agent",
34380
+ graph_stats: "Knowledge graph stats: total edges, by relation type",
34381
+ get_summary: "Structured conversation summary: participants, topics, key messages, blockers. Required: session_id? or space?. Optional: limit?",
34382
+ get_topics: "Extract topics from space or session. Optional: space?, session_id?, limit?",
34383
+ trending_topics: "Trending topics across all messages. Optional: hours?, project_id?, top_n?",
34384
+ get_session_activity: "Get activity metrics for a session: velocity, agents, reply ratio, reactions, trending. Required: session_id",
34385
+ hot_sessions: "List conversations by hotness score (velocity, reactions, replies, priority, blockers). Optional: limit?, min_score?, space?, project_id?",
34386
+ add_reaction: "Add emoji reaction to a message. Required: message_id, emoji. Optional: from?",
34387
+ remove_reaction: "Remove emoji reaction from a message. Required: message_id, emoji. Optional: from?",
34388
+ get_reactions: "Get all reactions for a message. Required: message_id",
34389
+ get_reaction_summary: "Get emoji counts + agent lists for a message. Required: message_id",
34390
+ acquire_lock: "Acquire advisory/exclusive lock on a resource. Required: resource_type, resource_id. Optional: lock_type?(advisory|exclusive), expiry_ms?, from?",
34391
+ release_lock: "Release lock held by agent. Required: resource_type, resource_id. Optional: from?",
34392
+ check_lock: "Check if resource is locked and who holds it. Required: resource_type, resource_id",
34393
+ list_locks: "List active locks. Optional: resource_type?, agent_id?",
34394
+ get_thread_replies: "Get all replies in a thread. Required: message_id. Optional: limit?",
33489
34395
  set_focus: "Set agent focus to a project. All read tools default to this scope. Required: project_id. Optional: from?",
33490
34396
  get_focus: "Get current focus: session focus, DB project_id, effective project_id. Optional: from?",
33491
34397
  unfocus: "Clear agent focus (session + DB). Optional: from?",
@@ -33944,6 +34850,43 @@ function startDashboardServer(port = 0, host) {
33944
34850
  const agents = listAgents({ online_only: onlineOnly });
33945
34851
  return jsonResponse(applyFields(agents, url2.searchParams.get("fields")));
33946
34852
  }
34853
+ if (path === "/api/sessions/hot" && req.method === "GET") {
34854
+ const limit = url2.searchParams.get("limit") ? parseInt(url2.searchParams.get("limit")) : undefined;
34855
+ const min_score = url2.searchParams.get("min_score") ? parseInt(url2.searchParams.get("min_score")) : undefined;
34856
+ const space = url2.searchParams.get("space") ?? undefined;
34857
+ const project_id = url2.searchParams.get("project_id") ?? undefined;
34858
+ const sessions = listHotSessions({ limit, min_score, space, project_id });
34859
+ return jsonResponse(sessions);
34860
+ }
34861
+ if (path === "/api/graph" && req.method === "GET") {
34862
+ const entityType = url2.searchParams.get("entity_type");
34863
+ const entityId = url2.searchParams.get("entity_id");
34864
+ if (entityType && entityId) {
34865
+ return jsonResponse(getRelated(entityType, entityId));
34866
+ }
34867
+ return jsonResponse(getGraphStats());
34868
+ }
34869
+ const agentNetMatch = path.match(/^\/api\/graph\/agent\/(.+)$/);
34870
+ if (agentNetMatch && req.method === "GET") {
34871
+ return jsonResponse(getAgentNetwork(decodeURIComponent(agentNetMatch[1])));
34872
+ }
34873
+ if (path === "/api/reactions" && req.method === "GET") {
34874
+ const messageIdStr = url2.searchParams.get("message_id");
34875
+ if (!messageIdStr)
34876
+ return jsonResponse({ error: "message_id required" }, 400);
34877
+ const messageId = parseInt(messageIdStr);
34878
+ if (isNaN(messageId))
34879
+ return jsonResponse({ error: "message_id must be a number" }, 400);
34880
+ const summary = url2.searchParams.get("summary") === "true";
34881
+ const result = summary ? getReactionSummary(messageId) : getReactions(messageId);
34882
+ return jsonResponse(result);
34883
+ }
34884
+ if (path === "/api/locks" && req.method === "GET") {
34885
+ const resource_type = url2.searchParams.get("resource_type") ?? undefined;
34886
+ const agent_id = url2.searchParams.get("agent_id") ?? undefined;
34887
+ const locks = listLocks({ resource_type, agent_id });
34888
+ return jsonResponse(locks);
34889
+ }
33947
34890
  if (path === "/api/version" && req.method === "GET") {
33948
34891
  try {
33949
34892
  const pkg2 = await Promise.resolve().then(() => __toESM(require_package(), 1));
@@ -34024,6 +34967,10 @@ var init_serve = __esm(() => {
34024
34967
  init_projects();
34025
34968
  init_db();
34026
34969
  init_presence();
34970
+ init_reactions();
34971
+ init_hot();
34972
+ init_graph();
34973
+ init_locks();
34027
34974
  isDirectRun2 = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("serve.ts") || process.argv[1]?.endsWith("serve.js");
34028
34975
  if (isDirectRun2) {
34029
34976
  const port = normalizePort(process.env.PORT, 0);
@@ -34055,6 +35002,11 @@ init_projects();
34055
35002
  init_db();
34056
35003
  init_identity();
34057
35004
  init_presence();
35005
+ init_reactions();
35006
+ init_hot();
35007
+ init_topics();
35008
+ init_summary();
35009
+ init_graph();
34058
35010
  init_terminal_markdown();
34059
35011
  import chalk3 from "chalk";
34060
35012
  import { render } from "ink";
@@ -35120,6 +36072,223 @@ program2.command("search").description("Search messages by content").argument("<
35120
36072
  }
35121
36073
  closeDb();
35122
36074
  });
36075
+ program2.command("since").description("Show all activity (DMs + spaces) since a duration ago").argument("<duration>", "Duration: e.g. 30m, 2h, 1d").option("--json", "Output as JSON").action((duration3, opts) => {
36076
+ const match = duration3.match(/^(\d+)([mhd])$/);
36077
+ if (!match) {
36078
+ console.error(chalk3.red(`Invalid duration "${duration3}". Use format: 30m, 2h, 1d`));
36079
+ process.exit(1);
36080
+ }
36081
+ const value = parseInt(match[1]);
36082
+ const unit = match[2];
36083
+ const msMap = { m: 60000, h: 3600000, d: 86400000 };
36084
+ const since = new Date(Date.now() - value * msMap[unit]).toISOString().replace("T", "T").slice(0, 23);
36085
+ const messages = readMessages({ since, order: "asc", limit: 200 });
36086
+ if (opts.json) {
36087
+ console.log(JSON.stringify(messages, null, 2));
36088
+ } else {
36089
+ if (messages.length === 0) {
36090
+ console.log(chalk3.dim(`No activity in the last ${duration3}.`));
36091
+ } else {
36092
+ console.log(chalk3.bold(`Activity since ${duration3} ago (${messages.length} message(s)):
36093
+ `));
36094
+ for (const msg of messages) {
36095
+ const time3 = chalk3.dim(msg.created_at.slice(11, 19));
36096
+ const from = chalk3.cyan(msg.from_agent);
36097
+ const where = msg.space ? chalk3.magenta(`#${msg.space}`) : chalk3.yellow(`\u2192 ${msg.to_agent}`);
36098
+ const priority = msg.priority !== "normal" ? chalk3.red(` [${msg.priority}]`) : "";
36099
+ const unread = !msg.read_at ? chalk3.green(" \u2022") : "";
36100
+ const content = renderContent(msg.content);
36101
+ console.log(`${time3} ${from} ${where}${priority}${unread}`);
36102
+ console.log(` ${content}
36103
+ `);
36104
+ }
36105
+ }
36106
+ }
36107
+ closeDb();
36108
+ });
36109
+ var graph = program2.command("graph").description("Knowledge graph operations");
36110
+ graph.command("build").description("Build/rebuild knowledge graph from messages, spaces, projects").option("--json", "Output as JSON").action((opts) => {
36111
+ const result = buildGraph();
36112
+ if (opts.json) {
36113
+ console.log(JSON.stringify(result, null, 2));
36114
+ } else {
36115
+ console.log(chalk3.green(`Graph built: ${result.edges_created} created, ${result.edges_updated} updated`));
36116
+ }
36117
+ closeDb();
36118
+ });
36119
+ graph.command("stats").description("Show knowledge graph statistics").option("--json", "Output as JSON").action((opts) => {
36120
+ const stats = getGraphStats();
36121
+ if (opts.json) {
36122
+ console.log(JSON.stringify(stats, null, 2));
36123
+ } else {
36124
+ console.log(chalk3.bold(`Knowledge Graph: ${stats.total_edges} edges
36125
+ `));
36126
+ for (const [relation, count] of Object.entries(stats.by_relation)) {
36127
+ console.log(` ${chalk3.cyan(relation.padEnd(20))} ${count}`);
36128
+ }
36129
+ }
36130
+ closeDb();
36131
+ });
36132
+ graph.command("agent").description("Show an agent's communication network").argument("<name>", "Agent name").option("--json", "Output as JSON").action((name, opts) => {
36133
+ const network = getAgentNetwork(name);
36134
+ if (opts.json) {
36135
+ console.log(JSON.stringify(network, null, 2));
36136
+ } else {
36137
+ console.log(chalk3.bold(`Network for ${chalk3.cyan(name)}
36138
+ `));
36139
+ if (network.communicates_with.length > 0) {
36140
+ console.log(chalk3.bold(" Communicates with:"));
36141
+ for (const c of network.communicates_with) {
36142
+ console.log(` ${chalk3.cyan(c.agent.padEnd(20))} ${chalk3.dim(`${c.message_count} msgs`)}`);
36143
+ }
36144
+ }
36145
+ if (network.spaces.length > 0) {
36146
+ console.log(chalk3.bold(" Active spaces:"));
36147
+ for (const s of network.spaces) {
36148
+ console.log(` ${chalk3.magenta("#" + s.space.padEnd(19))} ${chalk3.dim(`${s.message_count} msgs`)}`);
36149
+ }
36150
+ }
36151
+ if (network.projects.length > 0) {
36152
+ console.log(chalk3.bold(" Projects:") + " " + network.projects.join(", "));
36153
+ }
36154
+ }
36155
+ closeDb();
36156
+ });
36157
+ program2.command("summary").description("Get a structured summary of a conversation").argument("<target>", "Session ID or space name").option("--json", "Output as JSON").action((target, opts) => {
36158
+ const summary = getConversationSummary(target);
36159
+ if (!summary) {
36160
+ console.error(chalk3.red(`No messages found for "${target}"`));
36161
+ process.exit(1);
36162
+ }
36163
+ if (opts.json) {
36164
+ console.log(JSON.stringify(summary, null, 2));
36165
+ } else {
36166
+ console.log(chalk3.bold(`Summary: ${target}
36167
+ `));
36168
+ console.log(` ${chalk3.bold("Participants:")} ${summary.participants.join(", ")}`);
36169
+ console.log(` ${chalk3.bold("Messages:")} ${summary.message_count}`);
36170
+ console.log(` ${chalk3.bold("Date range:")} ${summary.date_range.first.slice(0, 16)} \u2192 ${summary.date_range.last.slice(0, 16)}`);
36171
+ console.log(` ${chalk3.bold("Replies:")} ${summary.activity.reply_count} ${chalk3.bold("Reactions:")} ${summary.activity.reaction_count}`);
36172
+ if (summary.topics.length > 0) {
36173
+ console.log(`
36174
+ ${chalk3.bold("Topics:")} ${summary.topics.slice(0, 5).map((t) => t.topic).join(", ")}`);
36175
+ }
36176
+ if (summary.key_messages.length > 0) {
36177
+ console.log(`
36178
+ ${chalk3.bold("Key messages:")}`);
36179
+ for (const k of summary.key_messages.slice(0, 5)) {
36180
+ console.log(` [#${k.id}] ${chalk3.cyan(k.from)} (${chalk3.yellow(k.reason)}): ${k.content.slice(0, 80)}`);
36181
+ }
36182
+ }
36183
+ if (summary.unresolved_blockers.length > 0) {
36184
+ console.log(`
36185
+ ${chalk3.red.bold("Unresolved blockers:")}`);
36186
+ for (const b of summary.unresolved_blockers) {
36187
+ console.log(` ${chalk3.red("[BLOCKER]")} [#${b.id}] ${chalk3.cyan(b.from)}: ${b.content.slice(0, 80)}`);
36188
+ }
36189
+ }
36190
+ }
36191
+ closeDb();
36192
+ });
36193
+ program2.command("topics").description("Extract topics from a space, session, or trending globally").option("--space <name>", "Topics for a specific space").option("--session <id>", "Topics for a specific session").option("--hours <n>", "Trending topics in last N hours", parseInt).option("--json", "Output as JSON").action((opts) => {
36194
+ let topics;
36195
+ if (opts.space) {
36196
+ topics = getSpaceTopics(opts.space);
36197
+ } else if (opts.session) {
36198
+ topics = getSessionTopics(opts.session);
36199
+ } else {
36200
+ topics = getTrendingTopics({ hours: opts.hours ?? 24 });
36201
+ }
36202
+ if (opts.json) {
36203
+ console.log(JSON.stringify(topics, null, 2));
36204
+ } else {
36205
+ if (topics.length === 0) {
36206
+ console.log(chalk3.dim("No topics found."));
36207
+ } else {
36208
+ const label = opts.space ? `#${opts.space}` : opts.session ? opts.session : `last ${opts.hours ?? 24}h`;
36209
+ console.log(chalk3.bold(`Topics for ${label}
36210
+ `));
36211
+ for (const t of topics) {
36212
+ const bar = "\u2588".repeat(Math.min(Math.round(t.weight * 50), 30));
36213
+ console.log(` ${chalk3.cyan(t.topic.padEnd(20))} ${chalk3.dim(`\xD7${t.count}`)} ${chalk3.green(bar)}`);
36214
+ }
36215
+ }
36216
+ }
36217
+ closeDb();
36218
+ });
36219
+ program2.command("hot").description("Show hot conversations ranked by activity").option("--limit <n>", "Max results", parseInt).option("--min-score <n>", "Minimum hotness score", parseInt).option("--space <name>", "Filter by space").option("--json", "Output as JSON").action((opts) => {
36220
+ const sessions = listHotSessions({
36221
+ limit: opts.limit ?? 10,
36222
+ min_score: opts.minScore,
36223
+ space: opts.space
36224
+ });
36225
+ if (opts.json) {
36226
+ console.log(JSON.stringify(sessions, null, 2));
36227
+ } else {
36228
+ if (sessions.length === 0) {
36229
+ console.log(chalk3.dim("No hot conversations."));
36230
+ } else {
36231
+ console.log(chalk3.bold(`Hot Conversations
36232
+ `));
36233
+ for (const s of sessions) {
36234
+ const score = s.hotness_score > 20 ? chalk3.red(`\uD83D\uDD25 ${s.hotness_score}`) : chalk3.yellow(` ${s.hotness_score}`);
36235
+ const where = s.space ? chalk3.magenta(`#${s.space}`) : chalk3.cyan(s.participants.join(", "));
36236
+ const time3 = chalk3.dim(s.last_message_at.slice(11, 16));
36237
+ const msgs = chalk3.dim(`${s.message_count} msgs`);
36238
+ const agents = chalk3.dim(`${s.metrics.unique_agents} agents`);
36239
+ console.log(`${score} ${where} ${time3} ${msgs} ${agents}`);
36240
+ }
36241
+ }
36242
+ }
36243
+ closeDb();
36244
+ });
36245
+ program2.command("context").description("One-shot session boot context for agents: online agents, unread DMs, spaces, recent activity").option("--json", "Output as JSON").action((opts) => {
36246
+ const agent = resolveIdentity();
36247
+ heartbeat(agent);
36248
+ const db2 = getDb();
36249
+ const onlineAgents = listAgents({ online_only: true });
36250
+ const unreadDMs = readMessages({ to: agent, unread_only: true, limit: 5 });
36251
+ const mySpaces = db2.prepare(`
36252
+ SELECT s.name, s.description,
36253
+ (SELECT COUNT(*) FROM messages m WHERE m.space = s.name AND m.read_at IS NULL) as unread
36254
+ FROM spaces s
36255
+ JOIN space_members sm ON sm.space = s.name
36256
+ WHERE sm.agent = ?
36257
+ ORDER BY s.name
36258
+ `).all(agent);
36259
+ const recentDMs = readMessages({ to: agent, limit: 3 });
36260
+ const context = { agent, online_agents: onlineAgents, unread_dms: unreadDMs, spaces: mySpaces, recent_dms: recentDMs };
36261
+ if (opts.json) {
36262
+ console.log(JSON.stringify(context, null, 2));
36263
+ } else {
36264
+ console.log(chalk3.bold(`Context for ${chalk3.cyan(agent)}
36265
+ `));
36266
+ if (onlineAgents.length > 0) {
36267
+ const names = onlineAgents.map((a) => chalk3.green(a.agent)).join(", ");
36268
+ console.log(`${chalk3.bold("Online agents:")} ${names}`);
36269
+ } else {
36270
+ console.log(`${chalk3.bold("Online agents:")} ${chalk3.dim("none")}`);
36271
+ }
36272
+ if (unreadDMs.length > 0) {
36273
+ console.log(`${chalk3.bold("Unread DMs:")} ${chalk3.yellow(unreadDMs.length + " message(s)")}`);
36274
+ for (const msg of unreadDMs.slice(0, 3)) {
36275
+ console.log(` ${chalk3.dim(msg.created_at.slice(11, 16))} ${chalk3.cyan(msg.from_agent)}: ${msg.content.slice(0, 80)}`);
36276
+ }
36277
+ } else {
36278
+ console.log(`${chalk3.bold("Unread DMs:")} ${chalk3.dim("none")}`);
36279
+ }
36280
+ if (mySpaces.length > 0) {
36281
+ console.log(`${chalk3.bold("My spaces:")}`);
36282
+ for (const sp of mySpaces) {
36283
+ const unread = sp.unread > 0 ? chalk3.yellow(` (${sp.unread} unread)`) : "";
36284
+ console.log(` ${chalk3.magenta("#" + sp.name)}${unread}`);
36285
+ }
36286
+ } else {
36287
+ console.log(`${chalk3.bold("My spaces:")} ${chalk3.dim("none")}`);
36288
+ }
36289
+ }
36290
+ closeDb();
36291
+ });
35123
36292
  program2.command("sessions").description("List conversation sessions").option("--agent <id>", "Filter sessions involving this agent").option("--json", "Output as JSON").action((opts) => {
35124
36293
  const sessions = listSessions(opts.agent);
35125
36294
  if (opts.json) {
@@ -35234,6 +36403,79 @@ program2.command("status").description("Show database stats").option("--json", "
35234
36403
  }
35235
36404
  closeDb();
35236
36405
  });
36406
+ program2.command("doctor").description("Check conversations setup and health").option("--json", "Output as JSON").action(async (opts) => {
36407
+ const checks4 = [];
36408
+ try {
36409
+ const db2 = getDb();
36410
+ db2.prepare("SELECT 1").get();
36411
+ const dbPath = getDbPath();
36412
+ checks4.push({ name: "Database", ok: true, message: `OK \u2014 ${dbPath}` });
36413
+ } catch (e) {
36414
+ checks4.push({ name: "Database", ok: false, message: `Cannot open DB: ${e.message}` });
36415
+ }
36416
+ try {
36417
+ const db2 = getDb();
36418
+ const mode = db2.prepare("PRAGMA journal_mode").get();
36419
+ const isWal = mode.journal_mode === "wal";
36420
+ checks4.push({ name: "WAL mode", ok: isWal, message: isWal ? "OK \u2014 WAL mode enabled" : `WARNING \u2014 journal_mode is ${mode.journal_mode}` });
36421
+ } catch {
36422
+ checks4.push({ name: "WAL mode", ok: false, message: "Could not check WAL mode" });
36423
+ }
36424
+ try {
36425
+ const proc = Bun.spawn(["which", "conversations-mcp"], { stdout: "pipe", stderr: "pipe" });
36426
+ const exit = await proc.exited;
36427
+ const path = await new Response(proc.stdout).text();
36428
+ checks4.push({ name: "MCP binary", ok: exit === 0, message: exit === 0 ? `OK \u2014 ${path.trim()}` : "conversations-mcp not found in PATH \u2014 run: bun install -g @hasna/conversations" });
36429
+ } catch {
36430
+ checks4.push({ name: "MCP binary", ok: false, message: "Could not check MCP binary" });
36431
+ }
36432
+ try {
36433
+ const current = import__package2.default.version;
36434
+ const res = await fetch("https://registry.npmjs.org/@hasna/conversations/latest");
36435
+ const data = await res.json();
36436
+ const latest = data.version;
36437
+ const upToDate = current === latest;
36438
+ checks4.push({ name: "npm version", ok: upToDate, message: upToDate ? `OK \u2014 v${current} (latest)` : `Update available: v${current} \u2192 v${latest} \u2014 run: bun install -g @hasna/conversations@latest` });
36439
+ } catch {
36440
+ checks4.push({ name: "npm version", ok: true, message: "Could not check npm registry (offline?)" });
36441
+ }
36442
+ const { homedir: homedir5 } = await import("os");
36443
+ const { existsSync: existsSync2 } = await import("fs");
36444
+ const { join: join6 } = await import("path");
36445
+ const configPath = process.env.CONVERSATIONS_CONFIG_PATH ?? join6(homedir5(), ".conversations", "config.json");
36446
+ if (existsSync2(configPath)) {
36447
+ try {
36448
+ const { readFileSync: readFileSync3 } = await import("fs");
36449
+ JSON.parse(readFileSync3(configPath, "utf8"));
36450
+ checks4.push({ name: "Webhook config", ok: true, message: `OK \u2014 ${configPath}` });
36451
+ } catch (e) {
36452
+ checks4.push({ name: "Webhook config", ok: false, message: `Invalid JSON at ${configPath}: ${e.message}` });
36453
+ }
36454
+ } else {
36455
+ checks4.push({ name: "Webhook config", ok: true, message: "No webhook config (optional)" });
36456
+ }
36457
+ closeDb();
36458
+ const allOk = checks4.every((c) => c.ok);
36459
+ if (opts.json) {
36460
+ console.log(JSON.stringify({ ok: allOk, checks: checks4 }, null, 2));
36461
+ } else {
36462
+ console.log(chalk3.bold(`Conversations Doctor
36463
+ `));
36464
+ for (const check2 of checks4) {
36465
+ const icon = check2.ok ? chalk3.green("\u2713") : chalk3.red("\u2717");
36466
+ const label = chalk3.bold(check2.name.padEnd(16));
36467
+ console.log(` ${icon} ${label} ${check2.message}`);
36468
+ }
36469
+ console.log();
36470
+ if (allOk) {
36471
+ console.log(chalk3.green("All checks passed."));
36472
+ } else {
36473
+ const failed = checks4.filter((c) => !c.ok).length;
36474
+ console.log(chalk3.red(`${failed} check(s) failed.`));
36475
+ process.exit(1);
36476
+ }
36477
+ }
36478
+ });
35237
36479
  program2.command("update").description("Check for and install updates").option("--check", "Only check for updates, don't install").option("--json", "Output as JSON").action(async (opts) => {
35238
36480
  const pkg3 = await Promise.resolve().then(() => __toESM(require_package(), 1));
35239
36481
  const current = pkg3.version;
@@ -35760,6 +37002,64 @@ program2.command("unpin").description("Unpin a message").argument("<id>", "Messa
35760
37002
  }
35761
37003
  closeDb();
35762
37004
  });
37005
+ program2.command("pinned").description("List pinned messages").option("--space <name>", "Filter by space").option("--session <id>", "Filter by session ID").option("--limit <n>", "Max results", parseInt).option("--json", "Output as JSON").action((opts) => {
37006
+ const messages = getPinnedMessages({ space: opts.space, session_id: opts.session, limit: opts.limit });
37007
+ if (opts.json) {
37008
+ console.log(JSON.stringify(messages, null, 2));
37009
+ } else {
37010
+ if (messages.length === 0) {
37011
+ console.log(chalk3.dim("No pinned messages."));
37012
+ } else {
37013
+ console.log(chalk3.dim(`${messages.length} pinned message(s):
37014
+ `));
37015
+ for (const msg of messages) {
37016
+ const time3 = chalk3.dim(msg.created_at.slice(11, 19));
37017
+ const from = chalk3.cyan(msg.from_agent);
37018
+ const where = msg.space ? chalk3.magenta(`#${msg.space}`) : chalk3.yellow(msg.to_agent);
37019
+ console.log(`${chalk3.yellow("\uD83D\uDCCC")} [#${msg.id}] ${time3} ${from} \u2192 ${where}: ${msg.content}`);
37020
+ }
37021
+ }
37022
+ }
37023
+ closeDb();
37024
+ });
37025
+ program2.command("react").description("Add an emoji reaction to a message").argument("<id>", "Message ID", parseInt).argument("<emoji>", "Emoji to react with").option("--from <agent>", "Agent identity override").option("--json", "Output as JSON").action((id, emoji3, opts) => {
37026
+ const agent = resolveIdentity(opts.from);
37027
+ const reaction = addReaction(id, agent, emoji3);
37028
+ if (opts.json) {
37029
+ console.log(JSON.stringify(reaction, null, 2));
37030
+ } else {
37031
+ console.log(chalk3.green(`${emoji3} reaction added to message #${id}`));
37032
+ }
37033
+ closeDb();
37034
+ });
37035
+ program2.command("unreact").description("Remove an emoji reaction from a message").argument("<id>", "Message ID", parseInt).argument("<emoji>", "Emoji to remove").option("--from <agent>", "Agent identity override").option("--json", "Output as JSON").action((id, emoji3, opts) => {
37036
+ const agent = resolveIdentity(opts.from);
37037
+ const removed = removeReaction(id, agent, emoji3);
37038
+ if (opts.json) {
37039
+ console.log(JSON.stringify({ removed }, null, 2));
37040
+ } else {
37041
+ if (removed) {
37042
+ console.log(chalk3.green(`${emoji3} reaction removed from message #${id}`));
37043
+ } else {
37044
+ console.log(chalk3.dim(`No ${emoji3} reaction found on message #${id}`));
37045
+ }
37046
+ }
37047
+ closeDb();
37048
+ });
37049
+ program2.command("reactions").description("Show emoji reactions on a message").argument("<id>", "Message ID", parseInt).option("--json", "Output as JSON").action((id, opts) => {
37050
+ const summary = getReactionSummary(id);
37051
+ if (opts.json) {
37052
+ console.log(JSON.stringify(summary, null, 2));
37053
+ } else {
37054
+ if (summary.length === 0) {
37055
+ console.log(chalk3.dim(`No reactions on message #${id}`));
37056
+ } else {
37057
+ const parts = summary.map((r) => `${r.emoji} ${r.count}`).join(" ");
37058
+ console.log(`Message #${id}: ${parts}`);
37059
+ }
37060
+ }
37061
+ closeDb();
37062
+ });
35763
37063
  var agents = program2.command("agents").description("Manage agents");
35764
37064
  agents.command("list").description("List all agents with their presence status").option("--online", "Only show online agents").option("--json", "Output as JSON").action((opts) => {
35765
37065
  const agent = resolveIdentity();