thumbgate 1.26.8 → 1.27.3
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/plugin.json +1 -1
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +2 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +44 -31
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/mcp/server-stdio.js +41 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +184 -8
- package/bin/dashboard-cli.js +7 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +60 -0
- package/config/mcp-allowlists.json +8 -7
- package/config/model-candidates.json +71 -6
- package/package.json +28 -12
- package/public/about.html +162 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-plugin.html +66 -14
- package/public/compare.html +2 -2
- package/public/dashboard.html +224 -36
- package/public/guide.html +2 -2
- package/public/index.html +122 -40
- package/public/learn.html +70 -0
- package/public/lessons.html +129 -6
- package/public/numbers.html +2 -2
- package/public/pricing.html +28 -23
- package/public/pro.html +3 -3
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-schema.js +26 -0
- package/scripts/commercial-offer.js +10 -2
- package/scripts/dashboard-chat.js +199 -51
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +301 -67
- package/scripts/hybrid-feedback-context.js +141 -7
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plausible-domain-config.js +86 -0
- package/scripts/plausible-server-events.js +4 -2
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +2 -2
- package/scripts/repeat-metric.js +28 -12
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +80 -0
- package/scripts/seo-gsd.js +113 -0
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/tool-registry.js +37 -0
- package/scripts/workflow-sentinel.js +282 -54
- package/src/api/server.js +466 -60
- package/.claude-plugin/marketplace.json +0 -85
|
@@ -2,53 +2,138 @@
|
|
|
2
2
|
|
|
3
3
|
// scripts/dashboard-chat.js
|
|
4
4
|
// -----------------------------------------------------------------------------
|
|
5
|
-
// "Chat with your data" — the dashboard chat backend.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// "Chat with your data" — the dashboard chat backend. Local-first RAG over
|
|
6
|
+
// this install's ThumbGate data (lessons, raw feedback memories via LanceDB
|
|
7
|
+
// vectors, receipts, gate stats). Retrieval is local (lesson search + optional
|
|
8
|
+
// vector-store.searchSimilar). Generation uses your configured LLM: a local
|
|
9
|
+
// OpenAI-compatible endpoint first, then Gemini or Perplexity when explicitly
|
|
10
|
+
// configured.
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// customer connects their own DFCX agent + the ThumbGate webhook gate.)
|
|
12
|
+
// Dialogflow/Google is not the dashboard chatbot brain. It remains an optional
|
|
13
|
+
// guard-adapter path for buyers who already run their own Google agent tenancy.
|
|
14
14
|
// -----------------------------------------------------------------------------
|
|
15
15
|
|
|
16
16
|
const path = require('path');
|
|
17
17
|
|
|
18
18
|
const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
19
|
+
const PERPLEXITY_ENDPOINT = 'https://api.perplexity.ai/chat/completions';
|
|
19
20
|
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
|
20
21
|
const MAX_QUESTION_CHARS = 2000;
|
|
21
22
|
const MAX_CONTEXT_LESSONS = 8;
|
|
22
23
|
|
|
24
|
+
// Allowlist the model so a user-supplied `model` cannot route the call to an
|
|
25
|
+
// arbitrary / unexpected (or more expensive) endpoint. Anything not on the list
|
|
26
|
+
// falls back to the default.
|
|
27
|
+
const ALLOWED_MODELS = new Set([
|
|
28
|
+
'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-2.5-pro',
|
|
29
|
+
'gemini-2.0-flash', 'gemini-2.0-flash-lite',
|
|
30
|
+
'gemini-flash-latest', 'gemini-flash-lite-latest', 'gemini-pro-latest',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function resolveModel(requested) {
|
|
34
|
+
const r = String(requested || '').trim();
|
|
35
|
+
if (r && ALLOWED_MODELS.has(r)) return r;
|
|
36
|
+
const envModel = String(process.env.THUMBGATE_GEMINI_MODEL || '').trim();
|
|
37
|
+
if (envModel && ALLOWED_MODELS.has(envModel)) return envModel;
|
|
38
|
+
return DEFAULT_MODEL;
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
function resolveApiKey(opts = {}) {
|
|
24
|
-
|
|
42
|
+
let key = '';
|
|
43
|
+
if (Object.hasOwn(opts, 'apiKey')) {
|
|
44
|
+
key = opts.apiKey || '';
|
|
45
|
+
} else {
|
|
46
|
+
key = opts.apiKey || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || '';
|
|
47
|
+
}
|
|
48
|
+
if (!key) return '';
|
|
49
|
+
return key.trim().replace(/^["']|["']$/g, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function debugChatFallback(label, err) {
|
|
53
|
+
if (process.env.THUMBGATE_DEBUG_CHAT !== '1') return;
|
|
54
|
+
const detail = err?.message ? err.message : String(err);
|
|
55
|
+
console.warn(`[dashboard-chat] ${label}: ${detail}`);
|
|
25
56
|
}
|
|
26
57
|
|
|
27
|
-
|
|
28
|
-
function retrieveContext(question, opts = {}) {
|
|
29
|
-
let searchLessons;
|
|
58
|
+
function loadLessonSearcher() {
|
|
30
59
|
try {
|
|
31
|
-
|
|
32
|
-
} catch (
|
|
33
|
-
|
|
60
|
+
return require(path.join(__dirname, 'lesson-search')).searchLessons;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
debugChatFallback('lesson search unavailable', err);
|
|
63
|
+
return null;
|
|
34
64
|
}
|
|
35
|
-
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function lessonToContextItem(lesson) {
|
|
68
|
+
return {
|
|
69
|
+
id: lesson.id,
|
|
70
|
+
signal: lesson.signal || lesson.feedback || '',
|
|
71
|
+
title: (lesson.title || '').replace(/^(?:MISTAKE|SUCCESS):\s*/i, '').slice(0, 160),
|
|
72
|
+
content: String(lesson.content || lesson.context || '').replace(/\s+/g, ' ').trim().slice(0, 600),
|
|
73
|
+
tags: lesson.tags || [],
|
|
74
|
+
source: 'lessons',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function vectorMatchToContextItem(match, index) {
|
|
79
|
+
return {
|
|
80
|
+
id: match.id || `vec-${index}`,
|
|
81
|
+
signal: match.signal || '',
|
|
82
|
+
title: String(match.context || match.text || '').slice(0, 100),
|
|
83
|
+
content: match.text || match.context || '',
|
|
84
|
+
tags: match.tags ? String(match.tags).split(',').filter(Boolean) : [],
|
|
85
|
+
source: 'lancedb-vector',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function dedupeContextItems(items, limit = MAX_CONTEXT_LESSONS + 3) {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
return items.filter((item) => {
|
|
92
|
+
if (!(item.content || item.title)) return false;
|
|
93
|
+
const key = item.id || item.content.slice(0, 80);
|
|
94
|
+
if (seen.has(key)) return false;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
return true;
|
|
97
|
+
}).slice(0, limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function retrieveLessonContext(question, opts = {}) {
|
|
101
|
+
const searchLessons = loadLessonSearcher();
|
|
102
|
+
if (!searchLessons) return [];
|
|
36
103
|
try {
|
|
37
|
-
res = searchLessons(String(question || ''), {
|
|
104
|
+
const res = searchLessons(String(question || ''), {
|
|
38
105
|
limit: MAX_CONTEXT_LESSONS,
|
|
39
106
|
feedbackDir: opts.feedbackDir,
|
|
40
107
|
});
|
|
41
|
-
|
|
108
|
+
const rows = res?.results || res?.lessons || [];
|
|
109
|
+
return rows.slice(0, MAX_CONTEXT_LESSONS).map(lessonToContextItem);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
debugChatFallback('lesson retrieval failed', err);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function retrieveVectorContext(question, opts = {}) {
|
|
117
|
+
if (opts.useVectorSearch === false) return [];
|
|
118
|
+
try {
|
|
119
|
+
const vectorStore = require(path.join(__dirname, 'vector-store'));
|
|
120
|
+
const vecResults = vectorStore.searchSimilar
|
|
121
|
+
? await vectorStore.searchSimilar(String(question || ''), opts.vectorLimit || 4)
|
|
122
|
+
: [];
|
|
123
|
+
return vecResults
|
|
124
|
+
.filter((match) => match?.text)
|
|
125
|
+
.map(vectorMatchToContextItem);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
debugChatFallback('vector retrieval failed', err);
|
|
42
128
|
return [];
|
|
43
129
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})).filter((l) => l.content || l.title);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Retrieve relevant stored lessons and optional raw feedback vector matches.
|
|
133
|
+
async function retrieveContext(question, opts = {}) {
|
|
134
|
+
const lessons = retrieveLessonContext(question, opts);
|
|
135
|
+
const vectors = await retrieveVectorContext(question, opts);
|
|
136
|
+
return dedupeContextItems([...lessons, ...vectors]);
|
|
52
137
|
}
|
|
53
138
|
|
|
54
139
|
// Build a grounded RAG prompt. Pure function (testable).
|
|
@@ -72,15 +157,87 @@ function buildChatPrompt(question, lessons) {
|
|
|
72
157
|
|
|
73
158
|
// Parse the Gemini generateContent response into plain text. Pure (testable).
|
|
74
159
|
function parseGeminiAnswer(body) {
|
|
75
|
-
const parts = body
|
|
76
|
-
&& body.candidates
|
|
77
|
-
&& body.candidates[0]
|
|
78
|
-
&& body.candidates[0].content
|
|
79
|
-
&& body.candidates[0].content.parts;
|
|
160
|
+
const parts = body?.candidates?.[0]?.content?.parts;
|
|
80
161
|
if (!Array.isArray(parts)) return '';
|
|
81
162
|
return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join('').trim();
|
|
82
163
|
}
|
|
83
164
|
|
|
165
|
+
function buildOpenAiChatPayload(prompt, model) {
|
|
166
|
+
return JSON.stringify({
|
|
167
|
+
model,
|
|
168
|
+
messages: [{ role: 'user', content: prompt }],
|
|
169
|
+
temperature: 0.2,
|
|
170
|
+
max_tokens: 1024,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseOpenAiChatAnswer(json) {
|
|
175
|
+
return json?.choices?.[0]?.message?.content || '';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseModelError(json, status) {
|
|
179
|
+
return json?.error?.message ? String(json.error.message).split('\n')[0] : `HTTP ${status}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function trimTrailingSlashes(value) {
|
|
183
|
+
let text = String(value || '');
|
|
184
|
+
while (text.endsWith('/')) {
|
|
185
|
+
text = text.slice(0, -1);
|
|
186
|
+
}
|
|
187
|
+
return text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function callLocalOpenAiEndpoint({ endpoint, apiKey, model, prompt, fetchImpl, sources }) {
|
|
191
|
+
const url = endpoint.includes('/chat/completions')
|
|
192
|
+
? endpoint
|
|
193
|
+
: `${trimTrailingSlashes(endpoint)}/chat/completions`;
|
|
194
|
+
const res = await fetchImpl(url, {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: {
|
|
197
|
+
'content-type': 'application/json',
|
|
198
|
+
'Authorization': `Bearer ${apiKey || 'local'}`
|
|
199
|
+
},
|
|
200
|
+
body: buildOpenAiChatPayload(prompt, model),
|
|
201
|
+
});
|
|
202
|
+
const json = await res.json().catch(() => ({}));
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
return { ok: false, error: 'local_llm_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
205
|
+
}
|
|
206
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
207
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || model };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources }) {
|
|
211
|
+
const res = await fetchImpl(PERPLEXITY_ENDPOINT, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
214
|
+
body: buildOpenAiChatPayload(prompt, 'sonar'),
|
|
215
|
+
});
|
|
216
|
+
const json = await res.json().catch(() => ({}));
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
return { ok: false, error: 'perplexity_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
219
|
+
}
|
|
220
|
+
const answer = parseOpenAiChatAnswer(json);
|
|
221
|
+
return { ok: true, answer: answer.trim() || '(no answer returned)', sources, model: json.model || 'perplexity-hybrid' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources }) {
|
|
225
|
+
const res = await fetchImpl(`${GEMINI_ENDPOINT}/${encodeURIComponent(model)}:generateContent`, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey },
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
230
|
+
generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
const json = await res.json().catch(() => ({}));
|
|
234
|
+
if (!res.ok) {
|
|
235
|
+
return { ok: false, error: 'gemini_error', status: res.status, message: parseModelError(json, res.status), sources };
|
|
236
|
+
}
|
|
237
|
+
const answer = parseGeminiAnswer(json);
|
|
238
|
+
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
239
|
+
}
|
|
240
|
+
|
|
84
241
|
// Answer a question grounded in this install's lessons. Returns
|
|
85
242
|
// { ok, answer, sources, model } or { ok:false, error, ... }.
|
|
86
243
|
async function answerDataQuestion(question, opts = {}) {
|
|
@@ -90,41 +247,32 @@ async function answerDataQuestion(question, opts = {}) {
|
|
|
90
247
|
return { ok: false, error: 'question_too_long', message: `Question exceeds ${MAX_QUESTION_CHARS} characters.` };
|
|
91
248
|
}
|
|
92
249
|
|
|
250
|
+
const localEndpoint = opts.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '';
|
|
251
|
+
const localModel = opts.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || 'llama3';
|
|
93
252
|
const apiKey = resolveApiKey(opts);
|
|
94
|
-
const lessons = retrieveContext(q, opts);
|
|
253
|
+
const lessons = await retrieveContext(q, opts);
|
|
95
254
|
const sources = lessons.map((l) => ({ id: l.id, title: l.title, signal: l.signal }));
|
|
96
255
|
|
|
97
|
-
if (!apiKey) {
|
|
256
|
+
if (!apiKey && !localEndpoint) {
|
|
98
257
|
return {
|
|
99
258
|
ok: false,
|
|
100
259
|
error: 'no_api_key',
|
|
101
|
-
message: 'Chat is not configured. Set
|
|
260
|
+
message: 'Chat is not configured. Set a valid GEMINI_API_KEY, PERPLEXITY_API_KEY, or THUMBGATE_LOCAL_LLM_ENDPOINT in the project .env.',
|
|
102
261
|
sources,
|
|
103
262
|
};
|
|
104
263
|
}
|
|
105
264
|
|
|
106
|
-
const model = opts.model
|
|
265
|
+
const model = resolveModel(opts.model);
|
|
107
266
|
const prompt = buildChatPrompt(q, lessons);
|
|
108
267
|
const fetchImpl = opts.fetch || globalThis.fetch;
|
|
268
|
+
const isPerplexity = apiKey && (apiKey.startsWith('pplx-') || apiKey.includes('perplexity'));
|
|
109
269
|
|
|
110
270
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
body: JSON.stringify({
|
|
115
|
-
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
116
|
-
generationConfig: { temperature: 0.2, maxOutputTokens: 1024 },
|
|
117
|
-
}),
|
|
118
|
-
});
|
|
119
|
-
const json = await res.json().catch(() => ({}));
|
|
120
|
-
if (!res.ok) {
|
|
121
|
-
const msg = (json && json.error && json.error.message) ? String(json.error.message).split('\n')[0] : `HTTP ${res.status}`;
|
|
122
|
-
return { ok: false, error: 'gemini_error', status: res.status, message: msg, sources };
|
|
123
|
-
}
|
|
124
|
-
const answer = parseGeminiAnswer(json);
|
|
125
|
-
return { ok: true, answer: answer || '(no answer returned)', sources, model: json.modelVersion || model };
|
|
271
|
+
if (localEndpoint) return await callLocalOpenAiEndpoint({ endpoint: localEndpoint, apiKey, model: localModel, prompt, fetchImpl, sources });
|
|
272
|
+
if (isPerplexity) return await callPerplexityEndpoint({ apiKey, prompt, fetchImpl, sources });
|
|
273
|
+
return await callGeminiEndpoint({ apiKey, model, prompt, fetchImpl, sources });
|
|
126
274
|
} catch (err) {
|
|
127
|
-
return { ok: false, error: 'network', message: err
|
|
275
|
+
return { ok: false, error: 'network', message: err?.message || String(err), sources };
|
|
128
276
|
}
|
|
129
277
|
}
|
|
130
278
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const TRANSPORT_KEYS = new Set([
|
|
6
|
+
'hookeventname',
|
|
7
|
+
'hook_event_name',
|
|
8
|
+
'sessionid',
|
|
9
|
+
'session_id',
|
|
10
|
+
'transcriptpath',
|
|
11
|
+
'transcript_path',
|
|
12
|
+
'timestamp',
|
|
13
|
+
'createdat',
|
|
14
|
+
'created_at',
|
|
15
|
+
'updatedat',
|
|
16
|
+
'updated_at',
|
|
17
|
+
'cwd',
|
|
18
|
+
'pid',
|
|
19
|
+
'processid',
|
|
20
|
+
'process_id',
|
|
21
|
+
'promptid',
|
|
22
|
+
'prompt_id',
|
|
23
|
+
'traceid',
|
|
24
|
+
'trace_id',
|
|
25
|
+
'requestid',
|
|
26
|
+
'request_id',
|
|
27
|
+
'installid',
|
|
28
|
+
'install_id',
|
|
29
|
+
'visitorsessionid',
|
|
30
|
+
'visitor_session_id',
|
|
31
|
+
'toolinput',
|
|
32
|
+
'tool_input',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const TRANSPORT_WORDS = new Set([
|
|
36
|
+
...TRANSPORT_KEYS,
|
|
37
|
+
'hook',
|
|
38
|
+
'event',
|
|
39
|
+
'userpromptsubmit',
|
|
40
|
+
'user_prompt_submit',
|
|
41
|
+
'pretooluse',
|
|
42
|
+
'pre_tool_use',
|
|
43
|
+
'posttooluse',
|
|
44
|
+
'post_tool_use',
|
|
45
|
+
'claude',
|
|
46
|
+
'codex',
|
|
47
|
+
'projects',
|
|
48
|
+
'redacted',
|
|
49
|
+
'tmp',
|
|
50
|
+
'private',
|
|
51
|
+
'folders',
|
|
52
|
+
'json',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function stripEphemeralText(text) {
|
|
56
|
+
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, ' ')
|
|
59
|
+
.replace(/\/(?:private\/)?tmp\/[^\s"',}\]]+/gi, ' ')
|
|
60
|
+
.replace(/\/var\/folders\/[^\s"',}\]]+/gi, ' ')
|
|
61
|
+
.replace(/\/Users\/[^/\s]+\/\.(?:claude|codex|thumbgate)\/[^\s"',}\]]+/gi, ' ')
|
|
62
|
+
.replace(/\/Users\/[^/\s]+\/\.config\/thumbgate\/[^\s"',}\]]+/gi, ' ')
|
|
63
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ' ')
|
|
64
|
+
.replace(/\b[0-9a-f]{24,}\b/gi, ' ')
|
|
65
|
+
.replace(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z?\b/gi, ' ')
|
|
66
|
+
.replace(/\b\d{10,13}\b/g, ' ')
|
|
67
|
+
.replace(/:\d{4,5}\b/g, ':PORT');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function transportWordsOnly(text) {
|
|
71
|
+
const tokens = String(text || '')
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9_ -]/g, ' ')
|
|
74
|
+
.split(/\s+/)
|
|
75
|
+
.filter((token) => token.length >= 3);
|
|
76
|
+
if (tokens.length === 0) return true;
|
|
77
|
+
return tokens.every((token) => TRANSPORT_WORDS.has(token));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sanitizeFeedbackText(text) {
|
|
81
|
+
const stripped = stripEphemeralText(text)
|
|
82
|
+
.replace(/\/Users\/[^\s/]+/g, '/Users/redacted')
|
|
83
|
+
.replace(/\s+/g, ' ')
|
|
84
|
+
.trim();
|
|
85
|
+
if (transportWordsOnly(stripped)) return '';
|
|
86
|
+
return stripped;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function actionFingerprint(parts) {
|
|
90
|
+
const raw = Array.isArray(parts) ? parts.join(' ') : String(parts || '');
|
|
91
|
+
const stable = sanitizeFeedbackText(raw)
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9._:/ -]/g, ' ')
|
|
94
|
+
.replace(/\s+/g, ' ')
|
|
95
|
+
.trim();
|
|
96
|
+
if (!stable || transportWordsOnly(stable)) return null;
|
|
97
|
+
return crypto.createHash('sha256').update(stable).digest('hex').slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
TRANSPORT_WORDS,
|
|
102
|
+
sanitizeFeedbackText,
|
|
103
|
+
actionFingerprint,
|
|
104
|
+
transportWordsOnly,
|
|
105
|
+
};
|