thumbgate 1.27.20 → 1.28.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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +124 -129
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +18 -33
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +95 -78
  8. package/bin/postinstall.js +1 -1
  9. package/config/entitlement-public-keys.json +6 -0
  10. package/config/gates/default.json +3 -3
  11. package/config/github-about.json +2 -5
  12. package/config/merge-quality-checks.json +3 -0
  13. package/config/post-deploy-marketing-pages.json +1 -1
  14. package/hooks/hooks.json +38 -0
  15. package/package.json +28 -10
  16. package/public/about.html +1 -4
  17. package/public/agent-manager.html +6 -6
  18. package/public/agents-cost-savings.html +3 -3
  19. package/public/ai-malpractice-prevention.html +1 -1
  20. package/public/blog.html +12 -9
  21. package/public/chatgpt-app.html +3 -3
  22. package/public/codex-enterprise.html +3 -3
  23. package/public/codex-plugin.html +5 -5
  24. package/public/compare.html +10 -10
  25. package/public/dashboard.html +1 -1
  26. package/public/diagnostic.html +23 -1
  27. package/public/federal.html +2 -2
  28. package/public/guide.html +14 -14
  29. package/public/index.html +202 -220
  30. package/public/install.html +14 -14
  31. package/public/learn.html +4 -17
  32. package/public/numbers.html +2 -2
  33. package/public/partner-intake.html +198 -0
  34. package/public/pricing.html +61 -31
  35. package/public/pro.html +2 -2
  36. package/scripts/agent-reward-model.js +13 -0
  37. package/scripts/bayes-optimal-gate.js +6 -1
  38. package/scripts/billing.js +815 -137
  39. package/scripts/checkout-attribution-reference.js +85 -0
  40. package/scripts/claude-feedback-sync.js +23 -6
  41. package/scripts/cli-feedback.js +33 -8
  42. package/scripts/commercial-offer.js +3 -3
  43. package/scripts/entitlement.js +250 -0
  44. package/scripts/export-databricks-bundle.js +5 -0
  45. package/scripts/export-dpo-pairs.js +6 -0
  46. package/scripts/export-hf-dataset.js +5 -0
  47. package/scripts/feedback-loop.js +290 -1
  48. package/scripts/feedback-quality.js +25 -26
  49. package/scripts/feedback-sanitizer.js +151 -3
  50. package/scripts/gates-engine.js +134 -43
  51. package/scripts/hosted-config.js +9 -2
  52. package/scripts/imperative-detector.js +85 -0
  53. package/scripts/intervention-policy.js +13 -0
  54. package/scripts/mailer/resend-mailer.js +11 -5
  55. package/scripts/pr-manager.js +9 -22
  56. package/scripts/pro-local-dashboard.js +198 -0
  57. package/scripts/risk-scorer.js +6 -0
  58. package/scripts/secret-scanner.js +363 -68
  59. package/scripts/self-protection.js +21 -36
  60. package/scripts/seo-gsd.js +2 -2
  61. package/scripts/thompson-sampling.js +11 -2
  62. package/src/api/server.js +202 -22
  63. package/public/assets/brand/github-social-preview.png +0 -0
@@ -7,6 +7,7 @@
7
7
  * -> compute analytics -> generate prevention rules
8
8
  */
9
9
 
10
+ const crypto = require('node:crypto');
10
11
  const fs = require('fs');
11
12
  const path = require('path');
12
13
  const { loadOptionalModule } = require('./private-core-boundary');
@@ -19,6 +20,7 @@ const {
19
20
  const {
20
21
  buildClarificationMessage,
21
22
  isGenericFeedbackText,
23
+ normalizeFeedbackText,
22
24
  } = require('./feedback-quality');
23
25
  const {
24
26
  buildRubricEvaluation,
@@ -55,6 +57,10 @@ const {
55
57
  }));
56
58
 
57
59
  const AUDIT_TRAIL_TAG = 'audit-trail';
60
+ const FEEDBACK_EVENT_DEDUPE_WINDOW_MS = 30 * 1000;
61
+ const FEEDBACK_EVENT_CLAIM_STALE_MS = 60 * 1000;
62
+ const FEEDBACK_EVENT_CLAIM_WAIT_MS = 15 * 1000;
63
+ const FEEDBACK_EVENT_CLAIM_POLL_MS = 25;
58
64
 
59
65
  /**
60
66
  * Anonymous fire-and-forget CLI feedback telemetry.
@@ -631,6 +637,269 @@ function normalizeSignal(signal) {
631
637
  return null;
632
638
  }
633
639
 
640
+ function hashFeedbackSourcePart(value) {
641
+ const text = String(value || '').trim();
642
+ if (!text) return null;
643
+ return crypto.createHash('sha256').update(text).digest('hex');
644
+ }
645
+
646
+ function parseFeedbackSourceTimestamp(value) {
647
+ if (typeof value === 'number' && Number.isFinite(value)) {
648
+ return value > 1e12 ? value : value * 1000;
649
+ }
650
+ const text = String(value || '').trim();
651
+ if (!text) return Date.now();
652
+ if (/^\d+$/.test(text)) {
653
+ const numeric = Number(text);
654
+ return numeric > 1e12 ? numeric : numeric * 1000;
655
+ }
656
+ const parsed = Date.parse(text);
657
+ return Number.isFinite(parsed) ? parsed : Date.now();
658
+ }
659
+
660
+ /**
661
+ * Build a privacy-preserving identity for one external thumbs event.
662
+ * Raw session, prompt, and project identifiers are hashed before persistence.
663
+ * A prompt ID wins when present so two distinct prompts with identical text are
664
+ * still separate events; otherwise the session + project + text fingerprint is
665
+ * deduplicated inside a short capture window.
666
+ */
667
+ function buildFeedbackSourceIdentity(input = {}) {
668
+ const signal = normalizeSignal(input.signal);
669
+ const normalizedText = normalizeFeedbackText(input.promptText || input.context);
670
+ if (!signal || !normalizedText) return null;
671
+
672
+ const sessionHash = hashFeedbackSourcePart(input.sessionId);
673
+ const promptIdHash = hashFeedbackSourcePart(input.promptId);
674
+ const projectHash = hashFeedbackSourcePart(input.projectDir || input.project);
675
+ if (!sessionHash && !promptIdHash) return null;
676
+
677
+ const textHash = hashFeedbackSourcePart(normalizedText);
678
+ const discriminator = promptIdHash
679
+ ? `prompt:${sessionHash || ''}:${projectHash || ''}:${promptIdHash}`
680
+ : `content:${sessionHash || ''}:${projectHash || ''}:${textHash}`;
681
+ const eventHash = hashFeedbackSourcePart(['v1', signal, discriminator].join(':'));
682
+ const key = `fev_${eventHash}`;
683
+
684
+ return {
685
+ key,
686
+ signal,
687
+ normalizedText,
688
+ sessionHash,
689
+ promptIdHash,
690
+ projectHash,
691
+ eventTimestampMs: parseFeedbackSourceTimestamp(input.timestamp),
692
+ source: String(input.source || 'external').replace(/[^a-z0-9._-]/gi, '-').slice(0, 64),
693
+ };
694
+ }
695
+
696
+ function normalizeFeedbackSourceIdentity(identity, params = {}) {
697
+ if (!identity || typeof identity !== 'object' || typeof identity.key !== 'string') return null;
698
+ const signal = normalizeSignal(params.signal || identity.signal);
699
+ const normalizedText = normalizeFeedbackText(
700
+ identity.normalizedText || params.context || params.whatWentWrong || params.whatWorked,
701
+ );
702
+ if (!signal || !normalizedText) return null;
703
+
704
+ return {
705
+ key: `fev_${hashFeedbackSourcePart(identity.key)}`,
706
+ signal,
707
+ normalizedText,
708
+ sessionHash: hashFeedbackSourcePart(identity.sessionHash),
709
+ promptIdHash: hashFeedbackSourcePart(identity.promptIdHash),
710
+ projectHash: hashFeedbackSourcePart(identity.projectHash),
711
+ eventTimestampMs: parseFeedbackSourceTimestamp(identity.eventTimestampMs),
712
+ source: String(identity.source || 'external').replace(/[^a-z0-9._-]/gi, '-').slice(0, 64),
713
+ };
714
+ }
715
+
716
+ function publicFeedbackSourceMetadata(identity) {
717
+ if (!identity) return null;
718
+ return {
719
+ key: identity.key,
720
+ sessionHash: identity.sessionHash,
721
+ promptIdHash: identity.promptIdHash,
722
+ projectHash: identity.projectHash,
723
+ source: identity.source,
724
+ timestamp: new Date(identity.eventTimestampMs).toISOString(),
725
+ };
726
+ }
727
+
728
+ function readClaimState(claimPath) {
729
+ try {
730
+ return JSON.parse(fs.readFileSync(path.join(claimPath, 'state.json'), 'utf8'));
731
+ } catch {
732
+ return null;
733
+ }
734
+ }
735
+
736
+ function writeClaimState(claimPath, state) {
737
+ const target = path.join(claimPath, 'state.json');
738
+ const temporary = path.join(claimPath, `state.${process.pid}.${Date.now()}.tmp`);
739
+ fs.writeFileSync(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 });
740
+ fs.renameSync(temporary, target);
741
+ }
742
+
743
+ function sleepSync(milliseconds) {
744
+ const signal = new Int32Array(new SharedArrayBuffer(4));
745
+ Atomics.wait(signal, 0, 0, milliseconds);
746
+ }
747
+
748
+ function replaceStaleClaim(claimPath) {
749
+ const nonce = crypto.randomBytes(4).toString('hex');
750
+ const stalePath = `${claimPath}.stale-${process.pid}-${Date.now()}-${nonce}`;
751
+ try {
752
+ fs.renameSync(claimPath, stalePath);
753
+ fs.rmSync(stalePath, { recursive: true, force: true });
754
+ return true;
755
+ } catch (error) {
756
+ if (error && (error.code === 'ENOENT' || error.code === 'EEXIST')) return false;
757
+ throw error;
758
+ }
759
+ }
760
+
761
+ function waitForClaimCompletion(claimPath, deadlineMs) {
762
+ let state = readClaimState(claimPath);
763
+ while ((!state || state.status === 'pending') && Date.now() < deadlineMs) {
764
+ sleepSync(FEEDBACK_EVENT_CLAIM_POLL_MS);
765
+ state = readClaimState(claimPath);
766
+ }
767
+ return state;
768
+ }
769
+
770
+ function findRecordById(filePath, id) {
771
+ if (!id) return null;
772
+ const entries = readJSONL(filePath, { maxLines: 500 });
773
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
774
+ if (entries[index]?.id === id) return entries[index];
775
+ }
776
+ return null;
777
+ }
778
+
779
+ function duplicateCaptureResult(receipt, paths, options = {}) {
780
+ const feedbackEvent = findRecordById(paths.FEEDBACK_LOG_PATH, receipt?.feedbackId);
781
+ const memoryRecord = findRecordById(paths.MEMORY_LOG_PATH, receipt?.memoryId);
782
+ return {
783
+ accepted: Boolean(receipt?.accepted),
784
+ signalLogged: Boolean(receipt?.signalLogged),
785
+ duplicate: true,
786
+ pending: Boolean(options.pending),
787
+ status: options.pending ? 'duplicate_in_progress' : 'duplicate',
788
+ reason: options.pending ? 'source_event_capture_in_progress' : 'source_event_already_captured',
789
+ message: options.pending
790
+ ? 'Equivalent feedback is already being captured.'
791
+ : 'This feedback event was already captured; no counters or lessons were changed.',
792
+ feedbackEvent: feedbackEvent || (receipt?.feedbackId ? { id: receipt.feedbackId } : null),
793
+ memoryRecord: memoryRecord || (receipt?.memoryId ? { id: receipt.memoryId } : null),
794
+ };
795
+ }
796
+
797
+ function sourceHashMatches(expected, actual) {
798
+ return !expected || !actual || expected === actual;
799
+ }
800
+
801
+ function isDuplicateFeedbackEntry(entry, identity) {
802
+ if (!entry || entry.signal !== identity.signal) return false;
803
+ if (normalizeFeedbackText(entry.submittedContext || entry.context) !== identity.normalizedText) return false;
804
+
805
+ const sourceEvent = entry.sourceEvent || {};
806
+ if (!sourceHashMatches(identity.sessionHash, sourceEvent.sessionHash)) return false;
807
+ if (!sourceHashMatches(identity.projectHash, sourceEvent.projectHash)) return false;
808
+ if (!sourceHashMatches(identity.promptIdHash, sourceEvent.promptIdHash)) return false;
809
+ if (identity.promptIdHash && sourceEvent.promptIdHash === identity.promptIdHash) return true;
810
+
811
+ const timestamp = parseFeedbackSourceTimestamp(sourceEvent.timestamp || entry.timestamp);
812
+ return Math.abs(identity.eventTimestampMs - timestamp) <= FEEDBACK_EVENT_DEDUPE_WINDOW_MS;
813
+ }
814
+
815
+ function receiptFromFeedbackEntry(entry, paths) {
816
+ if (!entry) return null;
817
+ const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 500 });
818
+ const memory = memories.find((candidate) => candidate?.sourceFeedbackId === entry.id);
819
+ return {
820
+ status: 'complete',
821
+ feedbackId: entry.id,
822
+ memoryId: memory?.id,
823
+ accepted: entry.actionType !== 'no-action'
824
+ && (!Array.isArray(entry.validationIssues) || entry.validationIssues.length === 0),
825
+ signalLogged: true,
826
+ completedAtMs: Date.parse(entry.timestamp || '') || Date.now(),
827
+ };
828
+ }
829
+
830
+ function recentDuplicateReceipt(identity, paths) {
831
+ const entries = readJSONL(paths.FEEDBACK_LOG_PATH, { maxLines: 250 });
832
+ const entry = entries.findLast((candidate) => isDuplicateFeedbackEntry(candidate, identity));
833
+ return receiptFromFeedbackEntry(entry, paths);
834
+ }
835
+
836
+ function tryCreateFeedbackClaim(claimPath) {
837
+ try {
838
+ fs.mkdirSync(claimPath, { mode: 0o700 });
839
+ writeClaimState(claimPath, { status: 'pending', startedAtMs: Date.now() });
840
+ return { owned: true, claimPath };
841
+ } catch (error) {
842
+ if (error?.code === 'EEXIST') return null;
843
+ throw error;
844
+ }
845
+ }
846
+
847
+ function existingClaimResult(state, identity, paths) {
848
+ const stateTimestamp = Number(state?.completedAtMs || state?.startedAtMs || 0);
849
+ const ageMs = stateTimestamp > 0 ? Date.now() - stateTimestamp : 0;
850
+ if (state?.status === 'complete'
851
+ && (identity.promptIdHash || ageMs <= FEEDBACK_EVENT_DEDUPE_WINDOW_MS)) {
852
+ return duplicateCaptureResult(state, paths);
853
+ }
854
+ if (state?.status === 'pending' && ageMs <= FEEDBACK_EVENT_CLAIM_STALE_MS) {
855
+ return duplicateCaptureResult(state, paths, { pending: true });
856
+ }
857
+ return null;
858
+ }
859
+
860
+ function acquireFeedbackEventClaim(identity, paths) {
861
+ const recent = recentDuplicateReceipt(identity, paths);
862
+ if (recent) return { owned: false, result: duplicateCaptureResult(recent, paths) };
863
+
864
+ const claimsRoot = path.join(paths.FEEDBACK_DIR, '.feedback-event-claims');
865
+ const claimPath = path.join(claimsRoot, identity.key);
866
+ ensureDir(claimsRoot);
867
+
868
+ for (let attempt = 0; attempt < 3; attempt += 1) {
869
+ const ownedClaim = tryCreateFeedbackClaim(claimPath);
870
+ if (ownedClaim) return ownedClaim;
871
+
872
+ const state = waitForClaimCompletion(claimPath, Date.now() + FEEDBACK_EVENT_CLAIM_WAIT_MS);
873
+ const duplicateResult = existingClaimResult(state, identity, paths);
874
+ if (duplicateResult) return { owned: false, result: duplicateResult };
875
+ if (!replaceStaleClaim(claimPath)) continue;
876
+ }
877
+
878
+ return {
879
+ owned: false,
880
+ result: duplicateCaptureResult(null, paths, { pending: true }),
881
+ };
882
+ }
883
+
884
+ function finalizeFeedbackEventClaim(claim, result) {
885
+ if (!claim?.owned) return;
886
+ writeClaimState(claim.claimPath, {
887
+ status: 'complete',
888
+ startedAtMs: Date.now(),
889
+ completedAtMs: Date.now(),
890
+ accepted: Boolean(result?.accepted),
891
+ signalLogged: Boolean(result?.signalLogged || result?.feedbackEvent),
892
+ feedbackId: result?.feedbackEvent?.id || null,
893
+ memoryId: result?.memoryRecord?.id || null,
894
+ });
895
+ }
896
+
897
+ function releaseFeedbackEventClaim(claim) {
898
+ if (claim?.owned && claim.claimPath) {
899
+ fs.rmSync(claim.claimPath, { recursive: true, force: true });
900
+ }
901
+ }
902
+
634
903
  function parseOptionalObject(input, name) {
635
904
  if (input == null) return {};
636
905
  if (typeof input === 'object' && !Array.isArray(input)) return input;
@@ -1144,6 +1413,7 @@ function captureFeedback(params) {
1144
1413
  structuredRule: structuredRule || null,
1145
1414
  ...(reflection && { reflection }),
1146
1415
  gateAction: params.gateAction || null,
1416
+ sourceEvent: publicFeedbackSourceMetadata(params.sourceEvent),
1147
1417
  timestamp: now,
1148
1418
  };
1149
1419
 
@@ -1567,6 +1837,24 @@ function captureFeedback(params) {
1567
1837
  return result;
1568
1838
  }
1569
1839
 
1840
+ function captureFeedbackIdempotent(params = {}) {
1841
+ const paths = getFeedbackPaths();
1842
+ const identity = normalizeFeedbackSourceIdentity(params.sourceEvent, params);
1843
+ if (!identity) return captureFeedback(params);
1844
+
1845
+ const claim = acquireFeedbackEventClaim(identity, paths);
1846
+ if (!claim.owned) return claim.result;
1847
+
1848
+ try {
1849
+ const result = captureFeedback({ ...params, sourceEvent: identity });
1850
+ finalizeFeedbackEventClaim(claim, result);
1851
+ return result;
1852
+ } catch (error) {
1853
+ releaseFeedbackEventClaim(claim);
1854
+ throw error;
1855
+ }
1856
+ }
1857
+
1570
1858
  function analyzeFeedback(logPath) {
1571
1859
  const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
1572
1860
  const resolvedLogPath = logPath || FEEDBACK_LOG_PATH;
@@ -2200,7 +2488,8 @@ function buildCorrectiveActionsReminder(correctiveActions = []) {
2200
2488
  }
2201
2489
 
2202
2490
  module.exports = {
2203
- captureFeedback,
2491
+ captureFeedback: captureFeedbackIdempotent,
2492
+ buildFeedbackSourceIdentity,
2204
2493
  compactMemories,
2205
2494
  buildCorrectiveActionsReminder,
2206
2495
  analyzeFeedback,
@@ -100,49 +100,48 @@ function isNearDownToken(token) {
100
100
 
101
101
  function detectFeedbackSignal(value) {
102
102
  const raw = String(value || '');
103
- if (/[👎👎🏻👎🏼👎🏽👎🏾👎🏿]/u.test(raw)) return { signal: 'down', confidence: 'emoji', match: '👎' };
104
- if (/[👍👍🏻👍🏼👍🏽👍🏾👍🏿]/u.test(raw)) return { signal: 'up', confidence: 'emoji', match: '👍' };
103
+ const leading = raw.trimStart();
104
+ if (/^["'`]/.test(leading)) return null;
105
+ if (/^👎(?:🏻|🏼|🏽|🏾|🏿)?/u.test(leading)) return { signal: 'down', confidence: 'emoji', match: '👎' };
106
+ if (/^👍(?:🏻|🏼|🏽|🏾|🏿)?/u.test(leading)) return { signal: 'up', confidence: 'emoji', match: '👍' };
105
107
 
106
108
  const normalized = normalizeFeedbackText(raw);
107
109
  if (!normalized) return null;
108
110
 
109
111
  const exactDown = [
110
- /\bthumbs?\s*down\b/,
111
- /\bthumbs?down\b/,
112
- /\bthat failed\b/,
113
- /\bit failed\b/,
114
- /\bthat was wrong\b/,
115
- /\bfix this\b/,
112
+ /^thumbs?\s*down\b/,
113
+ /^thumbs?down\b/,
114
+ /^that failed\b/,
115
+ /^it failed\b/,
116
+ /^that was wrong\b/,
116
117
  ];
117
118
  if (exactDown.some((pattern) => pattern.test(normalized))) {
118
119
  return { signal: 'down', confidence: 'exact', match: normalized };
119
120
  }
120
121
 
121
122
  const exactUp = [
122
- /\bthumbs?\s*up\b/,
123
- /\bthumbs?up\b/,
124
- /\bthat worked\b/,
125
- /\bit worked\b/,
126
- /\blooks good\b/,
127
- /\bgood job\b/,
128
- /\bgood work\b/,
129
- /\bnice work\b/,
130
- /\bperfect\b/,
131
- /\blgtm\b/,
123
+ /^thumbs?\s*up\b/,
124
+ /^thumbs?up\b/,
125
+ /^that worked\b/,
126
+ /^it worked\b/,
127
+ /^looks good\b/,
128
+ /^good job\b/,
129
+ /^good work\b/,
130
+ /^nice work\b/,
131
+ /^perfect\b/,
132
+ /^lgtm\b/,
132
133
  ];
133
134
  if (exactUp.some((pattern) => pattern.test(normalized))) {
134
135
  return { signal: 'up', confidence: 'exact', match: normalized };
135
136
  }
136
137
 
137
138
  const words = normalized.split(/\s+/).filter(Boolean);
138
- for (let i = 0; i < words.length - 1; i++) {
139
- if (!isNearThumbToken(words[i])) continue;
140
- if (isNearDownToken(words[i + 1])) {
141
- return { signal: 'down', confidence: 'fuzzy', match: `${words[i]} ${words[i + 1]}` };
142
- }
143
- if (isNearUpToken(words[i + 1])) {
144
- return { signal: 'up', confidence: 'fuzzy', match: `${words[i]} ${words[i + 1]}` };
145
- }
139
+ if (!isNearThumbToken(words[0])) return null;
140
+ if (isNearDownToken(words[1])) {
141
+ return { signal: 'down', confidence: 'fuzzy', match: `${words[0]} ${words[1]}` };
142
+ }
143
+ if (isNearUpToken(words[1])) {
144
+ return { signal: 'up', confidence: 'fuzzy', match: `${words[0]} ${words[1]}` };
146
145
  }
147
146
 
148
147
  return null;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('crypto');
3
+ const crypto = require('node:crypto');
4
4
 
5
5
  const TRANSPORT_KEYS = new Set([
6
6
  'hookeventname',
@@ -52,10 +52,136 @@ const TRANSPORT_WORDS = new Set([
52
52
  'json',
53
53
  ]);
54
54
 
55
+ // Transport keys that distinguish hook/session envelopes from ordinary JSON
56
+ // tool inputs. A JSON object is rejected only when it carries one of these
57
+ // keys; valid inputs such as {"filePath":"AGENTS.md"} must remain searchable.
58
+ const REJECT_SUBSTRINGS = [
59
+ 'session_id',
60
+ 'transcript_path',
61
+ 'prompt_id',
62
+ 'hook_event_name',
63
+ ];
64
+ const REJECT_JSON_KEYS = new Set([
65
+ ...REJECT_SUBSTRINGS,
66
+ 'sessionid',
67
+ 'transcriptpath',
68
+ 'promptid',
69
+ 'hookeventname',
70
+ ]);
71
+
72
+ // A token is treated as a filesystem-path fragment when it contains a path
73
+ // separator, ends in a machine-file extension, or is a bare UUID.
74
+ function isPathToken(token) {
75
+ if (!token) return false;
76
+ return (
77
+ token.includes('/') ||
78
+ token.includes('\\') ||
79
+ /\.(?:jsonl|json|log|sqlite|db)$/i.test(token) ||
80
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)
81
+ );
82
+ }
83
+
84
+ function parseJsonValue(text) {
85
+ try {
86
+ return { ok: true, value: JSON.parse(text) };
87
+ } catch (error) {
88
+ if (!(error instanceof SyntaxError)) throw error;
89
+ return { ok: false, value: null };
90
+ }
91
+ }
92
+
93
+ function hasTransportJsonKey(text) {
94
+ const trimmed = String(text || '').trim();
95
+ if (!/^[[{]/.test(trimmed)) return false;
96
+ const parsedResult = parseJsonValue(trimmed);
97
+ if (!parsedResult.ok) return false;
98
+ const parsed = parsedResult.value;
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
100
+ const keys = new Set(Object.keys(parsed).map((key) => key.toLowerCase()));
101
+ return [...keys].some((key) => REJECT_JSON_KEYS.has(key));
102
+ }
103
+
104
+ // Positive rejection guard. Returns true when `text` is a transport/metadata
105
+ // blob that must never be stored as a lesson, because it:
106
+ // (a) is a JSON object carrying a hook/session transport key, OR
107
+ // (b) contains multiple known transport marker substrings, OR
108
+ // (c) is dominated by filesystem-path tokens.
109
+ function looksLikeTransportBlob(text) {
110
+ const trimmed = String(text || '').trim();
111
+ if (!trimmed) return false;
112
+
113
+ if (hasTransportJsonKey(trimmed)) return true;
114
+
115
+ // Multiple marker names identify non-JSON transport residue. A human
116
+ // sentence that merely discusses `session_id` is not itself an envelope.
117
+ const lower = trimmed.toLowerCase();
118
+ const markerCount = REJECT_SUBSTRINGS.filter((marker) => lower.includes(marker)).length;
119
+ if (markerCount >= 2) return true;
120
+
121
+ // (b) Dominated by filesystem-path tokens.
122
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
123
+ if (tokens.length > 0) {
124
+ const pathTokens = tokens.filter(isPathToken);
125
+ if (pathTokens.length > 0 && pathTokens.length / tokens.length > 0.5) {
126
+ return true;
127
+ }
128
+ }
129
+
130
+ return false;
131
+ }
132
+
133
+ function firstStringField(record, names) {
134
+ for (const name of names) {
135
+ if (typeof record[name] === 'string' && record[name].trim()) return record[name].trim();
136
+ if (typeof record[name] === 'number' && Number.isFinite(record[name])) return String(record[name]);
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Extract the human prompt plus the minimum source metadata needed to identify
143
+ * one hook event. Transcript paths and raw identifiers never enter lesson text;
144
+ * callers hash the source fields before persistence.
145
+ */
146
+ function extractPromptEnvelope(rawStdin) {
147
+ const raw = typeof rawStdin === 'string' ? rawStdin : '';
148
+ const trimmed = raw.trim();
149
+ if (!trimmed) return { prompt: '' };
150
+
151
+ if (/^[[{]/.test(trimmed)) {
152
+ const parsedResult = parseJsonValue(trimmed);
153
+ if (!parsedResult.ok) return { prompt: trimmed };
154
+ const parsed = parsedResult.value;
155
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
156
+ return {
157
+ prompt: typeof parsed.prompt === 'string' ? parsed.prompt.trim() : '',
158
+ sessionId: firstStringField(parsed, ['session_id', 'sessionId']),
159
+ promptId: firstStringField(parsed, ['prompt_id', 'promptId']),
160
+ projectDir: firstStringField(parsed, ['cwd', 'project', 'project_dir', 'projectDir']),
161
+ timestamp: firstStringField(parsed, ['timestamp', 'created_at', 'createdAt']),
162
+ hookEventName: firstStringField(parsed, ['hook_event_name', 'hookEventName']),
163
+ };
164
+ }
165
+ return { prompt: '' };
166
+ }
167
+
168
+ return { prompt: trimmed };
169
+ }
170
+
171
+ // Persist ONLY the human `.prompt` field from a hook transport envelope.
172
+ function extractPromptText(rawStdin) {
173
+ return extractPromptEnvelope(rawStdin).prompt;
174
+ }
175
+
55
176
  function stripEphemeralText(text) {
56
177
  if (!text || typeof text !== 'string') return '';
57
- return String(text)
58
- .replace(/["']?(?:hook_?event_?name|session_?id|transcript_?path|timestamp|created_?at|updated_?at|cwd|pid|process_?id|prompt_?id|trace_?id|request_?id|install_?id|visitor_?session_?id)["']?\s*[:=]\s*["']?[^"',}\]\s]+["']?/gi, ' ')
178
+ let stripped = String(text);
179
+ for (const key of TRANSPORT_KEYS) {
180
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
181
+ const assignment = new RegExp(String.raw`["']?${escapedKey}["']?\s*[:=]\s*["']?[^"',}\]\s]+["']?`, 'gi');
182
+ stripped = stripped.replace(assignment, ' ');
183
+ }
184
+ return stripped
59
185
  .replace(/\/(?:private\/)?tmp\/[^\s"',}\]]+/gi, ' ')
60
186
  .replace(/\/var\/folders\/[^\s"',}\]]+/gi, ' ')
61
187
  .replace(/\/Users\/[^/\s]+\/\.(?:claude|codex|thumbgate)\/[^\s"',}\]]+/gi, ' ')
@@ -68,6 +194,10 @@ function stripEphemeralText(text) {
68
194
  }
69
195
 
70
196
  function transportWordsOnly(text) {
197
+ // Positive rejection first: JSON payloads, transport markers, and
198
+ // path-dominated blobs are always "transport only" regardless of the
199
+ // incidental non-transport words they contain.
200
+ if (looksLikeTransportBlob(text)) return true;
71
201
  const tokens = String(text || '')
72
202
  .toLowerCase()
73
203
  .replace(/[^a-z0-9_ -]/g, ' ')
@@ -78,6 +208,20 @@ function transportWordsOnly(text) {
78
208
  }
79
209
 
80
210
  function sanitizeFeedbackText(text) {
211
+ const raw = String(text || '').trim();
212
+ // A raw hook-stdin PAYLOAD — a pure JSON object carrying transport markers
213
+ // (session_id / transcript_path / prompt_id / hook_event_name) — is never a
214
+ // human lesson; reject it whole, even when it also carries a `prompt` field
215
+ // (that field is pulled out separately via extractPromptText). But do NOT
216
+ // reject content JSON that carries no transport markers (e.g. a tool input
217
+ // {"filePath":"…","reason":"…"}): it must survive so it can be matched against
218
+ // patterns. The discriminator is the marker, not JSON-ness.
219
+ if (hasTransportJsonKey(raw)) return '';
220
+ // Otherwise scrub ephemeral marker key:value pairs and volatile paths, then
221
+ // reject only if nothing substantive remains — so real feedback that arrives
222
+ // next to hook metadata keeps the sentence while the metadata is stripped, and
223
+ // space-separated marker residue / path-dominated blobs collapse to
224
+ // transport-only text and are dropped by transportWordsOnly().
81
225
  const stripped = stripEphemeralText(text)
82
226
  .replace(/\/Users\/[^\s/]+/g, '/Users/redacted')
83
227
  .replace(/\s+/g, ' ')
@@ -99,7 +243,11 @@ function actionFingerprint(parts) {
99
243
 
100
244
  module.exports = {
101
245
  TRANSPORT_WORDS,
246
+ REJECT_SUBSTRINGS,
102
247
  sanitizeFeedbackText,
103
248
  actionFingerprint,
104
249
  transportWordsOnly,
250
+ looksLikeTransportBlob,
251
+ extractPromptEnvelope,
252
+ extractPromptText,
105
253
  };