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/README.md +25 -10
- package/dist/manifest.js +102 -25
- package/dist/manifest.js.map +2 -2
- package/dist/ui/index.js +261 -22
- package/dist/ui/index.js.map +2 -2
- package/dist/worker.js +548 -28
- package/dist/worker.js.map +4 -4
- package/package.json +10 -16
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:
|
|
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
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
if (
|
|
1862
|
-
|
|
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
|
-
|
|
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: ${
|
|
2065
|
+
content: `Today's corpus: ${results.length} tweets total. Showing top ${limited.length}.
|
|
2066
|
+
|
|
2067
|
+
Top results:
|
|
2068
|
+
${topLinks}`,
|
|
1892
2069
|
data: {
|
|
1893
|
-
date:
|
|
1894
|
-
total:
|
|
1895
|
-
items:
|
|
1896
|
-
stats: summary
|
|
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: "
|
|
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;
|