thumbgate 0.9.11 → 0.9.13

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.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/adapters/README.md +1 -1
  5. package/adapters/claude/.mcp.json +2 -2
  6. package/adapters/codex/config.toml +2 -2
  7. package/adapters/mcp/server-stdio.js +1 -1
  8. package/adapters/opencode/opencode.json +1 -1
  9. package/package.json +2 -2
  10. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  11. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  12. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  13. package/plugins/codex-profile/.mcp.json +1 -1
  14. package/plugins/codex-profile/INSTALL.md +1 -1
  15. package/plugins/codex-profile/README.md +1 -1
  16. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  17. package/plugins/opencode-profile/INSTALL.md +1 -1
  18. package/public/index.html +1 -1
  19. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  20. package/scripts/hook-runtime.js +4 -12
  21. package/scripts/install-mcp.js +0 -3
  22. package/scripts/mcp-config.js +11 -18
  23. package/scripts/post-everywhere.js +29 -1
  24. package/scripts/published-cli.js +30 -3
  25. package/scripts/social-analytics/poll-all.js +20 -5
  26. package/scripts/social-analytics/pollers/plausible.js +2 -4
  27. package/scripts/social-analytics/publish-thumbgate-launch.js +6 -0
  28. package/scripts/social-analytics/publishers/zernio.js +95 -4
  29. package/scripts/social-analytics/schedule-thumbgate-campaign.js +1 -1
  30. package/scripts/social-reply-monitor.js +39 -8
  31. package/scripts/statusline-local-stats.js +16 -0
  32. package/scripts/statusline.sh +16 -1
  33. package/scripts/sync-version.js +13 -0
  34. package/scripts/test-coverage.js +1 -0
  35. package/src/api/server.js +11 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "plugins": [
5
5
  {
6
6
  "name": "thumbgate",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
3
  "description": "Pre-action gates that block AI coding agents from repeating known mistakes. Captures feedback, auto-promotes failures into prevention rules, and enforces them via PreToolUse hooks.",
4
- "version": "0.9.11",
4
+ "version": "0.9.13",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "ThumbGate — 👍👎 feedback that teaches your AI agent. Thumbs down a mistake, it never happens again.",
5
5
  "homepage": "https://github.com/IgorGanapolsky/thumbgate",
6
6
  "transport": "stdio",
@@ -3,7 +3,7 @@
3
3
  - `chatgpt/openapi.yaml`: import into GPT Actions.
4
4
  - `gemini/function-declarations.json`: Gemini function-calling definitions.
5
5
  - `mcp/server-stdio.js`: underlying local MCP stdio server implementation.
6
- - `claude/.mcp.json`: example Claude Code MCP config using `npx --yes --package thumbgate@0.9.11 thumbgate serve`.
6
+ - `claude/.mcp.json`: example Claude Code MCP config using `npx --yes --package thumbgate@0.9.13 thumbgate serve`.
7
7
  - `codex/config.toml`: example Codex MCP profile section using the same version-pinned portable launcher.
8
8
  - `amp/skills/thumbgate-feedback/SKILL.md`: Amp skill template.
9
9
  - `opencode/opencode.json`: portable OpenCode MCP profile using the same version-pinned portable launcher.
@@ -2,13 +2,13 @@
2
2
  "mcpServers": {
3
3
  "thumbgate": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "serve"]
5
+ "args": ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "serve"]
6
6
  }
7
7
  },
8
8
  "hooks": {
9
9
  "preToolUse": {
10
10
  "command": "npx",
11
- "args": ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "gate-check"]
11
+ "args": ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "gate-check"]
12
12
  }
13
13
  }
14
14
  }
@@ -1,9 +1,9 @@
1
1
  # Codex MCP profile (copy into ~/.codex/config.toml or merge section)
2
2
  [mcp_servers.thumbgate]
3
3
  command = "npx"
4
- args = ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "serve"]
4
+ args = ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "serve"]
5
5
 
6
6
  # Hard PreToolUse hook for Codex
7
7
  [hooks.pre_tool_use]
8
8
  command = "npx"
9
- args = ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "gate-check"]
9
+ args = ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "gate-check"]
@@ -111,7 +111,7 @@ const {
111
111
  finalizeSession: finalizeFeedbackSession,
112
112
  } = require('../../scripts/feedback-session');
113
113
 
114
- const SERVER_INFO = { name: 'thumbgate-mcp', version: '0.9.11' };
114
+ const SERVER_INFO = { name: 'thumbgate-mcp', version: '0.9.13' };
115
115
  const COMMERCE_CATEGORIES = [
116
116
  'product_recommendation',
117
117
  'brand_compliance',
@@ -7,7 +7,7 @@
7
7
  "npx",
8
8
  "--yes",
9
9
  "--package",
10
- "thumbgate@0.9.11",
10
+ "thumbgate@0.9.13",
11
11
  "thumbgate",
12
12
  "serve"
13
13
  ],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thumbgate",
3
- "version": "0.9.11",
4
- "description": "ThumbGate \u2014 Make your AI coding agent self-improving. Every mistake becomes a prevention rule that physically blocks the agent from repeating it. Feedback-driven enforcement via PreToolUse hooks, Thompson Sampling for adaptive gates, SQLite+FTS5 lesson DB, and LanceDB vector search. Your agent gets smarter with every session.",
3
+ "version": "0.9.13",
4
+ "description": "ThumbGate Make your AI coding agent self-improving. Every mistake becomes a prevention rule that physically blocks the agent from repeating it. Feedback-driven enforcement via PreToolUse hooks, Thompson Sampling for adaptive gates, SQLite+FTS5 lesson DB, and LanceDB vector search. Your agent gets smarter with every session.",
5
5
  "homepage": "https://thumbgate-production.up.railway.app",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-bridge",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "Run Codex review, adversarial review, and second-pass handoffs from Claude Code while keeping ThumbGate reliability memory in the loop.",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "--yes",
7
7
  "--package",
8
- "thumbgate@0.9.11",
8
+ "thumbgate@0.9.13",
9
9
  "thumbgate",
10
10
  "serve"
11
11
  ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-profile",
3
- "version": "0.9.11",
3
+ "version": "0.9.13",
4
4
  "description": "ThumbGate for Codex: pre-action gates, skill packs, hallucination detection, PII scanning, progressive disclosure (82% token savings), and MCP-backed reliability memory.",
5
5
  "author": {
6
6
  "name": "Igor Ganapolsky",
@@ -5,7 +5,7 @@
5
5
  "args": [
6
6
  "--yes",
7
7
  "--package",
8
- "thumbgate@0.9.11",
8
+ "thumbgate@0.9.13",
9
9
  "thumbgate",
10
10
  "serve"
11
11
  ]
@@ -31,7 +31,7 @@ The following block is appended to `~/.codex/config.toml`:
31
31
  ```toml
32
32
  [mcp_servers.thumbgate]
33
33
  command = "npx"
34
- args = ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "serve"]
34
+ args = ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "serve"]
35
35
  ```
36
36
 
37
37
  The repo-local Codex app plugin ships the same runtime path through `plugins/codex-profile/.mcp.json`, so the manual config and plugin metadata stay aligned.
@@ -29,7 +29,7 @@ That profile launches:
29
29
  ```toml
30
30
  [mcp_servers.thumbgate]
31
31
  command = "npx"
32
- args = ["--yes", "--package", "thumbgate@0.9.11", "thumbgate", "serve"]
32
+ args = ["--yes", "--package", "thumbgate@0.9.13", "thumbgate", "serve"]
33
33
  ```
34
34
 
35
35
  ## Why this exists
@@ -2,7 +2,7 @@
2
2
  "name": "thumbgate",
3
3
  "displayName": "ThumbGate",
4
4
  "description": "👍👎 Thumbs down a mistake — your AI agent won't repeat it. Thumbs up good work — it remembers the pattern.",
5
- "version": "0.9.11",
5
+ "version": "0.9.13",
6
6
  "author": {
7
7
  "name": "Igor Ganapolsky"
8
8
  },
@@ -25,7 +25,7 @@ The portable profile adds this MCP server entry:
25
25
  "mcp": {
26
26
  "thumbgate": {
27
27
  "type": "local",
28
- "command": ["npx", "--yes", "--package", "thumbgate@0.9.11", "thumbgate", "serve"],
28
+ "command": ["npx", "--yes", "--package", "thumbgate@0.9.13", "thumbgate", "serve"],
29
29
  "enabled": true
30
30
  }
31
31
  }
package/public/index.html CHANGED
@@ -554,7 +554,7 @@ __GA_BOOTSTRAP__
554
554
  <!-- HOW IT WORKS -->
555
555
  <section class="how-it-works" id="how-it-works">
556
556
  <div class="container">
557
- <div class="section-label">New in v0.9.11</div>
557
+ <div class="section-label">New in v0.9.13</div>
558
558
  <h2 class="section-title">Three steps to stop repeated AI failures</h2>
559
559
  <div class="steps">
560
560
  <div class="step">
@@ -6,7 +6,7 @@ const {
6
6
  isSourceCheckout,
7
7
  publishedCliAvailable,
8
8
  } = require('./mcp-config');
9
- const { runPublishedCliHelp, publishedCliArgs } = require('./published-cli');
9
+ const { publishedCliShellCommand } = require('./published-cli');
10
10
 
11
11
  const PKG_ROOT = path.join(__dirname, '..');
12
12
  const featureSupportCache = new Map();
@@ -28,15 +28,7 @@ function publishedHookCommandsAvailable(version) {
28
28
  return featureSupportCache.get(version);
29
29
  }
30
30
 
31
- let available = false;
32
- try {
33
- const helpText = runPublishedCliHelp(version, { timeout: 8000 });
34
- available = ['gate-check', 'cache-update', 'statusline-render', 'hook-auto-capture', 'session-start']
35
- .every((command) => helpText.includes(command));
36
- } catch {
37
- available = false;
38
- }
39
-
31
+ const available = true;
40
32
  featureSupportCache.set(version, available);
41
33
  return available;
42
34
  }
@@ -44,12 +36,12 @@ function publishedHookCommandsAvailable(version) {
44
36
  function resolveCliBaseCommand() {
45
37
  const version = packageVersion();
46
38
  if (publishedHookCommandsAvailable(version)) {
47
- return `npx ${publishedCliArgs(version).map(shellQuote).join(' ')}`;
39
+ return publishedCliShellCommand(version);
48
40
  }
49
41
  if (isSourceCheckout(PKG_ROOT)) {
50
42
  return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))}`;
51
43
  }
52
- return `npx -y thumbgate@${version}`;
44
+ return publishedCliShellCommand(version);
53
45
  }
54
46
 
55
47
  function buildPortableHookCommand(subcommand) {
@@ -30,8 +30,6 @@ function resolveMcpServerConfig(flags = {}) {
30
30
  });
31
31
  }
32
32
 
33
- const MCP_SERVER_CONFIG = resolveMcpServerConfig();
34
-
35
33
  function parseFlags(argv) {
36
34
  const flags = {};
37
35
  for (const arg of argv) {
@@ -155,7 +153,6 @@ function installMcp(flags) {
155
153
  module.exports = {
156
154
  MCP_SERVER_KEY,
157
155
  LEGACY_MCP_SERVER_KEYS,
158
- MCP_SERVER_CONFIG,
159
156
  resolveMcpServerConfig,
160
157
  resolveSettingsPath,
161
158
  loadSettings,
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execFileSync } = require('child_process');
6
- const { publishedCliArgs, runPublishedCliHelp } = require('./published-cli');
6
+ const { publishedCliShellCommand } = require('./published-cli');
7
7
  const DEFAULT_PKG_ROOT = path.join(__dirname, '..');
8
8
  const cliAvailabilityCache = new Map();
9
9
 
@@ -101,8 +101,8 @@ function resolveLocalServerPath(pkgRoot, scope = 'project') {
101
101
 
102
102
  function portableMcpEntry(pkgVersion) {
103
103
  return {
104
- command: 'npx',
105
- args: publishedCliArgs(pkgVersion, ['serve']),
104
+ command: 'sh',
105
+ args: ['-lc', publishedCliShellCommand(pkgVersion, ['serve'])],
106
106
  };
107
107
  }
108
108
 
@@ -163,33 +163,26 @@ function publishedCliOverride() {
163
163
  }
164
164
 
165
165
  function publishedCliAvailable(pkgVersion) {
166
- if (!isVersionPublished(pkgVersion)) {
167
- return false;
168
- }
169
166
  const override = publishedCliOverride();
170
167
  if (override !== null) {
171
168
  return override;
172
169
  }
173
- if (cliAvailabilityCache.has(pkgVersion)) {
174
- return cliAvailabilityCache.get(pkgVersion);
170
+ if (!isVersionPublished(pkgVersion)) {
171
+ return false;
175
172
  }
176
-
177
- let available = false;
178
- try {
179
- runPublishedCliHelp(pkgVersion, { timeout: 8000 });
180
- available = true;
181
- } catch (_) {
182
- available = false;
173
+ if (!cliAvailabilityCache.has(pkgVersion)) {
174
+ cliAvailabilityCache.set(pkgVersion, true);
183
175
  }
184
-
185
- cliAvailabilityCache.set(pkgVersion, available);
186
- return available;
176
+ return cliAvailabilityCache.get(pkgVersion);
187
177
  }
188
178
 
189
179
  function resolveMcpEntry({ pkgRoot, pkgVersion, scope = 'project', targetDir = pkgRoot }) {
190
180
  if (!isSourceCheckout(pkgRoot)) {
191
181
  return portableMcpEntry(pkgVersion);
192
182
  }
183
+ if (scope === 'home' && publishedCliAvailable(pkgVersion)) {
184
+ return portableMcpEntry(pkgVersion);
185
+ }
193
186
  if (scope === 'project' && !isSameCheckoutFamily(pkgRoot, targetDir) && publishedCliAvailable(pkgVersion)) {
194
187
  return portableMcpEntry(pkgVersion);
195
188
  }
@@ -183,6 +183,32 @@ async function postToDevTo(parsed, dryRun) {
183
183
  return devto.publishArticle({ title, body_markdown: body, tags: parsed.tags });
184
184
  }
185
185
 
186
+ async function postToTikTok(parsed, dryRun) {
187
+ const text = parsed.body || '';
188
+ if (!text) throw new Error('TikTok post requires body');
189
+
190
+ if (dryRun) {
191
+ console.log(`[dry-run] TikTok: "${text.slice(0, 100)}..." (${text.length} chars)`);
192
+ return { dryRun: true };
193
+ }
194
+
195
+ const tiktok = getPublisher('tiktok');
196
+ return tiktok.publishPost({ text });
197
+ }
198
+
199
+ async function postToYouTube(parsed, dryRun) {
200
+ const { title, body } = parsed;
201
+ if (!title || !body) throw new Error('YouTube post requires title and body');
202
+
203
+ if (dryRun) {
204
+ console.log(`[dry-run] YouTube: "${title}" (${body.length} chars)`);
205
+ return { dryRun: true };
206
+ }
207
+
208
+ const youtube = getPublisher('youtube');
209
+ return youtube.publishPost({ title, description: body });
210
+ }
211
+
186
212
  // ---------------------------------------------------------------------------
187
213
  // Main orchestrator
188
214
  // ---------------------------------------------------------------------------
@@ -192,6 +218,8 @@ const DISPATCHERS = {
192
218
  x: postToX,
193
219
  linkedin: postToLinkedIn,
194
220
  devto: postToDevTo,
221
+ tiktok: postToTikTok,
222
+ youtube: postToYouTube,
195
223
  };
196
224
 
197
225
  async function postEverywhere(filePath, { platforms, dryRun } = {}) {
@@ -211,7 +239,7 @@ async function postEverywhere(filePath, { platforms, dryRun } = {}) {
211
239
  // Determine which platforms to post to.
212
240
  // Default excludes devto — high-volume Dev.to posting is counterproductive (0 engagement on 427 posts).
213
241
  // Use --platforms=devto explicitly for monthly cross-posts only.
214
- const DEFAULT_PLATFORMS = ['reddit', 'x', 'linkedin'];
242
+ const DEFAULT_PLATFORMS = ['reddit', 'x', 'linkedin', 'tiktok', 'youtube'];
215
243
  const targetPlatforms = platforms || (parsed.platform ? [parsed.platform] : DEFAULT_PLATFORMS);
216
244
 
217
245
  // Preserve original body/comment so each platform gets a fresh UTM tag
@@ -5,14 +5,39 @@ const os = require('os');
5
5
  const path = require('path');
6
6
  const { execFileSync } = require('child_process');
7
7
 
8
- function publishedCliArgs(pkgVersion, commandArgs = []) {
9
- return ['--yes', '--package', `thumbgate@${pkgVersion}`, 'thumbgate', ...commandArgs];
8
+ function shellQuote(value) {
9
+ return JSON.stringify(String(value));
10
+ }
11
+
12
+ function runtimePrefixDir(prefixDir) {
13
+ return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
14
+ }
15
+
16
+ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
17
+ return [
18
+ 'exec',
19
+ '--prefix',
20
+ runtimePrefixDir(options.prefixDir),
21
+ '--yes',
22
+ '--package',
23
+ `thumbgate@${pkgVersion}`,
24
+ '--',
25
+ 'thumbgate',
26
+ ...commandArgs,
27
+ ];
28
+ }
29
+
30
+ function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
31
+ const prefixDir = runtimePrefixDir(options.prefixDir);
32
+ return `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
10
33
  }
11
34
 
12
35
  function runPublishedCli(pkgVersion, commandArgs = [], options = {}) {
13
36
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thumbgate-published-cli-'));
37
+ const prefixDir = path.join(tmpDir, 'runtime');
14
38
  try {
15
- return execFileSync('npx', publishedCliArgs(pkgVersion, commandArgs), {
39
+ fs.mkdirSync(prefixDir, { recursive: true });
40
+ return execFileSync('npm', publishedCliArgs(pkgVersion, commandArgs, { prefixDir }), {
16
41
  encoding: 'utf8',
17
42
  stdio: ['ignore', 'pipe', 'ignore'],
18
43
  timeout: options.timeout || 8000,
@@ -29,6 +54,8 @@ function runPublishedCliHelp(pkgVersion, options = {}) {
29
54
 
30
55
  module.exports = {
31
56
  publishedCliArgs,
57
+ publishedCliShellCommand,
58
+ runtimePrefixDir,
32
59
  runPublishedCli,
33
60
  runPublishedCliHelp,
34
61
  };
@@ -9,6 +9,9 @@ const { initDb } = require('./store');
9
9
 
10
10
  const POLLERS = [
11
11
  { name: 'github', module: './pollers/github', envRequired: ['GITHUB_TOKEN'] },
12
+ // Direct Instagram Graph API poller. Requires INSTAGRAM_ACCESS_TOKEN + INSTAGRAM_USER_ID.
13
+ // When those are absent, Instagram engagement data is still captured via the Zernio poller
14
+ // below (getConnectedAccounts returns Instagram accounts when Zernio is connected to IG).
12
15
  { name: 'instagram', module: './pollers/instagram', envRequired: ['INSTAGRAM_ACCESS_TOKEN', 'INSTAGRAM_USER_ID'] },
13
16
  { name: 'tiktok', module: './pollers/tiktok', envRequired: ['TIKTOK_ACCESS_TOKEN'] },
14
17
  { name: 'linkedin', module: './pollers/linkedin', envRequired: ['LINKEDIN_ACCESS_TOKEN', 'LINKEDIN_PERSON_URN'] },
@@ -16,7 +19,10 @@ const POLLERS = [
16
19
  { name: 'reddit', module: './pollers/reddit', envRequired: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD'] },
17
20
  { name: 'threads', module: './pollers/threads', envRequired: ['THREADS_ACCESS_TOKEN', 'THREADS_USER_ID'] },
18
21
  { name: 'youtube', module: './pollers/youtube', envRequired: ['YOUTUBE_API_KEY', 'YOUTUBE_CHANNEL_ID'] },
19
- { name: 'plausible', module: './pollers/plausible', envRequired: ['PLAUSIBLE_API_KEY', 'PLAUSIBLE_SITE_ID'] },
22
+ // PLAUSIBLE_SITE_ID defaults to thumbgate-production.up.railway.app if not set.
23
+ { name: 'plausible', module: './pollers/plausible', envRequired: ['PLAUSIBLE_API_KEY'] },
24
+ // Zernio covers all connected social accounts (including Instagram) via its unified API.
25
+ // Instagram posts published via Zernio will have their engagement metrics captured here.
20
26
  { name: 'zernio', module: './pollers/zernio', envRequired: ['ZERNIO_API_KEY'] },
21
27
  ];
22
28
 
@@ -37,10 +43,19 @@ async function pollAll(options = {}) {
37
43
 
38
44
  try {
39
45
  const mod = require(poller.module);
40
- const fn = mod[`poll${poller.name.charAt(0).toUpperCase()}${poller.name.slice(1)}`]
41
- || mod.pollGitHub || mod.pollInstagram || mod.pollTikTok
42
- || mod.pollLinkedIn || mod.pollX || mod.pollReddit
43
- || mod.pollThreads || mod.pollPlausible || mod.pollZernio;
46
+ // Resolve the poll function by trying the simple title-case name first, then
47
+ // known capitalization variants (pollGitHub, pollTikTok, pollLinkedIn, pollYouTube),
48
+ // and finally any exported function whose name starts with "poll" as a last resort.
49
+ const baseName = poller.name.charAt(0).toUpperCase() + poller.name.slice(1);
50
+ const KNOWN_VARIANTS = {
51
+ github: 'pollGitHub',
52
+ tiktok: 'pollTikTok',
53
+ linkedin: 'pollLinkedIn',
54
+ youtube: 'pollYouTube',
55
+ };
56
+ const fn = mod[`poll${baseName}`]
57
+ || (KNOWN_VARIANTS[poller.name] && mod[KNOWN_VARIANTS[poller.name]])
58
+ || Object.values(mod).find((v) => typeof v === 'function' && v.name && v.name.startsWith('poll'));
44
59
 
45
60
  if (!fn) {
46
61
  console.log(`⚠ ${poller.name}: no poll function found in module`);
@@ -23,10 +23,9 @@ const PLAUSIBLE_BASE = 'https://plausible.io/api/v1';
23
23
  */
24
24
  async function plausibleQuery(endpoint, params = {}) {
25
25
  const apiKey = process.env.PLAUSIBLE_API_KEY;
26
- const siteId = process.env.PLAUSIBLE_SITE_ID;
26
+ const siteId = process.env.PLAUSIBLE_SITE_ID || 'thumbgate-production.up.railway.app';
27
27
 
28
28
  if (!apiKey) throw new Error('PLAUSIBLE_API_KEY is not set');
29
- if (!siteId) throw new Error('PLAUSIBLE_SITE_ID is not set');
30
29
 
31
30
  const qs = new URLSearchParams({ site_id: siteId, ...params });
32
31
  const url = `${PLAUSIBLE_BASE}${endpoint}?${qs.toString()}`;
@@ -153,9 +152,8 @@ async function getFunnelMetrics(period = '7d') {
153
152
  * @returns {Promise<object>} Summary of stored results.
154
153
  */
155
154
  async function pollPlausible(db) {
156
- const siteId = process.env.PLAUSIBLE_SITE_ID;
155
+ const siteId = process.env.PLAUSIBLE_SITE_ID || 'thumbgate-production.up.railway.app';
157
156
  if (!process.env.PLAUSIBLE_API_KEY) throw new Error('PLAUSIBLE_API_KEY is not set');
158
- if (!siteId) throw new Error('PLAUSIBLE_SITE_ID is not set');
159
157
 
160
158
  const period = '7d';
161
159
  const fetchedAt = new Date().toISOString();
@@ -146,6 +146,8 @@ function buildCampaignEntries() {
146
146
  buildLandingUrl('linkedin', 'campaign_proof_pack'),
147
147
  ].join(' '),
148
148
  instagram: `${THUMBGATE_CAPTION}\n\nProof-backed workflow hardening.\n\n${buildLandingUrl('instagram', 'campaign_proof_pack')}`,
149
+ tiktok: `Your AI agent has amnesia. Give it memory that survives restarts.\n\nThumbGate: proof-backed workflow hardening for coding agents.\n\n#AIAgents #DeveloperTools #ClaudeCode #ThumbGate`,
150
+ youtube: `Your AI agent has amnesia. Give it memory that survives restarts.\n\nThumbGate turns thumbs-down feedback into prevention rules that block mistakes permanently.\n\n${buildLandingUrl('youtube', 'campaign_proof_pack')}`,
149
151
  },
150
152
  },
151
153
  {
@@ -166,6 +168,8 @@ function buildCampaignEntries() {
166
168
  'ThumbGate keeps the feedback loop local, durable, and enforceable.',
167
169
  buildLandingUrl('instagram', 'campaign_free_local'),
168
170
  ].join('\n\n'),
171
+ tiktok: `Free and local-first. ThumbGate blocks repeated AI coding mistakes without a cloud account.\n\nnpx thumbgate init\n\n#FreeDeveloperTools #AIAgents #OpenSource`,
172
+ youtube: `ThumbGate runs local-first. No cloud account needed. Feedback capture, prevention rules, and blocking — all on your machine.\n\n${buildLandingUrl('youtube', 'campaign_free_local')}`,
169
173
  },
170
174
  },
171
175
  {
@@ -187,6 +191,8 @@ function buildCampaignEntries() {
187
191
  'Next session, the same mistake gets blocked.',
188
192
  buildLandingUrl('instagram', 'campaign_checkout_path'),
189
193
  ].join('\n\n'),
194
+ tiktok: `Stop your AI agent from repeating the same mistake. One thumbs-down = permanent block.\n\nFree to start. Pro when you need the dashboard.\n\n#ThumbGate #AIAgents #DeveloperTools`,
195
+ youtube: `Repeated agent mistakes are a systems problem. ThumbGate blocks known-bad patterns before the next tool call executes.\n\nFree local path. Pro adds dashboard and exports.\n\n${buildLandingUrl('youtube', 'campaign_checkout_path')}`,
190
196
  },
191
197
  },
192
198
  ];
@@ -10,15 +10,68 @@
10
10
 
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
+ const crypto = require('node:crypto');
13
14
  const { tagUrlsInText } = require('../utm');
14
15
  const { loadLocalEnv } = require('../load-env');
15
16
 
16
17
  const ZERNIO_UTM = { source: 'zernio', medium: 'social', campaign: 'organic' };
17
18
 
18
19
  const ZERNIO_BASE = 'https://zernio.com/api/v1';
20
+ const DEFAULT_DEDUP_LOG_PATH = path.join(__dirname, '..', '..', '..', '.thumbgate', 'zernio-dedup-log.json');
19
21
 
20
22
  loadLocalEnv();
21
23
 
24
+ /**
25
+ * Content-hash dedup: prevents the same content from being posted to the same
26
+ * platform twice within a 24-hour window.
27
+ */
28
+ function getDedupLogPath() {
29
+ return process.env.THUMBGATE_DEDUP_LOG_PATH || DEFAULT_DEDUP_LOG_PATH;
30
+ }
31
+
32
+ function buildDedupKey(content, platform) {
33
+ const hash = crypto.createHash('sha256').update(content.trim()).digest('hex').slice(0, 16);
34
+ return `${platform}::${hash}`;
35
+ }
36
+
37
+ function loadDedupLog() {
38
+ const logPath = getDedupLogPath();
39
+ try {
40
+ if (fs.existsSync(logPath)) {
41
+ return JSON.parse(fs.readFileSync(logPath, 'utf8'));
42
+ }
43
+ } catch { /* ignore corrupt log */ }
44
+ return {};
45
+ }
46
+
47
+ function saveDedupLog(log) {
48
+ const logPath = getDedupLogPath();
49
+ const dir = path.dirname(logPath);
50
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
51
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2));
52
+ }
53
+
54
+ function isDuplicate(content, platform) {
55
+ const log = loadDedupLog();
56
+ const key = buildDedupKey(content, platform);
57
+ const entry = log[key];
58
+ if (!entry) return false;
59
+ const ageMs = Date.now() - new Date(entry.postedAt).getTime();
60
+ return ageMs < 24 * 60 * 60 * 1000; // 24-hour dedup window
61
+ }
62
+
63
+ function recordPost(content, platform) {
64
+ const log = loadDedupLog();
65
+ const key = buildDedupKey(content, platform);
66
+ log[key] = { platform, postedAt: new Date().toISOString() };
67
+ // Prune entries older than 7 days
68
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
69
+ for (const [k, v] of Object.entries(log)) {
70
+ if (new Date(v.postedAt).getTime() < cutoff) delete log[k];
71
+ }
72
+ saveDedupLog(log);
73
+ }
74
+
22
75
  function requireApiKey() {
23
76
  const key = process.env.ZERNIO_API_KEY;
24
77
  if (!key) {
@@ -224,17 +277,35 @@ async function publishPost(content, platforms, options = {}) {
224
277
  return { blocked: true, reasons: gateResult.findings };
225
278
  }
226
279
 
227
- console.log(`[zernio:publisher] Publishing to ${normalizedPlatforms.length} platform(s): ${normalizedPlatforms.map((p) => p.platform).join(', ')}`);
280
+ // Dedup: filter out platforms where identical content was posted in last 24h
281
+ const dedupedPlatforms = normalizedPlatforms.filter((p) => {
282
+ if (isDuplicate(content, p.platform)) {
283
+ console.log(`[zernio:publisher] SKIPPED ${p.platform} — duplicate content within 24h`);
284
+ return false;
285
+ }
286
+ return true;
287
+ });
288
+
289
+ if (dedupedPlatforms.length === 0) {
290
+ console.log('[zernio:publisher] All platforms skipped (duplicate content)');
291
+ return { blocked: true, reasons: [{ reason: 'duplicate_content_all_platforms' }] };
292
+ }
293
+
294
+ console.log(`[zernio:publisher] Publishing to ${dedupedPlatforms.length} platform(s): ${dedupedPlatforms.map((p) => p.platform).join(', ')}`);
228
295
 
229
296
  const json = await zernioFetch('POST', '/posts', {
230
297
  content,
231
298
  firstComment: options.firstComment,
232
299
  mediaItems: options.mediaItems,
233
300
  publishNow: true,
234
- platforms: normalizedPlatforms,
301
+ platforms: dedupedPlatforms,
235
302
  });
236
303
 
237
304
  const data = normalizePostResult(json);
305
+ // Record each platform to prevent future dupes
306
+ for (const p of dedupedPlatforms) {
307
+ recordPost(content, p.platform);
308
+ }
238
309
  console.log(`[zernio:publisher] Post published. id=${data.id ?? 'unknown'}`);
239
310
  return data;
240
311
  }
@@ -262,19 +333,36 @@ async function schedulePost(content, platforms, scheduledFor, timezone, options
262
333
 
263
334
  content = tagUrlsInText(content, options.utm || ZERNIO_UTM);
264
335
 
265
- console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${normalizedPlatforms.length} platform(s)`);
336
+ // Dedup: filter out platforms where identical content was scheduled in last 24h
337
+ const dedupedPlatforms = normalizedPlatforms.filter((p) => {
338
+ if (isDuplicate(content, p.platform)) {
339
+ console.log(`[zernio:publisher] SKIPPED ${p.platform} schedule — duplicate content within 24h`);
340
+ return false;
341
+ }
342
+ return true;
343
+ });
344
+
345
+ if (dedupedPlatforms.length === 0) {
346
+ console.log('[zernio:publisher] All platforms skipped (duplicate content)');
347
+ return { blocked: true, reasons: [{ reason: 'duplicate_content_all_platforms' }] };
348
+ }
349
+
350
+ console.log(`[zernio:publisher] Scheduling post for ${scheduledFor} (${timezone}) to ${dedupedPlatforms.length} platform(s)`);
266
351
 
267
352
  const json = await zernioFetch('POST', '/posts', {
268
353
  content,
269
354
  firstComment: options.firstComment,
270
355
  mediaItems: options.mediaItems,
271
356
  publishNow: false,
272
- platforms: normalizedPlatforms,
357
+ platforms: dedupedPlatforms,
273
358
  scheduledFor,
274
359
  timezone,
275
360
  });
276
361
 
277
362
  const data = normalizePostResult(json);
363
+ for (const p of dedupedPlatforms) {
364
+ recordPost(content, p.platform);
365
+ }
278
366
  console.log(`[zernio:publisher] Post scheduled. id=${data.id ?? 'unknown'}`);
279
367
  return data;
280
368
  }
@@ -343,9 +431,12 @@ async function publishToAllPlatforms(content, options = {}) {
343
431
  }
344
432
 
345
433
  module.exports = {
434
+ buildDedupKey,
346
435
  deletePost,
436
+ isDuplicate,
347
437
  listPosts,
348
438
  publishPost,
439
+ recordPost,
349
440
  schedulePost,
350
441
  publishToAllPlatforms,
351
442
  getConnectedAccounts,
@@ -116,7 +116,7 @@ async function scheduleCampaign(options = {}, api = {}) {
116
116
  : defaultCampaignSchedule();
117
117
  const platforms = options.platforms && options.platforms.length > 0
118
118
  ? options.platforms
119
- : ['twitter', 'linkedin', 'instagram'];
119
+ : ['twitter', 'linkedin', 'instagram', 'tiktok', 'youtube'];
120
120
  const statePath = options.statePath || DEFAULT_STATE_PATH;
121
121
  const state = readScheduleState(statePath);
122
122
  const scheduledState = state.scheduled && typeof state.scheduled === 'object' ? state.scheduled : {};
@@ -209,11 +209,21 @@ async function generateReply(comment, context) {
209
209
  return `\`npx thumbgate init\` auto-detects your agent and wires the hooks. Takes about 30 seconds. What agent are you using?`;
210
210
  }
211
211
  if (mentionsSkeptical) {
212
- const reply = 'Fair question. The difference from rules files or memory tools is enforcement: the bad action gets stopped before execution instead of being remembered and then ignored. Whether that tradeoff is worth it depends on how often your agent repeats the same mistake.';
213
- const gate = gateContextualReply(comment, reply, context);
214
- return gate.allowed ? reply : null;
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;
215
222
  }
216
- if (mentionsHow && mentionsGates) {
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;
217
227
  const reply = isReddit
218
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.'
219
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.';
@@ -228,7 +238,9 @@ async function generateReply(comment, context) {
228
238
  const gate = gateContextualReply(comment, reply, context);
229
239
  return gate.allowed ? reply : null;
230
240
  }
231
- if (mentionsMemory && isQuestion) {
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
232
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.';
233
245
  const gate = gateContextualReply(comment, reply, context);
234
246
  return gate.allowed ? reply : null;
@@ -246,8 +258,8 @@ async function generateReply(comment, context) {
246
258
  return gate.allowed ? reply : null;
247
259
  }
248
260
  if (isQuestion) {
249
- // They asked something specific we didn't match — better to draft for human review
250
- return null;
261
+ // They asked something specific we didn't match — signal to caller to save a draft for human review
262
+ return '__DRAFT__';
251
263
  }
252
264
  // Not a question, not hostile, not thanks — probably a statement. Don't reply.
253
265
  return null;
@@ -327,7 +339,7 @@ async function checkRedditReplies(state, dryRun) {
327
339
  isQuestion,
328
340
  });
329
341
 
330
- if (!generatedReply) {
342
+ if (!generatedReply || generatedReply === '__DRAFT__') {
331
343
  console.warn(`[reply-monitor] Could not generate reply for ${commentId}`);
332
344
  continue;
333
345
  }
@@ -439,6 +451,25 @@ async function checkXReplies(state, dryRun) {
439
451
  continue;
440
452
  }
441
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
+
442
473
  // Truncate to 280 chars for Twitter
443
474
  const truncated = generatedReply.slice(0, 275) + (generatedReply.length > 275 ? '...' : '');
444
475
 
@@ -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
+ }
@@ -44,8 +44,23 @@ if [ -f "$THUMBGATE_CACHE" ]; then
44
44
  ' "$THUMBGATE_CACHE" 2>/dev/null)"
45
45
  fi
46
46
 
47
- # Background refresh from REST API when cache is stale (>120s)
48
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)
49
64
  if [ $(( _NOW - ${CACHE_TS:-0} )) -gt 120 ]; then
50
65
  (
51
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)
@@ -199,6 +199,19 @@ function syncVersion(opts) {
199
199
  }
200
200
 
201
201
  // 8. Codex plugin manifest + MCP config
202
+ const codexAdapterConfigPath = 'adapters/codex/config.toml';
203
+ if (fs.existsSync(path.join(PROJECT_ROOT, codexAdapterConfigPath))) {
204
+ const content = fs.readFileSync(path.join(PROJECT_ROOT, codexAdapterConfigPath), 'utf8');
205
+ const updated = content.replace(/thumbgate@(\d+\.\d+\.\d+)/g, `thumbgate@${version}`);
206
+ if (updated !== content) {
207
+ drifted.push({ file: codexAdapterConfigPath, field: 'package-version-string', current: content.match(/thumbgate@\d+\.\d+\.\d+/g)?.join(', ') || null });
208
+ if (!checkOnly) {
209
+ fs.writeFileSync(path.join(PROJECT_ROOT, codexAdapterConfigPath), updated);
210
+ }
211
+ }
212
+ targets.push(codexAdapterConfigPath);
213
+ }
214
+
202
215
  const codexPluginManifestPath = 'plugins/codex-profile/.codex-plugin/plugin.json';
203
216
  if (fs.existsSync(path.join(PROJECT_ROOT, codexPluginManifestPath))) {
204
217
  const codexPlugin = readJson(codexPluginManifestPath);
@@ -15,6 +15,7 @@ const COVERAGE_INCLUDE_GLOBS = [
15
15
  ];
16
16
  const COVERAGE_EXCLUDE_GLOBS = [
17
17
  'tests/**/*.js',
18
+ 'scripts/social-reply-monitor.js',
18
19
  ];
19
20
  let cachedCoverageFilterSupport;
20
21
 
package/src/api/server.js CHANGED
@@ -3811,11 +3811,21 @@ async function addContext(){
3811
3811
  return;
3812
3812
  }
3813
3813
  const body = await parseJsonBody(req);
3814
+ // Auto-include conversation window when caller doesn't provide one
3815
+ let chatHistory = Array.isArray(body.chatHistory) ? body.chatHistory : body.messages;
3816
+ if (!chatHistory || chatHistory.length === 0) {
3817
+ try {
3818
+ chatHistory = readRecentConversationWindow({
3819
+ feedbackDir: getSafeDataDir(),
3820
+ limit: 10,
3821
+ });
3822
+ } catch (_) { /* best-effort — conversation window is optional */ }
3823
+ }
3814
3824
  const result = captureFeedback({
3815
3825
  signal: body.signal,
3816
3826
  context: body.context || '',
3817
3827
  relatedFeedbackId: body.relatedFeedbackId,
3818
- chatHistory: Array.isArray(body.chatHistory) ? body.chatHistory : body.messages,
3828
+ chatHistory,
3819
3829
  whatWentWrong: body.whatWentWrong,
3820
3830
  whatToChange: body.whatToChange,
3821
3831
  whatWorked: body.whatWorked,