thumbgate 0.9.10 → 0.9.12
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/.claude-plugin/README.md +2 -2
- package/.claude-plugin/marketplace.json +4 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +115 -312
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +61 -1
- package/adapters/opencode/opencode.json +4 -2
- package/bin/cli.js +156 -8
- package/bin/memory.sh +3 -3
- package/config/e2e-critical-flows.json +4 -0
- package/config/gates/default.json +74 -2
- package/config/github-about.json +1 -1
- package/config/mcp-allowlists.json +27 -0
- package/package.json +22 -5
- package/plugins/amp-skill/INSTALL.md +1 -0
- package/plugins/amp-skill/SKILL.md +1 -0
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +4 -2
- package/plugins/claude-skill/INSTALL.md +1 -0
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +4 -2
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-marketplace/README.md +3 -3
- package/plugins/cursor-marketplace/mcp.json +3 -1
- package/plugins/cursor-marketplace/scripts/gate-check.sh +15 -5
- package/plugins/gemini-extension/INSTALL.md +3 -3
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/dashboard.html +15 -8
- package/public/index.html +125 -185
- package/public/js/buyer-intent.js +252 -0
- package/public/pro.html +1085 -0
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/adk-consolidator.js +14 -2
- package/scripts/agent-readiness.js +3 -1
- package/scripts/agent-security-hardening.js +4 -4
- package/scripts/auto-promote-gates.js +2 -0
- package/scripts/auto-wire-hooks.js +105 -17
- package/scripts/behavioral-extraction.js +2 -6
- package/scripts/billing.js +107 -3
- package/scripts/budget-guard.js +2 -2
- package/scripts/build-metadata.js +14 -0
- package/scripts/context-engine.js +1 -0
- package/scripts/deploy-policy.js +3 -17
- package/scripts/dpo-optimizer.js +3 -6
- package/scripts/ensure-repo-bootstrap.js +129 -0
- package/scripts/export-dpo-pairs.js +2 -3
- package/scripts/export-kto-pairs.js +3 -4
- package/scripts/export-training.js +8 -6
- package/scripts/feedback-attribution.js +23 -11
- package/scripts/feedback-loop.js +40 -2
- package/scripts/feedback-to-rules.js +2 -1
- package/scripts/filesystem-search.js +3 -2
- package/scripts/gates-engine.js +760 -29
- package/scripts/generate-pretool-hook.sh +0 -0
- package/scripts/gtm-revenue-loop.js +20 -1
- package/scripts/hook-auto-capture.sh +8 -3
- package/scripts/hook-runtime.js +81 -0
- package/scripts/hook-stop-self-score.sh +3 -3
- package/scripts/hook-thumbgate-cache-updater.js +99 -38
- package/scripts/hosted-config.js +4 -16
- package/scripts/hybrid-feedback-context.js +54 -14
- package/scripts/install-mcp.js +13 -3
- package/scripts/intent-router.js +2 -2
- package/scripts/license.js +52 -14
- package/scripts/local-model-profile.js +3 -2
- package/scripts/mcp-config.js +62 -7
- package/scripts/meta-policy.js +4 -8
- package/scripts/money-watcher.js +166 -16
- package/scripts/obsidian-export.js +1 -0
- package/scripts/operational-integrity.js +480 -0
- package/scripts/post-everywhere.js +35 -12
- package/scripts/pr-manager.js +14 -11
- package/scripts/profile-router.js +2 -0
- package/scripts/prompt-dlp.js +1 -0
- package/scripts/publish-decision.js +10 -0
- package/scripts/published-cli.js +61 -0
- package/scripts/risk-scorer.js +3 -2
- package/scripts/rlhf_session_start.sh +32 -0
- package/scripts/skill-quality-tracker.js +3 -5
- package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
- package/scripts/social-analytics/db/social-analytics.db-wal +0 -0
- package/scripts/social-analytics/engagement-audit.js +202 -0
- package/scripts/social-analytics/instagram-thumbgate-post.js +45 -7
- package/scripts/social-analytics/install-growth-automation.js +114 -0
- package/scripts/social-analytics/load-env.js +46 -0
- package/scripts/social-analytics/poll-all.js +23 -23
- package/scripts/social-analytics/pollers/plausible.js +2 -4
- package/scripts/social-analytics/pollers/zernio.js +3 -0
- package/scripts/social-analytics/publish-instagram-thumbgate.js +22 -3
- package/scripts/social-analytics/publish-thumbgate-launch.js +322 -0
- package/scripts/social-analytics/publishers/reddit.js +7 -12
- package/scripts/social-analytics/publishers/zernio.js +301 -22
- package/scripts/social-analytics/reconcile-thumbgate-campaign.js +165 -0
- package/scripts/social-analytics/schedule-thumbgate-campaign.js +275 -0
- package/scripts/social-analytics/sync-launch-assets.js +185 -0
- package/scripts/social-post-hourly.js +185 -0
- package/scripts/social-quality-gate.js +119 -3
- package/scripts/social-reply-monitor.js +184 -37
- package/scripts/statusline-cache-path.js +27 -0
- package/scripts/statusline-local-stats.js +16 -0
- package/scripts/statusline-meta.js +22 -0
- package/scripts/statusline.sh +40 -33
- package/scripts/sync-version.js +24 -3
- package/scripts/test-coverage.js +21 -13
- package/scripts/tool-registry.js +97 -0
- package/scripts/train_from_feedback.py +32 -9
- package/scripts/validate-feedback.js +3 -2
- package/scripts/vector-store.js +2 -3
- package/scripts/verify-obsidian-setup.sh +3 -3
- package/src/api/server.js +281 -33
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
+
|
|
3
4
|
const BOT_SLOP_PATTERNS = [
|
|
4
5
|
{ id: 'emoji_spam', pattern: /(?:🚀|💡|🔥|⚡|🎯|💪|🙌|👀){3,}/g, reason: 'Excessive emoji spam' },
|
|
5
6
|
{ id: 'generic_opener', pattern: /^(?:Just|Excited to|Thrilled to|Happy to|Proud to) (?:\w+ )*?(?:launch|ship|release|publish|built|creat|announc)/i, reason: 'Generic shipped opener' },
|
|
@@ -11,8 +12,123 @@ const BOT_SLOP_PATTERNS = [
|
|
|
11
12
|
{ id: 'self_congratulation', pattern: /(?:We're proud to|I'm honored to|Humbled to|Grateful to announce)/i, reason: 'Self-congratulatory opener' },
|
|
12
13
|
{ id: 'empty_hype', pattern: /(?:game.?changer|revolutionary|disruptive|next.?gen|cutting.?edge|world.?class|best.?in.?class)/i, reason: 'Empty hype words' },
|
|
13
14
|
];
|
|
15
|
+
|
|
16
|
+
const REPLY_TOPIC_PATTERNS = {
|
|
17
|
+
skills: /\bskill|template|process|workflow|review|sprint|implement|phase/i,
|
|
18
|
+
context: /\bcontext doc|context docs|conflicting|inconsisten|claude\.md|cursorrules|instruction/i,
|
|
19
|
+
memory: /\bmemory|remember|amnesia|across sessions|next session|compaction|persist/i,
|
|
20
|
+
setup: /\binstall|setup|config|init|repo|github|link|tool|open source|built/i,
|
|
21
|
+
gates: /\bgate|hook|block|prevent|pretooluse|mcp/i,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const UNSOLICITED_PROMO_PATTERNS = [
|
|
25
|
+
{ id: 'unsolicited_link', pattern: /https?:\/\//i, reason: 'Unsolicited link in reply' },
|
|
26
|
+
{ id: 'unsolicited_install', pattern: /npx thumbgate init/i, reason: 'Unsolicited install CTA in reply' },
|
|
27
|
+
{ id: 'unsolicited_stack_dump', pattern: /\b(?:sqlite\+fts5|thompson sampling|pretooluse|mcp server)\b/i, reason: 'Unsolicited architecture dump in reply' },
|
|
28
|
+
];
|
|
29
|
+
|
|
14
30
|
const MIN_POST_LENGTH = 30;
|
|
15
31
|
const MAX_POST_LENGTH = 2000;
|
|
16
|
-
|
|
17
|
-
function
|
|
18
|
-
|
|
32
|
+
|
|
33
|
+
function scanForSlop(postText) {
|
|
34
|
+
const text = String(postText || '');
|
|
35
|
+
const findings = [];
|
|
36
|
+
|
|
37
|
+
if (text.length < MIN_POST_LENGTH) {
|
|
38
|
+
findings.push({ id: 'too_short', reason: 'Too short' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (text.length > MAX_POST_LENGTH) {
|
|
42
|
+
findings.push({ id: 'too_long', reason: 'Too long' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const rule of BOT_SLOP_PATTERNS) {
|
|
46
|
+
rule.pattern.lastIndex = 0;
|
|
47
|
+
if (rule.pattern.test(text)) {
|
|
48
|
+
findings.push({ id: rule.id, reason: rule.reason });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const words = text.split(/\s+/).filter((word) => word.length > 3);
|
|
53
|
+
const capsWords = words.filter((word) => word === word.toUpperCase() && /[A-Z]/.test(word));
|
|
54
|
+
if (words.length > 5 && capsWords.length / words.length > 0.3) {
|
|
55
|
+
findings.push({ id: 'caps_shouting', reason: 'Too many ALL CAPS' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
allowed: findings.length === 0,
|
|
60
|
+
findings,
|
|
61
|
+
findingCount: findings.length,
|
|
62
|
+
postLength: text.length,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function detectReplyTopics(text) {
|
|
67
|
+
const content = String(text || '');
|
|
68
|
+
return Object.entries(REPLY_TOPIC_PATTERNS)
|
|
69
|
+
.filter(([, pattern]) => pattern.test(content))
|
|
70
|
+
.map(([topic]) => topic);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commentExplicitlyRequestsProduct(commentText) {
|
|
74
|
+
return /\b(?:what tool|what is it|which tool|repo|github|link|where can i find|can you share|how do i install|setup details|what did you build)\b/i.test(
|
|
75
|
+
String(commentText || '')
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function gateContextualReply(commentText, replyText, options = {}) {
|
|
80
|
+
const scan = scanForSlop(replyText);
|
|
81
|
+
const findings = [...scan.findings];
|
|
82
|
+
const comment = String(commentText || '');
|
|
83
|
+
const reply = String(replyText || '');
|
|
84
|
+
const platform = String(options.platform || '').toLowerCase();
|
|
85
|
+
const commentTopics = detectReplyTopics(comment);
|
|
86
|
+
const replyTopics = detectReplyTopics(reply);
|
|
87
|
+
|
|
88
|
+
if (commentTopics.length > 0 && !commentTopics.some((topic) => replyTopics.includes(topic))) {
|
|
89
|
+
findings.push({
|
|
90
|
+
id: 'not_contextual',
|
|
91
|
+
reason: 'Reply does not address the commenter’s actual point',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (platform === 'reddit' && !commentExplicitlyRequestsProduct(comment)) {
|
|
96
|
+
for (const rule of UNSOLICITED_PROMO_PATTERNS) {
|
|
97
|
+
rule.pattern.lastIndex = 0;
|
|
98
|
+
if (rule.pattern.test(reply)) {
|
|
99
|
+
findings.push({ id: rule.id, reason: rule.reason });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
allowed: findings.length === 0,
|
|
106
|
+
findings,
|
|
107
|
+
findingCount: findings.length,
|
|
108
|
+
replyLength: reply.length,
|
|
109
|
+
commentTopics,
|
|
110
|
+
replyTopics,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function gatePost(postText) {
|
|
115
|
+
const scan = scanForSlop(postText);
|
|
116
|
+
if (!scan.allowed) {
|
|
117
|
+
console.error('[social-quality-gate] BLOCKED:');
|
|
118
|
+
for (const finding of scan.findings) {
|
|
119
|
+
console.error(' -', finding.id, finding.reason);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return scan;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
BOT_SLOP_PATTERNS,
|
|
127
|
+
MAX_POST_LENGTH,
|
|
128
|
+
MIN_POST_LENGTH,
|
|
129
|
+
commentExplicitlyRequestsProduct,
|
|
130
|
+
detectReplyTopics,
|
|
131
|
+
gateContextualReply,
|
|
132
|
+
gatePost,
|
|
133
|
+
scanForSlop,
|
|
134
|
+
};
|
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
* State file: .thumbgate/reply-monitor-state.json — tracks which replies we've already responded to.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
require('dotenv').config();
|
|
20
19
|
const fs = require('fs');
|
|
21
20
|
const path = require('path');
|
|
21
|
+
const { loadLocalEnv } = require('./social-analytics/load-env');
|
|
22
|
+
const { gateContextualReply, commentExplicitlyRequestsProduct } = require('./social-quality-gate');
|
|
22
23
|
|
|
23
24
|
const STATE_FILE = path.resolve(__dirname, '..', '.thumbgate', 'reply-monitor-state.json');
|
|
24
25
|
const REDDIT_API_BASE = 'https://oauth.reddit.com';
|
|
26
|
+
const DEFAULT_X_HANDLE = 'IgorGanapolsky';
|
|
27
|
+
|
|
28
|
+
loadLocalEnv();
|
|
25
29
|
|
|
26
30
|
// ---------------------------------------------------------------------------
|
|
27
31
|
// State management
|
|
@@ -58,6 +62,85 @@ function saveDraft(draft) {
|
|
|
58
62
|
fs.appendFileSync(DRAFT_FILE, JSON.stringify(draft) + '\n');
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
function normalizeArray(value) {
|
|
66
|
+
if (value && Array.isArray(value.data)) {
|
|
67
|
+
return value.data;
|
|
68
|
+
}
|
|
69
|
+
return Array.isArray(value) ? value : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isRevenueRelevantXTweet(tweet = {}) {
|
|
73
|
+
const text = String(tweet.text || '').toLowerCase();
|
|
74
|
+
if (!text) return false;
|
|
75
|
+
return /thumbgate|thumbgate-production|checkout\/pro|workflow-sprint|pre-action gates|vibe coding/.test(text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildOwnedConversationQuery(tweetId, username = DEFAULT_X_HANDLE) {
|
|
79
|
+
const normalizedId = String(tweetId || '').trim();
|
|
80
|
+
const normalizedUsername = String(username || DEFAULT_X_HANDLE).trim();
|
|
81
|
+
if (!normalizedId) {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
return `conversation_id:${normalizedId} -from:${normalizedUsername}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function collectXSearchCandidates(options = {}) {
|
|
88
|
+
const searchTweets = options.searchTweets;
|
|
89
|
+
if (typeof searchTweets !== 'function') {
|
|
90
|
+
return { tweets: [], searchMode: 'unavailable', queryLog: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const username = options.username || process.env.X_USERNAME || DEFAULT_X_HANDLE;
|
|
94
|
+
const ownUserId = options.ownUserId || process.env.X_USER_ID || '1733256637199073280';
|
|
95
|
+
const queryLog = [];
|
|
96
|
+
const aggregate = [];
|
|
97
|
+
const seenTweetIds = new Set();
|
|
98
|
+
let ownedCandidates = [];
|
|
99
|
+
|
|
100
|
+
if (typeof options.fetchOwnedTweets === 'function') {
|
|
101
|
+
try {
|
|
102
|
+
const recentTweets = await options.fetchOwnedTweets();
|
|
103
|
+
ownedCandidates = normalizeArray(recentTweets)
|
|
104
|
+
.filter((tweet) => String(tweet.author_id || ownUserId) === ownUserId)
|
|
105
|
+
.filter(isRevenueRelevantXTweet)
|
|
106
|
+
.slice(0, 6);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.warn(`[reply-monitor] Could not fetch own X tweets: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const tweet of ownedCandidates) {
|
|
113
|
+
const query = buildOwnedConversationQuery(tweet.id, username);
|
|
114
|
+
if (!query) continue;
|
|
115
|
+
queryLog.push(query);
|
|
116
|
+
const replies = normalizeArray(await searchTweets(query, { maxResults: 10 }));
|
|
117
|
+
for (const reply of replies) {
|
|
118
|
+
if (!reply || seenTweetIds.has(reply.id)) continue;
|
|
119
|
+
seenTweetIds.add(reply.id);
|
|
120
|
+
aggregate.push(reply);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (aggregate.length > 0) {
|
|
125
|
+
return {
|
|
126
|
+
tweets: aggregate,
|
|
127
|
+
ownTweets: ownedCandidates.map((tweet) => ({ id: tweet.id, text: tweet.text || '' })),
|
|
128
|
+
queryLog,
|
|
129
|
+
searchMode: 'owned_conversations',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fallbackQuery = 'thumbgate OR ThumbGate OR "pre-action gates"';
|
|
134
|
+
queryLog.push(fallbackQuery);
|
|
135
|
+
const fallbackTweets = normalizeArray(await searchTweets(fallbackQuery, { maxResults: 10 }));
|
|
136
|
+
return {
|
|
137
|
+
tweets: fallbackTweets,
|
|
138
|
+
ownTweets: ownedCandidates.map((tweet) => ({ id: tweet.id, text: tweet.text || '' })),
|
|
139
|
+
queryLog,
|
|
140
|
+
searchMode: 'keyword_fallback',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
61
144
|
// ---------------------------------------------------------------------------
|
|
62
145
|
// Bot/hostile detection — skip comments that are calling us out
|
|
63
146
|
// ---------------------------------------------------------------------------
|
|
@@ -97,7 +180,8 @@ async function generateReply(comment, context) {
|
|
|
97
180
|
|
|
98
181
|
// NEVER reply with generic fluff — build reply from what they ACTUALLY said
|
|
99
182
|
const isQuestion = context.isQuestion || /\?/.test(comment);
|
|
100
|
-
const
|
|
183
|
+
const isReddit = context.platform === 'reddit';
|
|
184
|
+
const wantsProductDetails = commentExplicitlyRequestsProduct(comment);
|
|
101
185
|
|
|
102
186
|
// Extract the specific topic they're asking about
|
|
103
187
|
const mentionsSetup = /install|setup|config|init|npx|how.+start/i.test(lc);
|
|
@@ -108,33 +192,74 @@ async function generateReply(comment, context) {
|
|
|
108
192
|
const mentionsScaling = /scale|team|multi.?repo|collaborate|share/i.test(lc);
|
|
109
193
|
const mentionsSkeptical = /why not|already exist|what.+different|vs |compared to/i.test(lc);
|
|
110
194
|
const mentionsThanks = /thanks|thank you|cool|nice|interesting|awesome/i.test(lc);
|
|
195
|
+
const mentionsSkillsProcess = /skill|template|process|workflow|review|sprint|implement|phase/i.test(lc);
|
|
196
|
+
const mentionsConflictingDocs = /context doc|context docs|conflicting|inconsisten|claude\.md|cursorrules|instruction/i.test(lc);
|
|
111
197
|
|
|
112
198
|
// Build response that addresses THEIR specific point
|
|
113
|
-
if (
|
|
199
|
+
if (mentionsSkillsProcess || mentionsConflictingDocs) {
|
|
200
|
+
const reply = [
|
|
201
|
+
'That matches what I have seen too.',
|
|
202
|
+
'Smaller review/implement phases hold up much better than one giant instruction blob,',
|
|
203
|
+
'and conflicting context docs are where things usually start drifting.'
|
|
204
|
+
].join(' ');
|
|
205
|
+
const gate = gateContextualReply(comment, reply, context);
|
|
206
|
+
return gate.allowed ? reply : null;
|
|
207
|
+
}
|
|
208
|
+
if (mentionsSetup && isQuestion && !isReddit) {
|
|
114
209
|
return `\`npx thumbgate init\` auto-detects your agent and wires the hooks. Takes about 30 seconds. What agent are you using?`;
|
|
115
210
|
}
|
|
116
211
|
if (mentionsSkeptical) {
|
|
117
|
-
|
|
212
|
+
// Build a reply that mirrors the commenter's frame (memory, context docs, or general rules)
|
|
213
|
+
// so gateContextualReply's topic-overlap check passes.
|
|
214
|
+
let replyBase;
|
|
215
|
+
if (mentionsMemory) {
|
|
216
|
+
replyBase = 'The distinction from memory tools is enforcement: memory helps the agent remember a past mistake, but it can still repeat it. The gate stops the already-rejected move before it runs. Whether that extra step is worth the setup depends on how often your agent ignores its own memory.';
|
|
217
|
+
} else {
|
|
218
|
+
replyBase = 'The difference from cursorrules or instruction files is enforcement: the bad action gets stopped before execution instead of being added to context docs and then ignored anyway. Whether that tradeoff is worth it depends on how often your agent repeats the same mistake.';
|
|
219
|
+
}
|
|
220
|
+
const gate = gateContextualReply(comment, replyBase, context);
|
|
221
|
+
return gate.allowed ? replyBase : null;
|
|
118
222
|
}
|
|
119
|
-
if (mentionsHow &&
|
|
120
|
-
|
|
223
|
+
if (mentionsGates && (mentionsHow || (!isReddit && !mentionsThanks))) {
|
|
224
|
+
// On X, engage on gate-topic statements too (not just "how does" questions).
|
|
225
|
+
// On Reddit, keep the old conservative behavior (questions only via mentionsHow).
|
|
226
|
+
if (isReddit && !mentionsHow) return null;
|
|
227
|
+
const reply = isReddit
|
|
228
|
+
? 'The short version is: the tool call gets checked before it runs. If it matches a previously rejected pattern, it is blocked and the agent has to try a different path.'
|
|
229
|
+
: 'PreToolUse hooks intercept the tool call before it runs. Each call is checked against prevention rules promoted from past failures. If it matches, the action is blocked and the agent has to try a different approach. The rules adapt over time so false positives decrease.';
|
|
230
|
+
const gate = gateContextualReply(comment, reply, context);
|
|
231
|
+
return gate.allowed ? reply : null;
|
|
121
232
|
}
|
|
122
233
|
if (mentionsScaling) {
|
|
123
|
-
|
|
234
|
+
if (isReddit && !wantsProductDetails) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
const reply = 'For teams, the useful part is shared lessons instead of each developer relearning the same failure pattern alone. Solo workflows usually benefit first from the local version.';
|
|
238
|
+
const gate = gateContextualReply(comment, reply, context);
|
|
239
|
+
return gate.allowed ? reply : null;
|
|
124
240
|
}
|
|
125
|
-
if (mentionsMemory
|
|
126
|
-
|
|
241
|
+
if (mentionsMemory) {
|
|
242
|
+
// Engage on memory/context topics whether it's a question or a statement — both are worth a reply on X
|
|
243
|
+
if (isReddit && !isQuestion) return null; // Reddit: only reply to direct questions
|
|
244
|
+
const reply = 'The useful distinction is memory versus enforcement. Memory helps the agent remember, but it can still ignore that memory. Enforcement is what stops the already-rejected move from happening again.';
|
|
245
|
+
const gate = gateContextualReply(comment, reply, context);
|
|
246
|
+
return gate.allowed ? reply : null;
|
|
127
247
|
}
|
|
128
|
-
if (mentionsCursor && isQuestion) {
|
|
129
|
-
return
|
|
248
|
+
if (mentionsCursor && isQuestion && !isReddit) {
|
|
249
|
+
return 'Works with Cursor via MCP. The same prevention rules can apply across Cursor, Claude Code, and Codex. What specific failure patterns are you hitting?';
|
|
130
250
|
}
|
|
131
251
|
if (mentionsThanks && !isQuestion) {
|
|
132
252
|
// Don't reply to simple "thanks" — it looks desperate
|
|
133
253
|
return null;
|
|
134
254
|
}
|
|
255
|
+
if (isReddit && wantsProductDetails) {
|
|
256
|
+
const reply = 'Happy to share the repo or setup details if that would help. The main thing that worked for me was keeping accepted and rejected patterns outside the session so the next run starts with the same constraints.';
|
|
257
|
+
const gate = gateContextualReply(comment, reply, context);
|
|
258
|
+
return gate.allowed ? reply : null;
|
|
259
|
+
}
|
|
135
260
|
if (isQuestion) {
|
|
136
|
-
// They asked something specific we didn't match —
|
|
137
|
-
return
|
|
261
|
+
// They asked something specific we didn't match — signal to caller to save a draft for human review
|
|
262
|
+
return '__DRAFT__';
|
|
138
263
|
}
|
|
139
264
|
// Not a question, not hostile, not thanks — probably a statement. Don't reply.
|
|
140
265
|
return null;
|
|
@@ -214,7 +339,7 @@ async function checkRedditReplies(state, dryRun) {
|
|
|
214
339
|
isQuestion,
|
|
215
340
|
});
|
|
216
341
|
|
|
217
|
-
if (!generatedReply) {
|
|
342
|
+
if (!generatedReply || generatedReply === '__DRAFT__') {
|
|
218
343
|
console.warn(`[reply-monitor] Could not generate reply for ${commentId}`);
|
|
219
344
|
continue;
|
|
220
345
|
}
|
|
@@ -260,29 +385,41 @@ async function checkXReplies(state, dryRun) {
|
|
|
260
385
|
|
|
261
386
|
// Use the post-to-x module for OAuth signing
|
|
262
387
|
let xModule;
|
|
388
|
+
let xPoller;
|
|
263
389
|
try {
|
|
264
390
|
xModule = require('./post-to-x.js');
|
|
391
|
+
xPoller = require('./social-analytics/pollers/x.js');
|
|
265
392
|
} catch {
|
|
266
|
-
console.warn('[reply-monitor] Could not load
|
|
393
|
+
console.warn('[reply-monitor] Could not load X modules, skipping X');
|
|
267
394
|
return [];
|
|
268
395
|
}
|
|
269
396
|
|
|
270
|
-
|
|
271
|
-
let mentions;
|
|
397
|
+
let searchResult;
|
|
272
398
|
try {
|
|
273
|
-
|
|
399
|
+
searchResult = await collectXSearchCandidates({
|
|
400
|
+
searchTweets: xModule.searchTweets,
|
|
401
|
+
fetchOwnedTweets: process.env.X_BEARER_TOKEN && process.env.X_USER_ID
|
|
402
|
+
? async () => {
|
|
403
|
+
const response = await xPoller.fetchUserTweets(process.env.X_BEARER_TOKEN, process.env.X_USER_ID);
|
|
404
|
+
return response && response.data;
|
|
405
|
+
}
|
|
406
|
+
: null,
|
|
407
|
+
ownUserId: process.env.X_USER_ID || '1733256637199073280',
|
|
408
|
+
username: process.env.X_USERNAME || DEFAULT_X_HANDLE,
|
|
409
|
+
});
|
|
274
410
|
} catch (err) {
|
|
275
411
|
console.warn(`[reply-monitor] X search failed: ${err.message}`);
|
|
276
412
|
return [];
|
|
277
413
|
}
|
|
278
414
|
|
|
279
|
-
|
|
280
|
-
const mentionsList = Array.isArray(mentions) ? mentions : mentions?.data;
|
|
415
|
+
const mentionsList = normalizeArray(searchResult && searchResult.tweets);
|
|
281
416
|
if (!mentionsList || mentionsList.length === 0) {
|
|
282
417
|
console.log('[reply-monitor] No X mentions found');
|
|
283
418
|
return [];
|
|
284
419
|
}
|
|
285
420
|
|
|
421
|
+
console.log(`[reply-monitor] X candidate search mode: ${searchResult.searchMode}`);
|
|
422
|
+
|
|
286
423
|
const results = [];
|
|
287
424
|
const repliesSentThisRun = new Set(); // Track reply text to prevent duplicates
|
|
288
425
|
|
|
@@ -314,6 +451,25 @@ async function checkXReplies(state, dryRun) {
|
|
|
314
451
|
continue;
|
|
315
452
|
}
|
|
316
453
|
|
|
454
|
+
// Unmatched question — save a draft for human review rather than silently skipping
|
|
455
|
+
if (generatedReply === '__DRAFT__') {
|
|
456
|
+
const draft = {
|
|
457
|
+
platform: 'x',
|
|
458
|
+
tweetId,
|
|
459
|
+
author: tweet.author_id,
|
|
460
|
+
theirTweet: tweet.text.slice(0, 500),
|
|
461
|
+
suggestedReply: null,
|
|
462
|
+
draftedAt: new Date().toISOString(),
|
|
463
|
+
status: 'needs_human_reply',
|
|
464
|
+
reason: 'unmatched_question',
|
|
465
|
+
};
|
|
466
|
+
saveDraft(draft);
|
|
467
|
+
state.repliedTo[`x_${tweetId}`] = { at: new Date().toISOString(), platform: 'x', drafted: true, skipped: 'needs_human_reply' };
|
|
468
|
+
results.push({ tweetId, reply: null, posted: false, drafted: true });
|
|
469
|
+
console.log(`[reply-monitor] 📝 DRAFTED (needs human reply) for tweet ${tweetId} — saved to .thumbgate/reply-drafts.jsonl`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
317
473
|
// Truncate to 280 chars for Twitter
|
|
318
474
|
const truncated = generatedReply.slice(0, 275) + (generatedReply.length > 275 ? '...' : '');
|
|
319
475
|
|
|
@@ -400,7 +556,14 @@ async function monitor({ platforms, dryRun } = {}) {
|
|
|
400
556
|
return allResults;
|
|
401
557
|
}
|
|
402
558
|
|
|
403
|
-
module.exports = {
|
|
559
|
+
module.exports = {
|
|
560
|
+
buildOwnedConversationQuery,
|
|
561
|
+
checkXReplies,
|
|
562
|
+
collectXSearchCandidates,
|
|
563
|
+
generateReply,
|
|
564
|
+
isRevenueRelevantXTweet,
|
|
565
|
+
monitor,
|
|
566
|
+
};
|
|
404
567
|
|
|
405
568
|
// ---------------------------------------------------------------------------
|
|
406
569
|
// CLI
|
|
@@ -418,22 +581,6 @@ if (require.main === module) {
|
|
|
418
581
|
const platformArg = getArg('--platform');
|
|
419
582
|
const platforms = platformArg ? [platformArg] : null;
|
|
420
583
|
|
|
421
|
-
// Load .env
|
|
422
|
-
const envPath = path.resolve(__dirname, '..', '.env');
|
|
423
|
-
if (fs.existsSync(envPath)) {
|
|
424
|
-
const envContent = fs.readFileSync(envPath, 'utf8');
|
|
425
|
-
for (const line of envContent.split('\n')) {
|
|
426
|
-
const trimmed = line.trim();
|
|
427
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
428
|
-
const eqIdx = trimmed.indexOf('=');
|
|
429
|
-
if (eqIdx > 0) {
|
|
430
|
-
const key = trimmed.slice(0, eqIdx);
|
|
431
|
-
const value = trimmed.slice(eqIdx + 1);
|
|
432
|
-
if (!process.env[key]) process.env[key] = value;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
584
|
monitor({ platforms, dryRun })
|
|
438
585
|
.then((results) => {
|
|
439
586
|
console.log('\n[reply-monitor] Summary:', JSON.stringify(results, null, 2));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
6
|
+
|
|
7
|
+
function unique(values = []) {
|
|
8
|
+
return [...new Set(values.filter(Boolean).map((value) => path.resolve(value)))];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getStatuslineCacheCandidates(options = {}) {
|
|
12
|
+
const cwd = options.cwd || process.cwd();
|
|
13
|
+
const home = options.home || process.env.HOME || process.env.USERPROFILE || '';
|
|
14
|
+
const feedbackDir = resolveFeedbackDir({ cwd, env: options.env || process.env });
|
|
15
|
+
|
|
16
|
+
return unique([
|
|
17
|
+
path.join(feedbackDir, 'statusline_cache.json'),
|
|
18
|
+
path.join(cwd, '.thumbgate', 'statusline_cache.json'),
|
|
19
|
+
home ? path.join(home, '.thumbgate', 'statusline_cache.json') : null,
|
|
20
|
+
]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (require.main === module) {
|
|
24
|
+
process.stdout.write(JSON.stringify({ candidates: getStatuslineCacheCandidates() }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { getStatuslineCacheCandidates };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { analyzeFeedback } = require('./feedback-loop');
|
|
5
|
+
const { normalizeStatsPayload } = require('./hook-thumbgate-cache-updater');
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const stats = analyzeFeedback();
|
|
9
|
+
const payload = {
|
|
10
|
+
...normalizeStatsPayload(stats),
|
|
11
|
+
updated_at: String(Math.floor(Date.now() / 1000)),
|
|
12
|
+
};
|
|
13
|
+
process.stdout.write(JSON.stringify(payload));
|
|
14
|
+
} catch (_) {
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { isProLicensed } = require('./license');
|
|
6
|
+
|
|
7
|
+
function getStatuslineMeta(options = {}) {
|
|
8
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
9
|
+
const env = options.env || process.env;
|
|
10
|
+
const homeDir = options.homeDir || env.HOME || env.USERPROFILE || '.';
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
version: String(pkg.version || '').trim() || 'unknown',
|
|
14
|
+
tier: isProLicensed({ homeDir }) ? 'Pro' : 'Free',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (require.main === module) {
|
|
19
|
+
process.stdout.write(JSON.stringify(getStatuslineMeta()));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { getStatuslineMeta };
|
package/scripts/statusline.sh
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# ThumbGate Status Line for Claude Code
|
|
3
|
-
# Shows ThumbGate feedback stats +
|
|
4
|
-
# Thumbs icons trigger CLI feedback capture inline (no browser).
|
|
3
|
+
# Shows ThumbGate feedback stats + package version/tier at a glance.
|
|
5
4
|
# Installed by: npx thumbgate init --agent claude-code
|
|
6
5
|
|
|
7
6
|
# Resolve script directory safely (CodeQL: no uncontrolled paths)
|
|
@@ -17,16 +16,21 @@ CTX_PCT="${CTX_PCT:-0}"
|
|
|
17
16
|
|
|
18
17
|
# ── ThumbGate stats from cache ────────────────────────────────────────
|
|
19
18
|
THUMBGATE_CACHE=""
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
_CACHE_CANDIDATES_JSON=$(node "${SCRIPT_DIR}/statusline-cache-path.js" 2>/dev/null)
|
|
20
|
+
if [ -n "$_CACHE_CANDIDATES_JSON" ]; then
|
|
21
|
+
while IFS= read -r candidate; do
|
|
22
|
+
[ -z "$candidate" ] && continue
|
|
23
|
+
if [ -f "$candidate" ]; then
|
|
24
|
+
THUMBGATE_CACHE="$candidate"
|
|
25
|
+
break
|
|
25
26
|
fi
|
|
26
|
-
done
|
|
27
|
-
|
|
27
|
+
done < <(echo "$_CACHE_CANDIDATES_JSON" | jq -r '.candidates[]?' 2>/dev/null)
|
|
28
|
+
fi
|
|
29
|
+
if [ -z "$THUMBGATE_CACHE" ]; then
|
|
30
|
+
THUMBGATE_CACHE="$(echo "$_CACHE_CANDIDATES_JSON" | jq -r '.candidates[0] // empty' 2>/dev/null)"
|
|
31
|
+
fi
|
|
28
32
|
if [ -z "$THUMBGATE_CACHE" ]; then
|
|
29
|
-
THUMBGATE_CACHE="${THUMBGATE_FEEDBACK_DIR:-.}
|
|
33
|
+
THUMBGATE_CACHE="${THUMBGATE_FEEDBACK_DIR:-.}/statusline_cache.json"
|
|
30
34
|
fi
|
|
31
35
|
|
|
32
36
|
UP="0"; DOWN="0"; LESSONS="0"; TREND="?"; CACHE_TS="0"
|
|
@@ -40,8 +44,23 @@ if [ -f "$THUMBGATE_CACHE" ]; then
|
|
|
40
44
|
' "$THUMBGATE_CACHE" 2>/dev/null)"
|
|
41
45
|
fi
|
|
42
46
|
|
|
43
|
-
# Background refresh from REST API when cache is stale (>120s)
|
|
44
47
|
_NOW=$(date +%s)
|
|
48
|
+
if [ "$UP" = "0" ] && [ "$DOWN" = "0" ] || [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
|
|
49
|
+
_LOCAL_STATS_JSON=$(node "${SCRIPT_DIR}/statusline-local-stats.js" 2>/dev/null)
|
|
50
|
+
if [ -n "$_LOCAL_STATS_JSON" ]; then
|
|
51
|
+
mkdir -p "$(dirname "$THUMBGATE_CACHE")"
|
|
52
|
+
printf '%s' "$_LOCAL_STATS_JSON" > "$THUMBGATE_CACHE"
|
|
53
|
+
eval "$(echo "$_LOCAL_STATS_JSON" | jq -r '
|
|
54
|
+
@sh "UP=\(.thumbs_up // "0")",
|
|
55
|
+
@sh "DOWN=\(.thumbs_down // "0")",
|
|
56
|
+
@sh "LESSONS=\(.lessons // "0")",
|
|
57
|
+
@sh "TREND=\(.trend // "?")",
|
|
58
|
+
@sh "CACHE_TS=\(.updated_at // "0")"
|
|
59
|
+
' 2>/dev/null)"
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# Background refresh from REST API when cache is stale (>120s)
|
|
45
64
|
if [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
|
|
46
65
|
(
|
|
47
66
|
_R=$(curl -s --max-time 3 "http://localhost:3456/v1/feedback/stats" -H "Authorization: Bearer ${THUMBGATE_API_KEY:-tg_creator_dev_enterprise}" 2>/dev/null)
|
|
@@ -59,13 +78,13 @@ except:pass
|
|
|
59
78
|
disown 2>/dev/null
|
|
60
79
|
fi
|
|
61
80
|
|
|
62
|
-
# ──
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if [ -n "$
|
|
66
|
-
eval "$(echo "$
|
|
67
|
-
@sh "
|
|
68
|
-
@sh "
|
|
81
|
+
# ── ThumbGate package metadata ────────────────────────────────────────
|
|
82
|
+
TG_VERSION="unknown"; TG_TIER="Free"
|
|
83
|
+
_META_JSON=$(node "${SCRIPT_DIR}/statusline-meta.js" 2>/dev/null)
|
|
84
|
+
if [ -n "$_META_JSON" ]; then
|
|
85
|
+
eval "$(echo "$_META_JSON" | jq -r '
|
|
86
|
+
@sh "TG_VERSION=\(.version // "unknown")",
|
|
87
|
+
@sh "TG_TIER=\(.tier // "Free")"
|
|
69
88
|
' 2>/dev/null)"
|
|
70
89
|
fi
|
|
71
90
|
|
|
@@ -88,29 +107,17 @@ case "${TREND}" in
|
|
|
88
107
|
improving) ARROW="↗" ;; degrading) ARROW="↘" ;; stable) ARROW="→" ;; *) ARROW="?" ;;
|
|
89
108
|
esac
|
|
90
109
|
|
|
91
|
-
# ── OSC 8 clickable links ────────────────────────────────────────
|
|
92
|
-
# Links use CLI commands instead of browser URLs.
|
|
93
|
-
# Clicking 👍 runs: node bin/cli.js feedback --signal=up
|
|
94
|
-
# Clicking 👎 runs: node bin/cli.js feedback --signal=down
|
|
95
|
-
osc_link() { printf '\033]8;;%s\a%s\033]8;;\a' "$1" "$2"; }
|
|
96
|
-
CLI="node ${SCRIPT_DIR}/../bin/cli.js"
|
|
97
|
-
|
|
98
110
|
# ── Output (single line) ─────────────────────────────────────────
|
|
111
|
+
LINE="ThumbGate v${TG_VERSION} · ${TG_TIER}"
|
|
99
112
|
if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
|
|
100
|
-
echo -e "${D}
|
|
113
|
+
echo -e "${D}${LINE} · no feedback yet${RST}"
|
|
101
114
|
else
|
|
102
|
-
|
|
103
|
-
LINE="ThumbGate: ${G}${BD}${UP}${RST}👍 ${R}${BD}${DOWN}${RST}👎 · ${M}${BD}${LESSONS}${RST} lessons ${ARROW}"
|
|
115
|
+
LINE="${LINE} · ${G}${BD}${UP}${RST}👍 ${R}${BD}${DOWN}${RST}👎 ${ARROW}"
|
|
104
116
|
|
|
105
117
|
# Control Tower alerts (if any)
|
|
106
118
|
[ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
|
|
107
119
|
[ "${AT_RISK:-0}" -gt 0 ] && LINE="${LINE} ${R}${AT_RISK}⚠${RST}"
|
|
108
120
|
[ "${ANOMALIES:-0}" -gt 0 ] && LINE="${LINE} ${R}${ANOMALIES}☠${RST}"
|
|
109
121
|
|
|
110
|
-
# Most recent lesson
|
|
111
|
-
if [ -n "$LESSON_TEXT" ]; then
|
|
112
|
-
LINE="${LINE} · ${C}${LESSON_TEXT}${RST}"
|
|
113
|
-
fi
|
|
114
|
-
|
|
115
122
|
echo -e "$LINE"
|
|
116
123
|
fi
|