peak6-x-intelligence-plugin 0.1.0 → 0.1.1

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/dist/worker.js CHANGED
@@ -901,7 +901,11 @@ var TOOL_NAMES = {
901
901
  getToday: "get-today",
902
902
  getAuthorities: "get-authorities",
903
903
  suggestHandles: "suggest-handles",
904
- trackHandle: "track-handle"
904
+ trackHandle: "track-handle",
905
+ analyzeTopic: "analyze-topic",
906
+ getThread: "get-thread",
907
+ promoteHandle: "promote-handle",
908
+ rateTweet: "rate-tweet"
905
909
  };
906
910
  var ENTITY_TYPES = {
907
911
  tweet: "tweet",
@@ -915,7 +919,8 @@ var STATE_KEYS = {
915
919
  var EVENT_NAMES = {
916
920
  corpusUpdated: "corpus.updated",
917
921
  handlePromoted: "handle.promoted",
918
- gapIdentified: "gap.identified"
922
+ gapIdentified: "gap.identified",
923
+ tweetHighEngagement: "tweet.high-engagement"
919
924
  };
920
925
  var DEFAULT_CONFIG = {
921
926
  company_id: "",
@@ -1017,7 +1022,12 @@ var DEFAULT_CONFIG = {
1017
1022
  },
1018
1023
  tracking_window_days: 14
1019
1024
  },
1020
- corpus_retention_days: 90
1025
+ corpus_retention_days: 90,
1026
+ engagement_thresholds: {
1027
+ noise_floor: 10,
1028
+ expert: 50,
1029
+ escalation: 100
1030
+ }
1021
1031
  };
1022
1032
 
1023
1033
  // src/pipeline/xai-client.ts
@@ -1181,6 +1191,59 @@ async function verifyTweetExists(http, secrets, bearerRef, tweetId) {
1181
1191
  const data = await response.json();
1182
1192
  return (data.data?.length ?? 0) > 0;
1183
1193
  }
1194
+ async function fetchTweetWithConversationId(http, secrets, logger, bearerRef, tweetId) {
1195
+ const bearerToken = await secrets.resolve(bearerRef);
1196
+ const url = `${X_API_BASE}/tweets?ids=${tweetId}&tweet.fields=conversation_id,${TWEET_FIELDS}&expansions=${EXPANSIONS}&user.fields=${USER_FIELDS}`;
1197
+ const response = await http.fetch(url, {
1198
+ method: "GET",
1199
+ headers: { Authorization: `Bearer ${bearerToken}` }
1200
+ });
1201
+ if (!response.ok) {
1202
+ const errorText = await response.text();
1203
+ throw new Error(`X API v2 error ${response.status}: ${errorText}`);
1204
+ }
1205
+ const data = await response.json();
1206
+ if (!data.data?.length) {
1207
+ throw new Error(`Tweet ${tweetId} not found`);
1208
+ }
1209
+ const rawTweet = data.data[0];
1210
+ const userMap = /* @__PURE__ */ new Map();
1211
+ if (data.includes?.users) {
1212
+ for (const user of data.includes.users) userMap.set(user.id, user);
1213
+ }
1214
+ const tweet = mapToEnrichedTweet(rawTweet, userMap.get(rawTweet.author_id), "open_discovery");
1215
+ const conversationId = rawTweet.conversation_id ?? tweetId;
1216
+ logger.debug("Fetched tweet with conversation_id", { tweetId, conversationId });
1217
+ return { tweet, conversationId };
1218
+ }
1219
+ async function fetchConversationTweets(http, secrets, logger, bearerRef, conversationId) {
1220
+ const bearerToken = await secrets.resolve(bearerRef);
1221
+ const query = encodeURIComponent(`conversation_id:${conversationId}`);
1222
+ const url = `${X_API_BASE}/tweets/search/recent?query=${query}&tweet.fields=${TWEET_FIELDS}&expansions=${EXPANSIONS}&user.fields=${USER_FIELDS}&max_results=100`;
1223
+ logger.debug("Fetching conversation tweets", { conversationId });
1224
+ const response = await http.fetch(url, {
1225
+ method: "GET",
1226
+ headers: { Authorization: `Bearer ${bearerToken}` }
1227
+ });
1228
+ if (!response.ok) {
1229
+ const errorText = await response.text();
1230
+ logger.warn("Conversation search failed", { status: response.status, error: errorText });
1231
+ return [];
1232
+ }
1233
+ const data = await response.json();
1234
+ const userMap = /* @__PURE__ */ new Map();
1235
+ if (data.includes?.users) {
1236
+ for (const user of data.includes.users) userMap.set(user.id, user);
1237
+ }
1238
+ const tweets = [];
1239
+ if (data.data) {
1240
+ for (const tweet of data.data) {
1241
+ tweets.push(mapToEnrichedTweet(tweet, userMap.get(tweet.author_id), "open_discovery"));
1242
+ }
1243
+ }
1244
+ logger.debug("Conversation tweets fetched", { conversationId, count: tweets.length });
1245
+ return tweets;
1246
+ }
1184
1247
 
1185
1248
  // src/pipeline/scoring.ts
1186
1249
  function trigrams(text) {
@@ -1253,6 +1316,24 @@ function computeRelevanceScore(tweet, topics) {
1253
1316
  const topicBoost = Math.min(matchCount * 0.1, 0.5);
1254
1317
  return Math.min(0.5 + topicBoost, 1);
1255
1318
  }
1319
+ var SINCE_PATTERN = /^(\d+)([hd])$/;
1320
+ function parseSince(since) {
1321
+ const match = SINCE_PATTERN.exec(since.trim());
1322
+ if (match) {
1323
+ const amount = parseInt(match[1], 10);
1324
+ const unit = match[2];
1325
+ const ms = unit === "h" ? amount * 36e5 : amount * 864e5;
1326
+ return new Date(Date.now() - ms);
1327
+ }
1328
+ if (/^\d{4}-\d{2}-\d{2}/.test(since)) {
1329
+ const d = new Date(since);
1330
+ return isNaN(d.getTime()) ? null : d;
1331
+ }
1332
+ return null;
1333
+ }
1334
+ function computeTotalEngagement(metrics) {
1335
+ return metrics.like_count + metrics.retweet_count + metrics.reply_count + metrics.quote_count;
1336
+ }
1256
1337
  function scoreTweets(tweets, weights, subWeights, authorityBoost, authorityHandles, authorityByDomain, topics, now) {
1257
1338
  const scoreTime = now ?? /* @__PURE__ */ new Date();
1258
1339
  return tweets.map((tweet) => {
@@ -1556,7 +1637,53 @@ function buildHandleUpdatesFromTweets(tweets, topics, authorityByDomain) {
1556
1637
  return [...handleMap.values()];
1557
1638
  }
1558
1639
 
1640
+ // src/pipeline/analyze-topic.ts
1641
+ function buildTopicQueries(topic, dateStr) {
1642
+ return [
1643
+ {
1644
+ text: `What are people saying about ${topic} today (${dateStr})? Focus on significant discussions, breaking developments, and notable opinions.`,
1645
+ topic: "core",
1646
+ phase: "open"
1647
+ },
1648
+ {
1649
+ text: `Which experts, analysts, or thought leaders are currently discussing ${topic}? What insights and perspectives are they sharing?`,
1650
+ topic: "expert_voices",
1651
+ phase: "open"
1652
+ },
1653
+ {
1654
+ text: `What problems, complaints, concerns, or risks are people raising about ${topic}? What pain points are being discussed?`,
1655
+ topic: "pain_points",
1656
+ phase: "open"
1657
+ },
1658
+ {
1659
+ text: `What positive developments, innovations, breakthroughs, or success stories related to ${topic} are being shared?`,
1660
+ topic: "positive_signals",
1661
+ phase: "open"
1662
+ },
1663
+ {
1664
+ text: `What articles, research papers, blog posts, or reports about ${topic} are being shared and discussed on X?`,
1665
+ topic: "link_based",
1666
+ phase: "open"
1667
+ }
1668
+ ];
1669
+ }
1670
+ function crossReferenceCorpus(topic, corpus) {
1671
+ const topicWords = topic.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
1672
+ const threshold = Math.max(1, Math.ceil(topicWords.length * 0.4));
1673
+ return corpus.filter((tweet) => {
1674
+ const textLower = tweet.text.toLowerCase();
1675
+ const matchCount = topicWords.filter((w) => textLower.includes(w)).length;
1676
+ return matchCount >= threshold;
1677
+ }).sort((a, b) => b.score - a.score);
1678
+ }
1679
+
1559
1680
  // src/worker.ts
1681
+ function tweetUrl(authorUsername, tweetId) {
1682
+ return `https://x.com/${authorUsername}/status/${tweetId}`;
1683
+ }
1684
+ function withUrl(tweet) {
1685
+ return { ...tweet, url: tweetUrl(tweet.author_username, tweet.tweet_id) };
1686
+ }
1560
1687
  async function getConfig(ctx) {
1561
1688
  const raw = await ctx.config.get();
1562
1689
  return { ...DEFAULT_CONFIG, ...raw };
@@ -1571,6 +1698,7 @@ function buildAuthorityMaps(config) {
1571
1698
  }
1572
1699
  return { allHandles, byDomain };
1573
1700
  }
1701
+ var pluginCtx = null;
1574
1702
  async function runDiscoveryPipeline(ctx, job) {
1575
1703
  const config = await getConfig(ctx);
1576
1704
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
@@ -1697,6 +1825,24 @@ async function runDiscoveryPipeline(ctx, job) {
1697
1825
  afterDedup: deduplicated.length,
1698
1826
  finalCorpus: corpus.length
1699
1827
  });
1828
+ const engagementThresholds = config.engagement_thresholds ?? DEFAULT_CONFIG.engagement_thresholds;
1829
+ for (const tweet of corpus) {
1830
+ const totalEngagement = computeTotalEngagement(tweet.metrics);
1831
+ if (totalEngagement >= engagementThresholds.escalation) {
1832
+ await ctx.events.emit(EVENT_NAMES.tweetHighEngagement, config.company_id, {
1833
+ tweet_id: tweet.tweet_id,
1834
+ author_username: tweet.author_username,
1835
+ text: tweet.text.slice(0, 280),
1836
+ url: tweetUrl(tweet.author_username, tweet.tweet_id),
1837
+ score: tweet.score,
1838
+ total_engagement: totalEngagement,
1839
+ metrics: tweet.metrics,
1840
+ is_authority: tweet.is_authority,
1841
+ threshold_crossed: "escalation"
1842
+ });
1843
+ await ctx.metrics.write("pipeline.tweet.high_engagement", 1);
1844
+ }
1845
+ }
1700
1846
  for (const tweet of corpus) {
1701
1847
  await ctx.entities.upsert({
1702
1848
  entityType: ENTITY_TYPES.tweet,
@@ -1748,7 +1894,18 @@ async function runDiscoveryPipeline(ctx, job) {
1748
1894
  await ctx.events.emit(EVENT_NAMES.corpusUpdated, config.company_id, {
1749
1895
  date: today,
1750
1896
  corpusSize: corpus.length,
1751
- topScore: corpus[0]?.score ?? 0
1897
+ topScore: corpus[0]?.score ?? 0,
1898
+ top_items: corpus.slice(0, 5).map((t) => ({
1899
+ tweet_id: t.tweet_id,
1900
+ author_username: t.author_username,
1901
+ score: t.score,
1902
+ text: t.text.slice(0, 140),
1903
+ url: tweetUrl(t.author_username, t.tweet_id),
1904
+ is_authority: t.is_authority
1905
+ })),
1906
+ stats: summary.discovery_stats,
1907
+ authority_count: Object.values(config.authority_lists).reduce((n, l) => n + l.handles.length, 0),
1908
+ handle_candidates: handleStats.discovered
1752
1909
  });
1753
1910
  for (const promo of promotions) {
1754
1911
  if (promo.status === "promoted") {
@@ -1847,25 +2004,34 @@ async function handleSearchCorpus(ctx, params, _runCtx) {
1847
2004
  const date = typeof p.date === "string" ? p.date : void 0;
1848
2005
  const pillar = typeof p.pillar === "string" ? p.pillar.toLowerCase() : void 0;
1849
2006
  const limit = typeof p.limit === "number" ? p.limit : 20;
2007
+ const minScore = typeof p.min_score === "number" ? p.min_score : 0;
2008
+ const minEngagement = typeof p.min_engagement === "number" ? p.min_engagement : 0;
2009
+ const since = typeof p.since === "string" ? p.since : void 0;
2010
+ const sinceDate = since ? parseSince(since) : null;
1850
2011
  const tweets = await ctx.entities.list({
1851
2012
  entityType: ENTITY_TYPES.tweet,
1852
- limit: 200
2013
+ limit: 500
1853
2014
  });
1854
2015
  const results = [];
1855
2016
  for (const tweet of tweets) {
1856
2017
  if (tweet.status === "removed" || tweet.status === "archived") continue;
1857
2018
  const data = tweet.data;
1858
- const textMatch = query ? data.text.toLowerCase().includes(query) : true;
1859
- const dateMatch = date ? data.created_at?.startsWith(date) ?? false : true;
1860
- const pillarMatch = pillar ? data.authority_domains?.some((d) => d.toLowerCase().includes(pillar)) ?? false : true;
1861
- if (textMatch && dateMatch && pillarMatch) {
1862
- results.push(data);
1863
- }
2019
+ if (query && !data.text.toLowerCase().includes(query)) continue;
2020
+ if (date && !(data.created_at?.startsWith(date) ?? false)) continue;
2021
+ if (sinceDate && new Date(data.created_at) < sinceDate) continue;
2022
+ if (pillar && !(data.authority_domains?.some((d) => d.toLowerCase().includes(pillar)) ?? false)) continue;
2023
+ if (data.score < minScore) continue;
2024
+ if (minEngagement > 0 && computeTotalEngagement(data.metrics) < minEngagement) continue;
2025
+ results.push(data);
1864
2026
  }
1865
2027
  results.sort((a, b) => b.score - a.score);
1866
- const limited = results.slice(0, limit);
2028
+ const limited = results.slice(0, limit).map(withUrl);
2029
+ const topLinks = limited.slice(0, 5).map((t) => `- @${t.author_username} (score: ${t.score.toFixed(1)}): ${t.url}`).join("\n");
1867
2030
  return {
1868
- content: `Found ${limited.length} tweets matching "${query}"`,
2031
+ content: `Found ${limited.length} of ${results.length} tweets matching "${query}"
2032
+
2033
+ Top results:
2034
+ ${topLinks}`,
1869
2035
  data: limited
1870
2036
  };
1871
2037
  }
@@ -1873,27 +2039,38 @@ async function handleGetToday(ctx, params, _runCtx) {
1873
2039
  const p = params;
1874
2040
  const pillar = typeof p.pillar === "string" ? p.pillar : void 0;
1875
2041
  const limit = typeof p.limit === "number" ? p.limit : 20;
2042
+ const minScore = typeof p.min_score === "number" ? p.min_score : 0;
1876
2043
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2044
+ const tweets = await ctx.entities.list({
2045
+ entityType: ENTITY_TYPES.tweet,
2046
+ limit: 500
2047
+ });
2048
+ const results = [];
2049
+ for (const tweet of tweets) {
2050
+ if (tweet.status === "removed" || tweet.status === "archived") continue;
2051
+ const data = tweet.data;
2052
+ if (!data.created_at?.startsWith(today)) continue;
2053
+ if (data.score < minScore) continue;
2054
+ if (pillar && !(data.authority_domains?.some((d) => d.toLowerCase().includes(pillar.toLowerCase())) ?? false)) continue;
2055
+ results.push(data);
2056
+ }
2057
+ results.sort((a, b) => b.score - a.score);
2058
+ const limited = results.slice(0, limit).map(withUrl);
1877
2059
  const summary = await ctx.state.get({
1878
2060
  scopeKind: "instance",
1879
2061
  stateKey: `${STATE_KEYS.corpusPrefix}${today}`
1880
2062
  });
1881
- if (!summary) {
1882
- return { content: "No corpus available for today. Discovery may not have run yet." };
1883
- }
1884
- let items = summary.top_items;
1885
- if (pillar) {
1886
- items = items.filter(
1887
- (t) => t.authority_domains?.some((d) => d.toLowerCase().includes(pillar.toLowerCase())) ?? false
1888
- );
1889
- }
2063
+ const topLinks = limited.slice(0, 5).map((t) => `- @${t.author_username} (score: ${t.score.toFixed(1)}): ${t.url}`).join("\n");
1890
2064
  return {
1891
- content: `Today's corpus: ${summary.total_tweets} tweets. Showing top ${Math.min(limit, items.length)}.`,
2065
+ content: `Today's corpus: ${results.length} tweets total. Showing top ${limited.length}.
2066
+
2067
+ Top results:
2068
+ ${topLinks}`,
1892
2069
  data: {
1893
- date: summary.date,
1894
- total: summary.total_tweets,
1895
- items: items.slice(0, limit),
1896
- stats: summary.discovery_stats
2070
+ date: today,
2071
+ total: results.length,
2072
+ items: limited,
2073
+ stats: summary?.discovery_stats ?? null
1897
2074
  }
1898
2075
  };
1899
2076
  }
@@ -1967,8 +2144,221 @@ async function handleTrackHandle(ctx, params, _runCtx) {
1967
2144
  content: `Handle @${handle} tracked with relevance ${relevance} in domain "${domain}"`
1968
2145
  };
1969
2146
  }
2147
+ async function handleAnalyzeTopic(ctx, params, _runCtx) {
2148
+ const p = params;
2149
+ const topic = typeof p.topic === "string" ? p.topic : "";
2150
+ const includeCorpus = p.include_corpus !== false;
2151
+ if (!topic) return { error: "topic is required" };
2152
+ const config = await getConfig(ctx);
2153
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2154
+ await ctx.metrics.write("tool.analyze_topic.invoked", 1);
2155
+ const queries = buildTopicQueries(topic, today);
2156
+ const freshResults = [];
2157
+ let totalCost = 0;
2158
+ for (const query of queries) {
2159
+ try {
2160
+ const result2 = await xaiSearch(
2161
+ ctx.http,
2162
+ ctx.secrets,
2163
+ ctx.logger,
2164
+ config.xai_api_key_ref,
2165
+ { query: query.text, fromDate: today, toDate: today, excludedHandles: config.global_excluded }
2166
+ );
2167
+ freshResults.push({
2168
+ tweet_ids: result2.tweetIds,
2169
+ query_type: query.topic,
2170
+ synthesis_text: result2.synthesisText
2171
+ });
2172
+ totalCost += result2.costUsdTicks;
2173
+ } catch (err) {
2174
+ ctx.logger.warn("Analyze topic sub-query failed", {
2175
+ queryType: query.topic,
2176
+ error: err instanceof Error ? err.message : String(err)
2177
+ });
2178
+ }
2179
+ }
2180
+ let corpusMatches = [];
2181
+ if (includeCorpus) {
2182
+ const allTweets = await ctx.entities.list({ entityType: ENTITY_TYPES.tweet, limit: 500 });
2183
+ const corpus = allTweets.filter((t) => t.status === "active").map((t) => t.data);
2184
+ corpusMatches = crossReferenceCorpus(topic, corpus);
2185
+ }
2186
+ await ctx.metrics.write("tool.analyze_topic.cost", totalCost / 1e9);
2187
+ const corpusWithUrls = corpusMatches.slice(0, 10).map(withUrl);
2188
+ const result = {
2189
+ topic,
2190
+ synthesis: freshResults.map((r) => `## ${r.query_type}
2191
+ ${r.synthesis_text}`).join("\n\n"),
2192
+ corpus_matches: corpusWithUrls,
2193
+ fresh_results: freshResults,
2194
+ generated_at: (/* @__PURE__ */ new Date()).toISOString()
2195
+ };
2196
+ const matchLinks = corpusWithUrls.slice(0, 5).map((t) => `- @${t.author_username}: ${t.url}`).join("\n");
2197
+ return {
2198
+ content: `Topic analysis for "${topic}": ${freshResults.length} queries completed, ${corpusMatches.length} corpus matches found.${matchLinks ? `
2199
+
2200
+ Top corpus matches:
2201
+ ${matchLinks}` : ""}`,
2202
+ data: result
2203
+ };
2204
+ }
2205
+ async function handleGetThread(ctx, params, _runCtx) {
2206
+ const p = params;
2207
+ const tweetId = typeof p.tweet_id === "string" ? p.tweet_id : "";
2208
+ if (!tweetId) return { error: "tweet_id is required" };
2209
+ const config = await getConfig(ctx);
2210
+ await ctx.metrics.write("tool.get_thread.invoked", 1);
2211
+ try {
2212
+ const { tweet, conversationId } = await fetchTweetWithConversationId(
2213
+ ctx.http,
2214
+ ctx.secrets,
2215
+ ctx.logger,
2216
+ config.x_api_bearer_ref,
2217
+ tweetId
2218
+ );
2219
+ const conversationTweets = await fetchConversationTweets(
2220
+ ctx.http,
2221
+ ctx.secrets,
2222
+ ctx.logger,
2223
+ config.x_api_bearer_ref,
2224
+ conversationId
2225
+ );
2226
+ const allThreadTweets = [tweet, ...conversationTweets.filter((t) => t.tweet_id !== tweetId)].map(withUrl);
2227
+ const result = {
2228
+ root_tweet_id: tweetId,
2229
+ conversation_id: conversationId,
2230
+ tweets: allThreadTweets,
2231
+ total_in_conversation: conversationTweets.length + 1
2232
+ };
2233
+ const threadLinks = allThreadTweets.map((t) => `- @${t.author_username}: ${t.url}`).join("\n");
2234
+ return {
2235
+ content: `Thread for tweet ${tweetId}: ${result.tweets.length} tweets in conversation ${conversationId}
2236
+
2237
+ ${threadLinks}`,
2238
+ data: result
2239
+ };
2240
+ } catch (err) {
2241
+ return {
2242
+ error: `Failed to fetch thread: ${err instanceof Error ? err.message : String(err)}`
2243
+ };
2244
+ }
2245
+ }
2246
+ async function handlePromoteHandle(ctx, params, runCtx) {
2247
+ const p = params;
2248
+ const handle = typeof p.handle === "string" ? p.handle.toLowerCase().replace(/^@/, "") : "";
2249
+ const listName = typeof p.list_name === "string" ? p.list_name : "";
2250
+ const note = typeof p.note === "string" ? p.note : "";
2251
+ if (!handle) return { error: "handle is required" };
2252
+ if (!listName) return { error: "list_name is required" };
2253
+ const config = await getConfig(ctx);
2254
+ if (!config.authority_lists[listName]) {
2255
+ return { error: `Authority list "${listName}" not found. Available: ${Object.keys(config.authority_lists).join(", ")}` };
2256
+ }
2257
+ const existing = await ctx.entities.list({
2258
+ entityType: ENTITY_TYPES.handle,
2259
+ externalId: handle,
2260
+ limit: 1
2261
+ });
2262
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2263
+ if (existing.length > 0) {
2264
+ const record = existing[0];
2265
+ const data = record.data;
2266
+ await ctx.entities.upsert({
2267
+ entityType: ENTITY_TYPES.handle,
2268
+ scopeKind: "instance",
2269
+ externalId: handle,
2270
+ title: handle,
2271
+ status: "promoted",
2272
+ data: {
2273
+ ...data,
2274
+ promoted_to_list: listName,
2275
+ notes: note ? `${data.notes ? data.notes + "; " : ""}Promoted to ${listName}: ${note}` : data.notes
2276
+ }
2277
+ });
2278
+ } else {
2279
+ const data = {
2280
+ first_seen: now.split("T")[0],
2281
+ last_seen: now.split("T")[0],
2282
+ appearances: 0,
2283
+ avg_relevance: 0,
2284
+ domains: [listName],
2285
+ followed_by_existing: [],
2286
+ followers_count: 0,
2287
+ sample_tweet_ids: [],
2288
+ promoted_to_list: listName,
2289
+ notes: note ? `Manually promoted to ${listName}: ${note}` : `Manually promoted to ${listName}`
2290
+ };
2291
+ await ctx.entities.upsert({
2292
+ entityType: ENTITY_TYPES.handle,
2293
+ scopeKind: "instance",
2294
+ externalId: handle,
2295
+ title: handle,
2296
+ status: "promoted",
2297
+ data
2298
+ });
2299
+ }
2300
+ await ctx.events.emit(EVENT_NAMES.handlePromoted, config.company_id, {
2301
+ handle,
2302
+ list_name: listName,
2303
+ promoted_by: runCtx.agentId ?? "manual",
2304
+ note
2305
+ });
2306
+ await ctx.activity.log({
2307
+ companyId: config.company_id,
2308
+ message: `Handle @${handle} promoted to authority list "${listName}"`,
2309
+ metadata: { list_name: listName, note }
2310
+ });
2311
+ return {
2312
+ content: `Handle @${handle} promoted to authority list "${listName}"`
2313
+ };
2314
+ }
2315
+ async function handleRateTweet(ctx, params, runCtx) {
2316
+ const p = params;
2317
+ const tweetId = typeof p.tweet_id === "string" ? p.tweet_id : "";
2318
+ const rating = typeof p.rating === "string" ? p.rating : "";
2319
+ const ratedBy = typeof p.rated_by === "string" ? p.rated_by : runCtx.agentId ?? "unknown";
2320
+ if (!tweetId) return { error: "tweet_id is required" };
2321
+ if (!["relevant", "irrelevant", "skip"].includes(rating)) {
2322
+ return { error: 'rating must be "relevant", "irrelevant", or "skip"' };
2323
+ }
2324
+ const existing = await ctx.entities.list({
2325
+ entityType: ENTITY_TYPES.tweet,
2326
+ externalId: tweetId,
2327
+ limit: 1
2328
+ });
2329
+ if (existing.length === 0) {
2330
+ return { error: `Tweet ${tweetId} not found in corpus` };
2331
+ }
2332
+ const record = existing[0];
2333
+ const data = record.data;
2334
+ const existingRatings = Array.isArray(data.ratings) ? data.ratings : [];
2335
+ const newRating = {
2336
+ rated_by: ratedBy,
2337
+ rated_at: (/* @__PURE__ */ new Date()).toISOString(),
2338
+ value: rating
2339
+ };
2340
+ await ctx.entities.upsert({
2341
+ entityType: ENTITY_TYPES.tweet,
2342
+ scopeKind: "instance",
2343
+ externalId: tweetId,
2344
+ title: record.title ?? tweetId,
2345
+ status: record.status ?? "active",
2346
+ data: {
2347
+ ...data,
2348
+ ratings: [...existingRatings, newRating]
2349
+ }
2350
+ });
2351
+ await ctx.metrics.write("tool.rate_tweet.invoked", 1);
2352
+ const data2 = record.data;
2353
+ const ratedUrl = data2.author_username ? tweetUrl(data2.author_username, tweetId) : `https://x.com/i/status/${tweetId}`;
2354
+ return {
2355
+ content: `Tweet ${tweetId} rated as "${rating}" by ${ratedBy}. Total ratings: ${existingRatings.length + 1}
2356
+ ${ratedUrl}`
2357
+ };
2358
+ }
1970
2359
  var plugin = definePlugin({
1971
2360
  async setup(ctx) {
2361
+ pluginCtx = ctx;
1972
2362
  ctx.jobs.register(JOB_KEYS.discoveryRun, (job) => runDiscoveryPipeline(ctx, job));
1973
2363
  ctx.jobs.register(JOB_KEYS.authorityDecay, (job) => handleAuthorityDecay(ctx, job));
1974
2364
  ctx.jobs.register(JOB_KEYS.complianceCheck, (job) => handleComplianceCheck(ctx, job));
@@ -1998,6 +2388,26 @@ var plugin = definePlugin({
1998
2388
  { displayName: "Track Handle", description: "Record a handle appearance", parametersSchema: {} },
1999
2389
  (params, runCtx) => handleTrackHandle(ctx, params, runCtx)
2000
2390
  );
2391
+ ctx.tools.register(
2392
+ TOOL_NAMES.analyzeTopic,
2393
+ { displayName: "Analyze Topic", description: "On-demand xAI topic analysis with 5-perspective decomposition and corpus cross-reference", parametersSchema: {} },
2394
+ (params, runCtx) => handleAnalyzeTopic(ctx, params, runCtx)
2395
+ );
2396
+ ctx.tools.register(
2397
+ TOOL_NAMES.getThread,
2398
+ { displayName: "Get Tweet Thread", description: "Fetch conversation context for a tweet", parametersSchema: {} },
2399
+ (params, runCtx) => handleGetThread(ctx, params, runCtx)
2400
+ );
2401
+ ctx.tools.register(
2402
+ TOOL_NAMES.promoteHandle,
2403
+ { displayName: "Promote Handle", description: "Promote a handle to authority status in a specific list", parametersSchema: {} },
2404
+ (params, runCtx) => handlePromoteHandle(ctx, params, runCtx)
2405
+ );
2406
+ ctx.tools.register(
2407
+ TOOL_NAMES.rateTweet,
2408
+ { displayName: "Rate Tweet", description: "Record relevance feedback on a corpus tweet", parametersSchema: {} },
2409
+ (params, runCtx) => handleRateTweet(ctx, params, runCtx)
2410
+ );
2001
2411
  ctx.data.register("dashboard-summary", async () => {
2002
2412
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2003
2413
  const summary = await ctx.state.get({
@@ -2028,6 +2438,57 @@ var plugin = definePlugin({
2028
2438
  ctx.data.register("plugin-config", async () => {
2029
2439
  return await ctx.config.get();
2030
2440
  });
2441
+ ctx.data.register("corpus-browser", async (params) => {
2442
+ const p = params;
2443
+ const date = typeof p.date === "string" ? p.date : (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2444
+ const pillar = typeof p.pillar === "string" ? p.pillar : void 0;
2445
+ const minScore = typeof p.min_score === "number" ? p.min_score : 0;
2446
+ const minEngagement = typeof p.min_engagement === "number" ? p.min_engagement : 0;
2447
+ const authorityOnly = p.authority_only === true;
2448
+ const page = typeof p.page === "number" ? p.page : 1;
2449
+ const pageSize = typeof p.page_size === "number" ? p.page_size : 25;
2450
+ const allTweets = await ctx.entities.list({
2451
+ entityType: ENTITY_TYPES.tweet,
2452
+ limit: 500
2453
+ });
2454
+ const filtered = [];
2455
+ for (const tweet of allTweets) {
2456
+ if (tweet.status === "removed" || tweet.status === "archived") continue;
2457
+ const data = tweet.data;
2458
+ if (!data.created_at?.startsWith(date)) continue;
2459
+ if (data.score < minScore) continue;
2460
+ if (pillar && !(data.authority_domains?.some((d) => d.toLowerCase().includes(pillar.toLowerCase())) ?? false)) continue;
2461
+ if (minEngagement > 0 && computeTotalEngagement(data.metrics) < minEngagement) continue;
2462
+ if (authorityOnly && !data.is_authority) continue;
2463
+ filtered.push(data);
2464
+ }
2465
+ filtered.sort((a, b) => b.score - a.score);
2466
+ const totalCount = filtered.length;
2467
+ const totalPages = Math.ceil(totalCount / pageSize) || 1;
2468
+ const start = (page - 1) * pageSize;
2469
+ const items = filtered.slice(start, start + pageSize).map(withUrl);
2470
+ const dates = [];
2471
+ for (let i = 0; i < 7; i++) {
2472
+ const d = /* @__PURE__ */ new Date();
2473
+ d.setDate(d.getDate() - i);
2474
+ dates.push(d.toISOString().split("T")[0]);
2475
+ }
2476
+ const config = await getConfig(ctx);
2477
+ const result = {
2478
+ items,
2479
+ pagination: { page, pageSize, totalCount, totalPages },
2480
+ filters: {
2481
+ date,
2482
+ pillar: pillar ?? null,
2483
+ minScore,
2484
+ minEngagement,
2485
+ authorityOnly
2486
+ },
2487
+ available_pillars: config.content_pillars,
2488
+ available_dates: dates
2489
+ };
2490
+ return result;
2491
+ });
2031
2492
  let discoveryRunning = false;
2032
2493
  ctx.actions.register("trigger-discovery", async () => {
2033
2494
  if (discoveryRunning) {
@@ -2049,7 +2510,66 @@ var plugin = definePlugin({
2049
2510
  ctx.logger.info("X Intelligence plugin initialized");
2050
2511
  },
2051
2512
  async onHealth() {
2052
- return { status: "ok", message: "X Intelligence plugin is running" };
2513
+ if (!pluginCtx) return { status: "error", message: "Plugin not initialized" };
2514
+ try {
2515
+ const config = await getConfig(pluginCtx);
2516
+ const lastRun = await pluginCtx.state.get({
2517
+ scopeKind: "instance",
2518
+ stateKey: STATE_KEYS.lastDiscoveryRun
2519
+ });
2520
+ const now = /* @__PURE__ */ new Date();
2521
+ const lastRunDate = lastRun?.generatedAt ? new Date(lastRun.generatedAt) : null;
2522
+ const hoursSinceLastRun = lastRunDate ? (now.getTime() - lastRunDate.getTime()) / (1e3 * 60 * 60) : null;
2523
+ const status = hoursSinceLastRun === null ? "degraded" : hoursSinceLastRun > 36 ? "degraded" : "ok";
2524
+ return {
2525
+ status,
2526
+ message: lastRun ? `Last discovery: ${lastRun.date} (${Math.round(hoursSinceLastRun)}h ago)` : "No discovery runs yet",
2527
+ details: {
2528
+ lastDiscoveryRun: lastRun?.date ?? null,
2529
+ hoursSinceLastRun: hoursSinceLastRun ? Math.round(hoursSinceLastRun) : null,
2530
+ configuredTopics: config.semantic_topics.length,
2531
+ configuredAuthorities: Object.keys(config.authority_lists).length
2532
+ }
2533
+ };
2534
+ } catch (err) {
2535
+ return {
2536
+ status: "error",
2537
+ message: `Health check failed: ${err instanceof Error ? err.message : String(err)}`
2538
+ };
2539
+ }
2540
+ },
2541
+ async onValidateConfig(config) {
2542
+ const warnings = [];
2543
+ const errors = [];
2544
+ const xaiRef = config.xai_api_key_ref;
2545
+ const xRef = config.x_api_bearer_ref;
2546
+ if (!xaiRef) errors.push("xai_api_key_ref is required");
2547
+ if (!xRef) errors.push("x_api_bearer_ref is required");
2548
+ if (pluginCtx && xaiRef) {
2549
+ try {
2550
+ const key = await pluginCtx.secrets.resolve(xaiRef);
2551
+ if (!key || key.length < 10) warnings.push("xAI API key appears invalid (too short)");
2552
+ } catch {
2553
+ errors.push(`Cannot resolve xAI API key secret: ${xaiRef}`);
2554
+ }
2555
+ }
2556
+ if (pluginCtx && xRef) {
2557
+ try {
2558
+ const key = await pluginCtx.secrets.resolve(xRef);
2559
+ if (!key || key.length < 10) warnings.push("X API bearer token appears invalid (too short)");
2560
+ } catch {
2561
+ errors.push(`Cannot resolve X API bearer secret: ${xRef}`);
2562
+ }
2563
+ }
2564
+ const sw = config.scoring_weights;
2565
+ if (sw) {
2566
+ const sum = (sw.relevance ?? 0) + (sw.recency ?? 0) + (sw.engagement ?? 0);
2567
+ if (Math.abs(sum - 1) > 0.05) warnings.push(`Scoring weights sum to ${sum.toFixed(2)}, expected ~1.0`);
2568
+ }
2569
+ if (config.semantic_topics?.length === 0) {
2570
+ warnings.push("No semantic topics configured \u2014 discovery will produce no results");
2571
+ }
2572
+ return { ok: errors.length === 0, warnings, errors };
2053
2573
  }
2054
2574
  });
2055
2575
  var worker_default = plugin;