thumbgate 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/README.md +4 -4
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +48 -16
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/mcp/server-stdio.js +11 -8
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +20 -11
- package/config/github-about.json +1 -1
- package/config/model-tiers.json +11 -0
- package/package.json +22 -11
- package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-codex-bridge/.mcp.json +1 -1
- package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
- package/plugins/codex-profile/.mcp.json +1 -1
- package/plugins/codex-profile/INSTALL.md +1 -1
- package/plugins/codex-profile/README.md +1 -1
- package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-marketplace/README.md +2 -2
- package/plugins/cursor-marketplace/commands/capture-feedback.md +2 -2
- package/plugins/cursor-marketplace/rules/feedback-capture.mdc +3 -3
- package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +3 -2
- package/plugins/opencode-profile/INSTALL.md +1 -1
- package/public/compare.html +302 -0
- package/public/guide.html +4 -4
- package/public/index.html +77 -38
- package/public/learn/ai-agent-persistent-memory.html +1 -0
- package/public/lessons.html +325 -17
- package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
- package/scripts/ai-search-visibility.js +142 -0
- package/scripts/audit-trail.js +6 -0
- package/scripts/capture-railway-diagnostics.sh +97 -0
- package/scripts/changeset-check.js +372 -0
- package/scripts/check-congruence.js +8 -5
- package/scripts/claude-feedback-sync.js +320 -0
- package/scripts/cli-telemetry.js +4 -1
- package/scripts/computer-use-firewall.js +45 -15
- package/scripts/contextfs.js +32 -23
- package/scripts/dashboard.js +84 -0
- package/scripts/docker-sandbox-planner.js +208 -0
- package/scripts/feedback-loop.js +16 -0
- package/scripts/github-about.js +56 -0
- package/scripts/intervention-policy.js +696 -0
- package/scripts/local-model-profile.js +18 -2
- package/scripts/model-tier-router.js +10 -1
- package/scripts/operational-integrity.js +361 -32
- package/scripts/prove-adapters.js +1 -0
- package/scripts/prove-automation.js +2 -2
- package/scripts/prove-packaged-runtime.js +260 -0
- package/scripts/prove-runtime.js +13 -0
- package/scripts/published-cli.js +10 -1
- package/scripts/rate-limiter.js +3 -3
- package/scripts/statusline-links.js +238 -0
- package/scripts/statusline-local-stats.js +2 -0
- package/scripts/statusline.sh +200 -10
- package/scripts/sync-github-about.js +7 -4
- package/scripts/tool-registry.js +2 -2
- package/scripts/workflow-sentinel.js +197 -39
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +12 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const {
|
|
7
|
+
captureFeedback,
|
|
8
|
+
getFeedbackPaths,
|
|
9
|
+
readJSONL,
|
|
10
|
+
analyzeFeedback,
|
|
11
|
+
} = require('./feedback-loop');
|
|
12
|
+
const { normalizeFeedbackText } = require('./feedback-quality');
|
|
13
|
+
const {
|
|
14
|
+
resolveFeedbackDir,
|
|
15
|
+
resolveProjectDir,
|
|
16
|
+
} = require('./feedback-paths');
|
|
17
|
+
const { refreshStatuslineCache } = require('./hook-thumbgate-cache-updater');
|
|
18
|
+
|
|
19
|
+
const SYNC_STATE_FILE = 'claude-feedback-sync-state.json';
|
|
20
|
+
const DEFAULT_RECENT_FEEDBACK_LIMIT = 250;
|
|
21
|
+
const DEFAULT_PROCESSED_ID_LIMIT = 512;
|
|
22
|
+
const DUPLICATE_WINDOW_MS = 30 * 1000;
|
|
23
|
+
|
|
24
|
+
function getClaudeHistoryPath(options = {}) {
|
|
25
|
+
if (options.historyPath) return options.historyPath;
|
|
26
|
+
if (process.env.THUMBGATE_CLAUDE_HISTORY_PATH) return process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
|
|
27
|
+
const homeDir = options.homeDir || process.env.HOME || process.env.USERPROFILE || '';
|
|
28
|
+
return path.join(homeDir, '.claude', 'history.jsonl');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getSyncStatePath(options = {}) {
|
|
32
|
+
const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
|
|
33
|
+
return path.join(feedbackDir, SYNC_STATE_FILE);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readSyncState(options = {}) {
|
|
37
|
+
const statePath = getSyncStatePath(options);
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
40
|
+
return {
|
|
41
|
+
historyOffset: Number(parsed.historyOffset || 0),
|
|
42
|
+
historySize: Number(parsed.historySize || 0),
|
|
43
|
+
processedIds: Array.isArray(parsed.processedIds) ? parsed.processedIds : [],
|
|
44
|
+
statePath,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return {
|
|
48
|
+
historyOffset: 0,
|
|
49
|
+
historySize: 0,
|
|
50
|
+
processedIds: [],
|
|
51
|
+
statePath,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeSyncState(state, options = {}) {
|
|
57
|
+
const statePath = getSyncStatePath(options);
|
|
58
|
+
const payload = {
|
|
59
|
+
historyOffset: Number(state.historyOffset || 0),
|
|
60
|
+
historySize: Number(state.historySize || 0),
|
|
61
|
+
processedIds: Array.isArray(state.processedIds) ? state.processedIds.slice(-DEFAULT_PROCESSED_ID_LIMIT) : [],
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
65
|
+
fs.writeFileSync(statePath, JSON.stringify(payload, null, 2));
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readHistoryEntriesSince(filePath, state) {
|
|
70
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
71
|
+
return {
|
|
72
|
+
entries: [],
|
|
73
|
+
nextOffset: 0,
|
|
74
|
+
size: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stat = fs.statSync(filePath);
|
|
79
|
+
const safeOffset = state && state.historyOffset > 0 && state.historyOffset <= stat.size
|
|
80
|
+
? state.historyOffset
|
|
81
|
+
: 0;
|
|
82
|
+
|
|
83
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
84
|
+
const contents = fileBuffer.slice(safeOffset).toString('utf8');
|
|
85
|
+
|
|
86
|
+
const entries = contents
|
|
87
|
+
.split('\n')
|
|
88
|
+
.map((line) => line.trim())
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.map((line) => {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(line);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
entries,
|
|
101
|
+
nextOffset: stat.size,
|
|
102
|
+
size: stat.size,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeProjectPath(value) {
|
|
107
|
+
try {
|
|
108
|
+
return value ? path.resolve(String(value)) : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseHistoryTimestamp(value) {
|
|
115
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
116
|
+
return value > 1e12 ? value : value * 1000;
|
|
117
|
+
}
|
|
118
|
+
const text = String(value || '').trim();
|
|
119
|
+
if (!text) return null;
|
|
120
|
+
if (/^\d+$/.test(text)) {
|
|
121
|
+
const numeric = Number(text);
|
|
122
|
+
return numeric > 1e12 ? numeric : numeric * 1000;
|
|
123
|
+
}
|
|
124
|
+
const parsed = Date.parse(text);
|
|
125
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function detectSignal(text) {
|
|
129
|
+
const normalized = String(text || '').toLowerCase();
|
|
130
|
+
if (/(thumbs?\s*down|that failed|that was wrong|fix this)/i.test(normalized)) return 'down';
|
|
131
|
+
if (/(thumbs?\s*up|that worked|looks good|nice work|perfect|good job)/i.test(normalized)) return 'up';
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractPromptText(entry) {
|
|
136
|
+
const candidates = [
|
|
137
|
+
entry && entry.display,
|
|
138
|
+
entry && entry.message && entry.message.content,
|
|
139
|
+
entry && entry.attachment && entry.attachment.prompt,
|
|
140
|
+
entry && entry.content,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
145
|
+
return candidate.trim();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildExternalId(entry, promptText) {
|
|
152
|
+
const hash = crypto.createHash('sha256');
|
|
153
|
+
hash.update(String(entry.sessionId || ''));
|
|
154
|
+
hash.update('|');
|
|
155
|
+
hash.update(String(entry.timestamp || ''));
|
|
156
|
+
hash.update('|');
|
|
157
|
+
hash.update(String(entry.project || entry.cwd || ''));
|
|
158
|
+
hash.update('|');
|
|
159
|
+
hash.update(String(promptText || ''));
|
|
160
|
+
return `claude-history:${hash.digest('hex')}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toHistoryCandidate(entry, options = {}) {
|
|
164
|
+
const promptText = extractPromptText(entry);
|
|
165
|
+
const signal = detectSignal(promptText);
|
|
166
|
+
if (!signal) return null;
|
|
167
|
+
|
|
168
|
+
const projectDir = normalizeProjectPath(options.projectDir);
|
|
169
|
+
const entryProject = normalizeProjectPath(entry.project || entry.cwd || '');
|
|
170
|
+
if (projectDir && entryProject && entryProject !== projectDir && !entryProject.startsWith(`${projectDir}${path.sep}`)) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (projectDir && !entryProject) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
externalId: buildExternalId(entry, promptText),
|
|
179
|
+
promptText,
|
|
180
|
+
signal,
|
|
181
|
+
timestampMs: parseHistoryTimestamp(entry.timestamp),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeCandidateText(value) {
|
|
186
|
+
return normalizeFeedbackText(String(value || '').replace(/\s+/g, ' '));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasMatchingFeedbackEntry(candidate, feedbackEntries) {
|
|
190
|
+
const candidateText = normalizeCandidateText(candidate.promptText);
|
|
191
|
+
if (!candidateText) return false;
|
|
192
|
+
|
|
193
|
+
return feedbackEntries.some((entry) => {
|
|
194
|
+
const signal = entry && entry.signal === 'negative' ? 'down' : 'up';
|
|
195
|
+
if (signal !== candidate.signal) return false;
|
|
196
|
+
|
|
197
|
+
const feedbackText = normalizeCandidateText(
|
|
198
|
+
entry.submittedContext
|
|
199
|
+
|| entry.context
|
|
200
|
+
|| entry.whatWentWrong
|
|
201
|
+
|| entry.whatWorked
|
|
202
|
+
|| ''
|
|
203
|
+
);
|
|
204
|
+
if (feedbackText !== candidateText) return false;
|
|
205
|
+
|
|
206
|
+
const feedbackTimestamp = Date.parse(entry.timestamp || '');
|
|
207
|
+
if (!Number.isFinite(feedbackTimestamp) || !Number.isFinite(candidate.timestampMs)) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
return Math.abs(feedbackTimestamp - candidate.timestampMs) <= DUPLICATE_WINDOW_MS;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function syncClaudeHistoryFeedback(options = {}) {
|
|
215
|
+
if (options.disabled || process.env.THUMBGATE_DISABLE_CLAUDE_HISTORY_SYNC === '1') {
|
|
216
|
+
return {
|
|
217
|
+
importedCount: 0,
|
|
218
|
+
skippedCount: 0,
|
|
219
|
+
reason: 'disabled',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const originalEnv = {
|
|
224
|
+
THUMBGATE_FEEDBACK_DIR: process.env.THUMBGATE_FEEDBACK_DIR,
|
|
225
|
+
THUMBGATE_PROJECT_DIR: process.env.THUMBGATE_PROJECT_DIR,
|
|
226
|
+
THUMBGATE_CLAUDE_HISTORY_PATH: process.env.THUMBGATE_CLAUDE_HISTORY_PATH,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
if (options.feedbackDir) process.env.THUMBGATE_FEEDBACK_DIR = options.feedbackDir;
|
|
230
|
+
if (options.projectDir && !options.feedbackDir) process.env.THUMBGATE_PROJECT_DIR = options.projectDir;
|
|
231
|
+
if (options.historyPath) process.env.THUMBGATE_CLAUDE_HISTORY_PATH = options.historyPath;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
|
|
235
|
+
const projectDir = normalizeProjectPath(options.projectDir) || resolveProjectDir({
|
|
236
|
+
cwd: process.cwd(),
|
|
237
|
+
env: process.env,
|
|
238
|
+
});
|
|
239
|
+
const historyPath = getClaudeHistoryPath(options);
|
|
240
|
+
const state = readSyncState({ feedbackDir });
|
|
241
|
+
const history = readHistoryEntriesSince(historyPath, state);
|
|
242
|
+
const existingEntries = readJSONL(path.join(feedbackDir, 'feedback-log.jsonl'), {
|
|
243
|
+
maxLines: DEFAULT_RECENT_FEEDBACK_LIMIT,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
let importedCount = 0;
|
|
247
|
+
let skippedCount = 0;
|
|
248
|
+
const processedIds = new Set(state.processedIds || []);
|
|
249
|
+
|
|
250
|
+
for (const entry of history.entries) {
|
|
251
|
+
const candidate = toHistoryCandidate(entry, { projectDir });
|
|
252
|
+
if (!candidate) continue;
|
|
253
|
+
if (processedIds.has(candidate.externalId)) {
|
|
254
|
+
skippedCount += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (hasMatchingFeedbackEntry(candidate, existingEntries)) {
|
|
259
|
+
processedIds.add(candidate.externalId);
|
|
260
|
+
skippedCount += 1;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const captureResult = captureFeedback({
|
|
265
|
+
signal: candidate.signal,
|
|
266
|
+
context: candidate.promptText,
|
|
267
|
+
whatWentWrong: candidate.signal === 'down' ? candidate.promptText : undefined,
|
|
268
|
+
whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
|
|
269
|
+
tags: ['claude-history-sync', 'auto-capture-fallback'],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (captureResult && captureResult.feedbackEvent) {
|
|
273
|
+
existingEntries.push(captureResult.feedbackEvent);
|
|
274
|
+
}
|
|
275
|
+
processedIds.add(candidate.externalId);
|
|
276
|
+
importedCount += 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
writeSyncState({
|
|
280
|
+
historyOffset: history.nextOffset,
|
|
281
|
+
historySize: history.size,
|
|
282
|
+
processedIds: Array.from(processedIds),
|
|
283
|
+
}, { feedbackDir });
|
|
284
|
+
|
|
285
|
+
if (importedCount > 0) {
|
|
286
|
+
refreshStatuslineCache(analyzeFeedback(path.join(feedbackDir, 'feedback-log.jsonl')), path.join(feedbackDir, 'statusline_cache.json'));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
importedCount,
|
|
291
|
+
skippedCount,
|
|
292
|
+
historyPath,
|
|
293
|
+
feedbackDir,
|
|
294
|
+
projectDir,
|
|
295
|
+
};
|
|
296
|
+
} finally {
|
|
297
|
+
if (originalEnv.THUMBGATE_FEEDBACK_DIR == null) delete process.env.THUMBGATE_FEEDBACK_DIR;
|
|
298
|
+
else process.env.THUMBGATE_FEEDBACK_DIR = originalEnv.THUMBGATE_FEEDBACK_DIR;
|
|
299
|
+
|
|
300
|
+
if (originalEnv.THUMBGATE_PROJECT_DIR == null) delete process.env.THUMBGATE_PROJECT_DIR;
|
|
301
|
+
else process.env.THUMBGATE_PROJECT_DIR = originalEnv.THUMBGATE_PROJECT_DIR;
|
|
302
|
+
|
|
303
|
+
if (originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH == null) delete process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
|
|
304
|
+
else process.env.THUMBGATE_CLAUDE_HISTORY_PATH = originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
SYNC_STATE_FILE,
|
|
310
|
+
detectSignal,
|
|
311
|
+
extractPromptText,
|
|
312
|
+
getClaudeHistoryPath,
|
|
313
|
+
hasMatchingFeedbackEntry,
|
|
314
|
+
parseHistoryTimestamp,
|
|
315
|
+
readHistoryEntriesSince,
|
|
316
|
+
readSyncState,
|
|
317
|
+
syncClaudeHistoryFeedback,
|
|
318
|
+
toHistoryCandidate,
|
|
319
|
+
writeSyncState,
|
|
320
|
+
};
|
package/scripts/cli-telemetry.js
CHANGED
|
@@ -5,7 +5,9 @@ const crypto = require('crypto');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
const
|
|
8
|
+
const _DEFAULT_TELEMETRY_HOST = 'https://thumbgate-production.up.railway.app';
|
|
9
|
+
// Respect THUMBGATE_API_URL so test environments can point to a local stub
|
|
10
|
+
const TELEMETRY_ENDPOINT = `${process.env.THUMBGATE_API_URL || _DEFAULT_TELEMETRY_HOST}/v1/telemetry/ping`;
|
|
9
11
|
const INSTALL_ID_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.thumbgate', 'install-id');
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -80,6 +82,7 @@ function trackEvent(eventType, metadata = {}) {
|
|
|
80
82
|
});
|
|
81
83
|
req.on('error', () => {}); // silently ignore
|
|
82
84
|
req.on('timeout', () => req.destroy());
|
|
85
|
+
req.on('socket', (s) => s.unref()); // fire-and-forget: never block process exit
|
|
83
86
|
req.end(payload);
|
|
84
87
|
} catch (_) {} // never crash the CLI
|
|
85
88
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { buildDockerSandboxPlan } = require('./docker-sandbox-planner');
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Computer-Use Action Firewall — normalizes OpenAI Responses API
|
|
@@ -129,13 +130,13 @@ function evaluateAction(action, preset = 'dev-sandbox', customRules = []) {
|
|
|
129
130
|
const normalized = action.type ? action : normalizeAction(action);
|
|
130
131
|
const presetConfig = PRESETS[preset];
|
|
131
132
|
if (!presetConfig) {
|
|
132
|
-
return {
|
|
133
|
+
return attachExecutionSurface({
|
|
133
134
|
decision: 'deny',
|
|
134
135
|
reason: `Unknown preset: ${preset}`,
|
|
135
136
|
preset,
|
|
136
137
|
riskLevel: normalized.riskLevel,
|
|
137
138
|
auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Unknown preset: ${preset}`, preset }),
|
|
138
|
-
};
|
|
139
|
+
}, normalized);
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
// Custom rules override preset defaults
|
|
@@ -143,81 +144,108 @@ function evaluateAction(action, preset = 'dev-sandbox', customRules = []) {
|
|
|
143
144
|
if (rule.action === normalized.type) {
|
|
144
145
|
const decision = rule.decision || 'deny';
|
|
145
146
|
const reason = rule.reason || `Custom rule override for ${normalized.type}`;
|
|
146
|
-
return {
|
|
147
|
+
return attachExecutionSurface({
|
|
147
148
|
decision,
|
|
148
149
|
reason,
|
|
149
150
|
preset,
|
|
150
151
|
riskLevel: normalized.riskLevel,
|
|
151
152
|
auditEntry: createAuditEntry(normalized, { decision, reason, preset }),
|
|
152
|
-
};
|
|
153
|
+
}, normalized);
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// Check dangerous shell patterns (always deny)
|
|
157
158
|
const dangerousMatch = matchesDangerousPattern(normalized);
|
|
158
159
|
if (dangerousMatch) {
|
|
159
|
-
return {
|
|
160
|
+
return attachExecutionSurface({
|
|
160
161
|
decision: 'deny',
|
|
161
162
|
reason: `Dangerous shell pattern detected: ${dangerousMatch}`,
|
|
162
163
|
preset,
|
|
163
164
|
riskLevel: 'critical',
|
|
164
165
|
auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Dangerous shell pattern: ${dangerousMatch}`, preset }),
|
|
165
|
-
};
|
|
166
|
+
}, normalized);
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
// Check secret patterns (always deny)
|
|
169
170
|
const secretMatch = matchesSecretPattern(normalized);
|
|
170
171
|
if (secretMatch) {
|
|
171
|
-
return {
|
|
172
|
+
return attachExecutionSurface({
|
|
172
173
|
decision: 'deny',
|
|
173
174
|
reason: `Secret pattern detected in content: ${secretMatch}`,
|
|
174
175
|
preset,
|
|
175
176
|
riskLevel: 'critical',
|
|
176
177
|
auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Secret pattern: ${secretMatch}`, preset }),
|
|
177
|
-
};
|
|
178
|
+
}, normalized);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
// Evaluate against preset
|
|
181
182
|
if (presetConfig.deny.includes(normalized.type)) {
|
|
182
|
-
return {
|
|
183
|
+
return attachExecutionSurface({
|
|
183
184
|
decision: 'deny',
|
|
184
185
|
reason: `Action ${normalized.type} denied by ${preset} preset`,
|
|
185
186
|
preset,
|
|
186
187
|
riskLevel: normalized.riskLevel,
|
|
187
188
|
auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Denied by preset`, preset }),
|
|
188
|
-
};
|
|
189
|
+
}, normalized);
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
if (presetConfig.requireApproval.includes(normalized.type)) {
|
|
192
|
-
return {
|
|
193
|
+
return attachExecutionSurface({
|
|
193
194
|
decision: 'require-approval',
|
|
194
195
|
reason: `Action ${normalized.type} requires approval in ${preset} preset`,
|
|
195
196
|
preset,
|
|
196
197
|
riskLevel: normalized.riskLevel,
|
|
197
198
|
auditEntry: createAuditEntry(normalized, { decision: 'require-approval', reason: `Requires approval`, preset }),
|
|
198
|
-
};
|
|
199
|
+
}, normalized);
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
if (presetConfig.allow.includes(normalized.type)) {
|
|
202
|
-
return {
|
|
203
|
+
return attachExecutionSurface({
|
|
203
204
|
decision: 'allow',
|
|
204
205
|
reason: `Action ${normalized.type} allowed by ${preset} preset`,
|
|
205
206
|
preset,
|
|
206
207
|
riskLevel: normalized.riskLevel,
|
|
207
208
|
auditEntry: createAuditEntry(normalized, { decision: 'allow', reason: `Allowed by preset`, preset }),
|
|
208
|
-
};
|
|
209
|
+
}, normalized);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
// Default: unknown actions require approval
|
|
212
|
-
return {
|
|
213
|
+
return attachExecutionSurface({
|
|
213
214
|
decision: 'require-approval',
|
|
214
215
|
reason: `Action ${normalized.type} not in preset; defaulting to require-approval`,
|
|
215
216
|
preset,
|
|
216
217
|
riskLevel: normalized.riskLevel,
|
|
217
218
|
auditEntry: createAuditEntry(normalized, { decision: 'require-approval', reason: `Not in preset`, preset }),
|
|
219
|
+
}, normalized);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function attachExecutionSurface(result, action) {
|
|
223
|
+
const executionSurface = buildDockerSandboxPlan({
|
|
224
|
+
toolName: action.type === 'shell.exec' ? 'Bash' : 'Write',
|
|
225
|
+
actionType: action.type,
|
|
226
|
+
command: action.type === 'shell.exec' ? action.target : '',
|
|
227
|
+
repoPath: action.args.repoPath || action.args.cwd || '',
|
|
228
|
+
affectedFiles: action.type.startsWith('file.') && action.target ? [action.target] : [],
|
|
229
|
+
riskBand: toSandboxRiskBand(action.riskLevel),
|
|
230
|
+
requiresNetwork: ['upload', 'download', 'message.send'].includes(action.type),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (!executionSurface.shouldSandbox) {
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
...result,
|
|
239
|
+
executionSurface,
|
|
218
240
|
};
|
|
219
241
|
}
|
|
220
242
|
|
|
243
|
+
function toSandboxRiskBand(riskLevel) {
|
|
244
|
+
if (riskLevel === 'high') return 'high';
|
|
245
|
+
if (riskLevel === 'medium') return 'medium';
|
|
246
|
+
return 'low';
|
|
247
|
+
}
|
|
248
|
+
|
|
221
249
|
function createAuditEntry(action, decision) {
|
|
222
250
|
return {
|
|
223
251
|
timestamp: action.timestamp || new Date().toISOString(),
|
|
@@ -247,4 +275,6 @@ module.exports = {
|
|
|
247
275
|
loadConfig,
|
|
248
276
|
matchesDangerousPattern,
|
|
249
277
|
matchesSecretPattern,
|
|
278
|
+
attachExecutionSurface,
|
|
279
|
+
toSandboxRiskBand,
|
|
250
280
|
};
|
package/scripts/contextfs.js
CHANGED
|
@@ -23,9 +23,15 @@ function getFeedbackBaseDir() {
|
|
|
23
23
|
return resolveFeedbackDir();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
function getContextFsRoot() {
|
|
27
|
+
const feedbackDir = getFeedbackBaseDir();
|
|
28
|
+
if (process.env.THUMBGATE_CONTEXTFS_DIR) return process.env.THUMBGATE_CONTEXTFS_DIR;
|
|
29
|
+
return feedbackDir.endsWith('contextfs') ? feedbackDir : path.join(feedbackDir, 'contextfs');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contextFsPath(...segments) {
|
|
33
|
+
return path.join(getContextFsRoot(), ...segments);
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
const NAMESPACES = {
|
|
31
37
|
rawHistory: 'raw_history',
|
|
@@ -101,7 +107,7 @@ function ensureDir(dirPath) {
|
|
|
101
107
|
|
|
102
108
|
function ensureContextFs() {
|
|
103
109
|
Object.values(NAMESPACES).forEach((subPath) => {
|
|
104
|
-
ensureDir(
|
|
110
|
+
ensureDir(contextFsPath(subPath));
|
|
105
111
|
});
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -111,7 +117,7 @@ function nowIso() {
|
|
|
111
117
|
|
|
112
118
|
function inferNamespaceFromPath(filePath) {
|
|
113
119
|
if (!filePath) return '';
|
|
114
|
-
const relativeDir = path.relative(
|
|
120
|
+
const relativeDir = path.relative(getContextFsRoot(), path.dirname(filePath));
|
|
115
121
|
if (!relativeDir || relativeDir.startsWith('..')) return '';
|
|
116
122
|
return relativeDir;
|
|
117
123
|
}
|
|
@@ -211,7 +217,7 @@ function getSemanticCacheConfig() {
|
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
function getSemanticCachePath() {
|
|
214
|
-
return
|
|
220
|
+
return contextFsPath(NAMESPACES.provenance, 'semantic-cache.jsonl');
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
function loadSemanticCacheEntries() {
|
|
@@ -227,7 +233,7 @@ function getSourceHash(namespaces) {
|
|
|
227
233
|
const normalizedNamespaces = normalizeNamespaces(namespaces);
|
|
228
234
|
|
|
229
235
|
for (const ns of normalizedNamespaces) {
|
|
230
|
-
const dirPath =
|
|
236
|
+
const dirPath = contextFsPath(ns);
|
|
231
237
|
if (!fs.existsSync(dirPath)) continue;
|
|
232
238
|
|
|
233
239
|
const files = fs.readdirSync(dirPath).sort();
|
|
@@ -291,7 +297,7 @@ function recordProvenance(event) {
|
|
|
291
297
|
timestamp: nowIso(),
|
|
292
298
|
...event,
|
|
293
299
|
};
|
|
294
|
-
appendJsonl(
|
|
300
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'events.jsonl'), payload);
|
|
295
301
|
return payload;
|
|
296
302
|
}
|
|
297
303
|
|
|
@@ -299,7 +305,7 @@ function writeContextObject({ namespace, title, content, tags = [], source, ttl
|
|
|
299
305
|
ensureContextFs();
|
|
300
306
|
|
|
301
307
|
const id = `${Date.now()}_${toSlug(title)}`;
|
|
302
|
-
const filePath =
|
|
308
|
+
const filePath = contextFsPath(namespace, `${id}.json`);
|
|
303
309
|
|
|
304
310
|
const doc = {
|
|
305
311
|
id,
|
|
@@ -361,7 +367,7 @@ function findExistingContextObject({ namespace, title, content, tags = [], sourc
|
|
|
361
367
|
ensureContextFs();
|
|
362
368
|
|
|
363
369
|
const expectedTags = normalizeTagList(tags);
|
|
364
|
-
const dirPath =
|
|
370
|
+
const dirPath = contextFsPath(namespace);
|
|
365
371
|
const files = listJsonFiles(dirPath).sort();
|
|
366
372
|
|
|
367
373
|
for (const filePath of files) {
|
|
@@ -507,7 +513,7 @@ function loadCandidates(namespaces) {
|
|
|
507
513
|
const docs = [];
|
|
508
514
|
|
|
509
515
|
selected.forEach((namespace) => {
|
|
510
|
-
const dir =
|
|
516
|
+
const dir = contextFsPath(namespace);
|
|
511
517
|
const files = listJsonFiles(dir);
|
|
512
518
|
files.forEach((filePath) => {
|
|
513
519
|
try {
|
|
@@ -624,7 +630,7 @@ function selectFlatContextItems(candidates, maxItems, maxChars) {
|
|
|
624
630
|
const MEMEX_INDEX_FILE = 'memex-index.jsonl';
|
|
625
631
|
|
|
626
632
|
function getMemexIndexPath() {
|
|
627
|
-
return
|
|
633
|
+
return contextFsPath(NAMESPACES.provenance, MEMEX_INDEX_FILE);
|
|
628
634
|
}
|
|
629
635
|
|
|
630
636
|
function buildIndexEntry(doc, filePath) {
|
|
@@ -751,7 +757,7 @@ function constructMemexPack({ query = '', maxItems = 8, maxChars = 6000, namespa
|
|
|
751
757
|
cache: { hit: false },
|
|
752
758
|
};
|
|
753
759
|
|
|
754
|
-
appendJsonl(
|
|
760
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
|
|
755
761
|
recordProvenance({
|
|
756
762
|
type: 'memex_pack_constructed',
|
|
757
763
|
packId,
|
|
@@ -792,7 +798,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
|
|
|
792
798
|
},
|
|
793
799
|
};
|
|
794
800
|
|
|
795
|
-
appendJsonl(
|
|
801
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
|
|
796
802
|
recordProvenance({
|
|
797
803
|
type: 'context_pack_cache_hit',
|
|
798
804
|
packId,
|
|
@@ -861,7 +867,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
|
|
|
861
867
|
retrieval: selection.retrieval,
|
|
862
868
|
};
|
|
863
869
|
|
|
864
|
-
appendJsonl(
|
|
870
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
|
|
865
871
|
appendSemanticCacheEntry({
|
|
866
872
|
id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
867
873
|
timestamp: nowIso(),
|
|
@@ -899,7 +905,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
|
|
|
899
905
|
timestamp: nowIso(),
|
|
900
906
|
};
|
|
901
907
|
|
|
902
|
-
appendJsonl(
|
|
908
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'evaluations.jsonl'), evaluation);
|
|
903
909
|
recordProvenance({
|
|
904
910
|
type: 'context_pack_evaluated',
|
|
905
911
|
packId,
|
|
@@ -912,7 +918,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
|
|
|
912
918
|
}
|
|
913
919
|
|
|
914
920
|
function getProvenance(limit = 50) {
|
|
915
|
-
const eventsPath =
|
|
921
|
+
const eventsPath = contextFsPath(NAMESPACES.provenance, 'events.jsonl');
|
|
916
922
|
const events = readJsonl(eventsPath);
|
|
917
923
|
return events.slice(-limit);
|
|
918
924
|
}
|
|
@@ -923,7 +929,7 @@ function getProvenance(limit = 50) {
|
|
|
923
929
|
* session starts with full context — no manual primer.md needed.
|
|
924
930
|
*/
|
|
925
931
|
function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, openFiles, customContext } = {}) {
|
|
926
|
-
ensureDir(
|
|
932
|
+
ensureDir(contextFsPath(NAMESPACES.session));
|
|
927
933
|
|
|
928
934
|
let gitContext = {};
|
|
929
935
|
try {
|
|
@@ -951,7 +957,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
|
|
|
951
957
|
customContext: customContext || null,
|
|
952
958
|
};
|
|
953
959
|
|
|
954
|
-
const primerPath =
|
|
960
|
+
const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
|
|
955
961
|
fs.writeFileSync(primerPath, JSON.stringify(primer, null, 2));
|
|
956
962
|
|
|
957
963
|
// Sync to primer.md if it exists
|
|
@@ -991,7 +997,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
|
|
|
991
997
|
* Read the most recent session handoff primer.
|
|
992
998
|
*/
|
|
993
999
|
function readSessionHandoff() {
|
|
994
|
-
const primerPath =
|
|
1000
|
+
const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
|
|
995
1001
|
if (!fs.existsSync(primerPath)) return null;
|
|
996
1002
|
try {
|
|
997
1003
|
return JSON.parse(fs.readFileSync(primerPath, 'utf8'));
|
|
@@ -1192,7 +1198,7 @@ function constructMultiHopPack({ query = '', maxItems = 8, maxChars = 6000, name
|
|
|
1192
1198
|
},
|
|
1193
1199
|
};
|
|
1194
1200
|
|
|
1195
|
-
appendJsonl(
|
|
1201
|
+
appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
|
|
1196
1202
|
appendSemanticCacheEntry({
|
|
1197
1203
|
id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1198
1204
|
timestamp: nowIso(),
|
|
@@ -1244,7 +1250,10 @@ function listPackTemplates() {
|
|
|
1244
1250
|
}
|
|
1245
1251
|
|
|
1246
1252
|
module.exports = {
|
|
1247
|
-
CONTEXTFS_ROOT
|
|
1253
|
+
get CONTEXTFS_ROOT() {
|
|
1254
|
+
return getContextFsRoot();
|
|
1255
|
+
},
|
|
1256
|
+
getContextFsRoot,
|
|
1248
1257
|
NAMESPACES,
|
|
1249
1258
|
ensureContextFs,
|
|
1250
1259
|
recordProvenance,
|
|
@@ -1283,5 +1292,5 @@ module.exports = {
|
|
|
1283
1292
|
|
|
1284
1293
|
if (require.main === module) {
|
|
1285
1294
|
ensureContextFs();
|
|
1286
|
-
console.log(`ContextFS ready at ${
|
|
1295
|
+
console.log(`ContextFS ready at ${getContextFsRoot()}`);
|
|
1287
1296
|
}
|