thumbgate 0.9.12 → 0.9.14

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/src/api/server.js CHANGED
@@ -13,6 +13,10 @@ const {
13
13
  getFeedbackPaths,
14
14
  appendDiagnosticRecord,
15
15
  } = require('../../scripts/feedback-loop');
16
+ const {
17
+ readActiveProjectState,
18
+ resolveProjectDir,
19
+ } = require('../../scripts/feedback-paths');
16
20
  const {
17
21
  readRecentConversationWindow,
18
22
  } = require('../../scripts/feedback-history-distiller');
@@ -203,8 +207,55 @@ function computeConversionStats(events) {
203
207
  }
204
208
  // ---------------------------------------------------------------------------
205
209
 
206
- function getSafeDataDir() {
207
- const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
210
+ function getRequestedProjectSelection(req, parsed) {
211
+ const projectFromQuery = parsed && parsed.searchParams
212
+ ? parsed.searchParams.get('project')
213
+ : null;
214
+ const projectFromHeaders = req && req.headers
215
+ ? req.headers['x-thumbgate-project-dir'] || req.headers['x-thumbgate-project']
216
+ : null;
217
+ return projectFromQuery || projectFromHeaders || null;
218
+ }
219
+
220
+ function getEffectiveRequestedProjectSelection(req, parsed) {
221
+ return isProjectSelectionAllowed(req, parsed)
222
+ ? getRequestedProjectSelection(req, parsed)
223
+ : null;
224
+ }
225
+
226
+ function isProjectSelectionAllowed(req, parsed) {
227
+ const explicitProject = getRequestedProjectSelection(req, parsed);
228
+ if (!explicitProject) return true;
229
+ return isLoopbackHost(getRequestHostHeader(req));
230
+ }
231
+
232
+ function resolveRequestProjectDir(req, parsed) {
233
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
234
+ return resolveProjectDir({
235
+ projectDir: explicitProject,
236
+ env: process.env,
237
+ });
238
+ }
239
+
240
+ function shouldPreferProjectScopedFeedback(req, parsed) {
241
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
242
+ if (explicitProject) return true;
243
+ if (process.env.THUMBGATE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR) return true;
244
+ if (process.env.THUMBGATE_FEEDBACK_DIR) return false;
245
+ return Boolean(readActiveProjectState({ env: process.env }));
246
+ }
247
+
248
+ function getRequestFeedbackPaths(req, parsed) {
249
+ const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
250
+ return getFeedbackPaths({
251
+ projectDir: resolveRequestProjectDir(req, parsed),
252
+ explicitProjectDir: explicitProject,
253
+ skipExplicitFeedbackDir: shouldPreferProjectScopedFeedback(req, parsed),
254
+ });
255
+ }
256
+
257
+ function getSafeDataDir(req, parsed) {
258
+ const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
208
259
  return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
209
260
  }
210
261
 
@@ -857,6 +908,14 @@ function getPublicOrigin(req) {
857
908
  return `${proto}://${host}`;
858
909
  }
859
910
 
911
+ function getRequestHostHeader(req) {
912
+ const forwardedHost = req.headers['x-forwarded-host'];
913
+ if (Array.isArray(forwardedHost)) {
914
+ return forwardedHost[0] || req.headers.host || '';
915
+ }
916
+ return forwardedHost || req.headers.host || '';
917
+ }
918
+
860
919
  function isLoopbackHost(hostValue) {
861
920
  const rawHost = String(hostValue || '').split(',')[0].trim();
862
921
  if (!rawHost) {
@@ -2094,10 +2153,10 @@ function extractTags(input) {
2094
2153
  return [];
2095
2154
  }
2096
2155
 
2097
- function resolveSafePath(inputPath, { mustExist = false } = {}) {
2156
+ function resolveSafePath(inputPath, { mustExist = false, safeDataDir } = {}) {
2098
2157
  const allowExternal = process.env.THUMBGATE_ALLOW_EXTERNAL_PATHS === 'true';
2099
2158
  const resolved = path.resolve(String(inputPath || ''));
2100
- const SAFE_DATA_DIR = getSafeDataDir();
2159
+ const SAFE_DATA_DIR = safeDataDir || getSafeDataDir();
2101
2160
  const inSafeRoot = resolved === SAFE_DATA_DIR || resolved.startsWith(`${SAFE_DATA_DIR}${path.sep}`);
2102
2161
 
2103
2162
  if (!allowExternal && !inSafeRoot) {
@@ -2121,6 +2180,13 @@ function createApiServer() {
2121
2180
  const isGetLikeRequest = req.method === 'GET' || isHeadRequest;
2122
2181
  const publicOrigin = getPublicOrigin(req);
2123
2182
  const hostedConfig = resolveHostedBillingConfig({ requestOrigin: publicOrigin });
2183
+ if (!isProjectSelectionAllowed(req, parsed)) {
2184
+ sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
2185
+ return;
2186
+ }
2187
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
2188
+ const requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
2189
+ const requestSafeDataDir = getSafeDataDir(req, parsed);
2124
2190
 
2125
2191
  // Public MCP endpoint — responds to Smithery registry scanning and MCP initialize
2126
2192
  // The initialize handshake is unauthenticated; subsequent tool calls require Bearer auth
@@ -2394,7 +2460,7 @@ function createApiServer() {
2394
2460
  const signal = parsed.searchParams.get('signal');
2395
2461
  if (signal === 'up' || signal === 'down') {
2396
2462
  const chatHistory = readRecentConversationWindow({
2397
- feedbackDir: getSafeDataDir(),
2463
+ feedbackDir: requestSafeDataDir,
2398
2464
  limit: 10,
2399
2465
  });
2400
2466
  const result = captureFeedback({
@@ -2484,7 +2550,7 @@ async function addContext(){
2484
2550
  sendJson(res, 400, { error: 'relatedFeedbackId is required' });
2485
2551
  return;
2486
2552
  }
2487
- const feedbackDir = getSafeDataDir();
2553
+ const feedbackDir = requestSafeDataDir;
2488
2554
  const detailField = signal === 'down' ? 'whatWentWrong' : 'whatWorked';
2489
2555
  const updated = updateLessonRecord(feedbackDir, relatedFeedbackId, (existing) => {
2490
2556
  const nextTags = Array.from(new Set([
@@ -2536,7 +2602,7 @@ async function addContext(){
2536
2602
  const lessonUpdateMatch = pathname.match(/^\/lessons\/([^/]+)\/update$/);
2537
2603
  if (req.method === 'POST' && lessonUpdateMatch) {
2538
2604
  const lessonId = decodeURIComponent(lessonUpdateMatch[1]);
2539
- const feedbackDir = getSafeDataDir();
2605
+ const feedbackDir = requestSafeDataDir;
2540
2606
  const body = await parseJsonBody(req);
2541
2607
  const record = findRecordById(lessonId, feedbackDir);
2542
2608
  if (!record) {
@@ -2568,7 +2634,7 @@ async function addContext(){
2568
2634
  const lessonDeleteMatch = pathname.match(/^\/lessons\/([^/]+)\/delete$/);
2569
2635
  if (req.method === 'POST' && lessonDeleteMatch) {
2570
2636
  const lessonId = decodeURIComponent(lessonDeleteMatch[1]);
2571
- const feedbackDir = getSafeDataDir();
2637
+ const feedbackDir = requestSafeDataDir;
2572
2638
  const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
2573
2639
  const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
2574
2640
  const deletedMemory = deleteRecordFromJsonl(memoryLogPath, lessonId);
@@ -2585,7 +2651,7 @@ async function addContext(){
2585
2651
  const lessonDetailMatch = pathname.match(/^\/lessons\/([^/]+)$/);
2586
2652
  if (isGetLikeRequest && lessonDetailMatch && lessonDetailMatch[1] !== '') {
2587
2653
  const lessonId = decodeURIComponent(lessonDetailMatch[1]);
2588
- const feedbackDir = getSafeDataDir();
2654
+ const feedbackDir = requestSafeDataDir;
2589
2655
  const record = findRecordById(lessonId, feedbackDir);
2590
2656
  if (!record) {
2591
2657
  sendHtml(res, 404, renderLessonDetailHtml(null, lessonId));
@@ -2959,7 +3025,7 @@ async function addContext(){
2959
3025
  }
2960
3026
 
2961
3027
  if (isGetLikeRequest && pathname === '/healthz') {
2962
- const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = getFeedbackPaths();
3028
+ const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = requestFeedbackPaths;
2963
3029
  sendJson(res, 200, {
2964
3030
  status: 'ok',
2965
3031
  feedbackLogPath: FEEDBACK_LOG_PATH,
@@ -3497,7 +3563,7 @@ async function addContext(){
3497
3563
 
3498
3564
  try {
3499
3565
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
3500
- sendJson(res, 200, analyzeFeedback());
3566
+ sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
3501
3567
  return;
3502
3568
  }
3503
3569
 
@@ -3740,7 +3806,9 @@ async function addContext(){
3740
3806
 
3741
3807
  if (req.method === 'GET' && pathname === '/v1/feedback/summary') {
3742
3808
  const recent = Number(parsed.searchParams.get('recent') || 20);
3743
- const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20);
3809
+ const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20, {
3810
+ feedbackDir: requestFeedbackDir,
3811
+ });
3744
3812
  sendJson(res, 200, { summary });
3745
3813
  return;
3746
3814
  }
@@ -3757,6 +3825,7 @@ async function addContext(){
3757
3825
  limit: Number.isFinite(limit) ? limit : 10,
3758
3826
  category,
3759
3827
  tags,
3828
+ feedbackDir: requestFeedbackDir,
3760
3829
  });
3761
3830
  sendJson(res, 200, results);
3762
3831
  return;
@@ -3811,11 +3880,21 @@ async function addContext(){
3811
3880
  return;
3812
3881
  }
3813
3882
  const body = await parseJsonBody(req);
3883
+ // Auto-include conversation window when caller doesn't provide one
3884
+ let chatHistory = Array.isArray(body.chatHistory) ? body.chatHistory : body.messages;
3885
+ if (!chatHistory || chatHistory.length === 0) {
3886
+ try {
3887
+ chatHistory = readRecentConversationWindow({
3888
+ feedbackDir: getSafeDataDir(),
3889
+ limit: 10,
3890
+ });
3891
+ } catch (_) { /* best-effort — conversation window is optional */ }
3892
+ }
3814
3893
  const result = captureFeedback({
3815
3894
  signal: body.signal,
3816
3895
  context: body.context || '',
3817
3896
  relatedFeedbackId: body.relatedFeedbackId,
3818
- chatHistory: Array.isArray(body.chatHistory) ? body.chatHistory : body.messages,
3897
+ chatHistory,
3819
3898
  whatWentWrong: body.whatWentWrong,
3820
3899
  whatToChange: body.whatToChange,
3821
3900
  whatWorked: body.whatWorked,
@@ -3836,7 +3915,9 @@ async function addContext(){
3836
3915
  if (req.method === 'POST' && pathname === '/v1/feedback/rules') {
3837
3916
  const body = await parseJsonBody(req);
3838
3917
  const minOccurrences = Number(body.minOccurrences || 2);
3839
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3918
+ const outputPath = body.outputPath
3919
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3920
+ : undefined;
3840
3921
  const result = writePreventionRules(outputPath, Number.isFinite(minOccurrences) ? minOccurrences : 2);
3841
3922
  sendJson(res, 200, {
3842
3923
  path: result.path,
@@ -3865,20 +3946,28 @@ async function addContext(){
3865
3946
  let memories = [];
3866
3947
 
3867
3948
  if (body.inputPath) {
3868
- const safeInputPath = resolveSafePath(body.inputPath, { mustExist: true });
3949
+ const safeInputPath = resolveSafePath(body.inputPath, {
3950
+ mustExist: true,
3951
+ safeDataDir: requestSafeDataDir,
3952
+ });
3869
3953
  const raw = fs.readFileSync(safeInputPath, 'utf-8');
3870
3954
  const parsedMemories = JSON.parse(raw);
3871
3955
  memories = Array.isArray(parsedMemories) ? parsedMemories : parsedMemories.memories || [];
3872
3956
  } else {
3873
3957
  const localPath = body.memoryLogPath
3874
- ? resolveSafePath(body.memoryLogPath, { mustExist: true })
3875
- : DEFAULT_LOCAL_MEMORY_LOG;
3958
+ ? resolveSafePath(body.memoryLogPath, {
3959
+ mustExist: true,
3960
+ safeDataDir: requestSafeDataDir,
3961
+ })
3962
+ : requestFeedbackPaths.MEMORY_LOG_PATH;
3876
3963
  memories = readJSONL(localPath);
3877
3964
  }
3878
3965
 
3879
3966
  const result = exportDpoFromMemories(memories);
3880
3967
  if (body.outputPath) {
3881
- const safeOutputPath = resolveSafePath(body.outputPath);
3968
+ const safeOutputPath = resolveSafePath(body.outputPath, {
3969
+ safeDataDir: requestSafeDataDir,
3970
+ });
3882
3971
  fs.mkdirSync(path.dirname(safeOutputPath), { recursive: true });
3883
3972
  fs.writeFileSync(safeOutputPath, result.jsonl);
3884
3973
  }
@@ -3889,14 +3978,18 @@ async function addContext(){
3889
3978
  learnings: result.learnings.length,
3890
3979
  unpairedErrors: result.unpairedErrors.length,
3891
3980
  unpairedLearnings: result.unpairedLearnings.length,
3892
- outputPath: body.outputPath ? resolveSafePath(body.outputPath) : null,
3981
+ outputPath: body.outputPath
3982
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3983
+ : null,
3893
3984
  });
3894
3985
  return;
3895
3986
  }
3896
3987
 
3897
3988
  if (req.method === 'POST' && pathname === '/v1/analytics/databricks/export') {
3898
3989
  const body = await parseJsonBody(req);
3899
- const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3990
+ const outputPath = body.outputPath
3991
+ ? resolveSafePath(body.outputPath, { safeDataDir: requestSafeDataDir })
3992
+ : undefined;
3900
3993
  const result = exportDatabricksBundle(undefined, outputPath);
3901
3994
  sendJson(res, 200, result);
3902
3995
  return;
@@ -3961,7 +4054,7 @@ async function addContext(){
3961
4054
  // ----------------------------------------------------------------
3962
4055
 
3963
4056
  if (req.method === 'GET' && pathname === '/v1/quality/scores') {
3964
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4057
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
3965
4058
  const model = loadModel(modelPath);
3966
4059
  const reliability = getReliability(model);
3967
4060
  const category = parsed.searchParams.get('category');
@@ -3981,7 +4074,7 @@ async function addContext(){
3981
4074
  }
3982
4075
 
3983
4076
  if (req.method === 'GET' && pathname === '/v1/quality/rules') {
3984
- const rulesPath = path.join(getSafeDataDir(), 'prevention-rules.md');
4077
+ const rulesPath = path.join(requestSafeDataDir, 'prevention-rules.md');
3985
4078
  let markdown = '';
3986
4079
  if (fs.existsSync(rulesPath)) {
3987
4080
  markdown = fs.readFileSync(rulesPath, 'utf8').trim();
@@ -3998,7 +4091,7 @@ async function addContext(){
3998
4091
  }
3999
4092
 
4000
4093
  if (req.method === 'GET' && pathname === '/v1/quality/posteriors') {
4001
- const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
4094
+ const modelPath = path.join(requestSafeDataDir, 'feedback_model.json');
4002
4095
  const model = loadModel(modelPath);
4003
4096
  const posteriors = samplePosteriors(model);
4004
4097
  sendJson(res, 200, { posteriors });
@@ -4237,9 +4330,8 @@ async function addContext(){
4237
4330
  return;
4238
4331
  }
4239
4332
 
4240
- const { FEEDBACK_DIR } = getFeedbackPaths();
4241
4333
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4242
- const data = generateDashboard(FEEDBACK_DIR, {
4334
+ const data = generateDashboard(requestFeedbackDir, {
4243
4335
  analyticsWindow: summaryOptions,
4244
4336
  billingSummary,
4245
4337
  billingSource: 'live',
@@ -4265,9 +4357,8 @@ async function addContext(){
4265
4357
  }
4266
4358
 
4267
4359
  try {
4268
- const { FEEDBACK_DIR } = getFeedbackPaths();
4269
4360
  const billingSummary = await getBillingSummaryLive(summaryOptions);
4270
- const data = generateDashboard(FEEDBACK_DIR, {
4361
+ const data = generateDashboard(requestFeedbackDir, {
4271
4362
  analyticsWindow: summaryOptions,
4272
4363
  billingSummary,
4273
4364
  billingSource: 'live',