thumbgate 1.25.2 → 1.26.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.
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * install-shim.js — Install a stable shim at ~/.thumbgate/bin/thumbgate-hook
5
+ *
6
+ * The shim is a tiny shell script that always resolves thumbgate@latest,
7
+ * so hook commands in settings.local.json never go stale. This is the
8
+ * Volta-style pattern: a version-agnostic indirection layer that survives
9
+ * across thumbgate upgrades.
10
+ *
11
+ * The shim checks for a cached runtime binary first (fast path), and falls
12
+ * back to `npx --yes thumbgate@latest` (slow path, self-installs).
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const SHIM_DIR = path.join(os.homedir(), '.thumbgate', 'bin');
20
+ const SHIM_PATH = path.join(SHIM_DIR, 'thumbgate-hook');
21
+ const RUNTIME_BIN = path.join(os.homedir(), '.thumbgate', 'runtime', 'node_modules', '.bin', 'thumbgate');
22
+
23
+ /**
24
+ * The shim script. Key design choices:
25
+ * - Uses `exec` to replace the shell process (no zombie processes)
26
+ * - Fast path: if cached runtime binary exists, exec it directly
27
+ * - Slow path: npx --yes thumbgate@latest (auto-installs)
28
+ * - Background upgrade: after the fast path succeeds once, spawn a
29
+ * detached npm install to refresh the cache for next time
30
+ */
31
+ function shimContent() {
32
+ const escapedRuntimeBin = JSON.stringify(RUNTIME_BIN);
33
+ const escapedRuntimeDir = JSON.stringify(path.join(os.homedir(), '.thumbgate', 'runtime'));
34
+
35
+ return `#!/usr/bin/env bash
36
+ # ThumbGate hook shim — DO NOT EDIT
37
+ # Installed by: thumbgate init
38
+ # Purpose: version-agnostic hook entry point that always runs latest ThumbGate
39
+ # Pattern: Volta-style stable shim (see https://volta.sh)
40
+
41
+ set -euo pipefail
42
+
43
+ RUNTIME_BIN=${escapedRuntimeBin}
44
+ RUNTIME_DIR=${escapedRuntimeDir}
45
+
46
+ # Fast path: cached runtime binary exists and is executable
47
+ if [ -x "$RUNTIME_BIN" ]; then
48
+ # Spawn background upgrade (detached, no stdout/stderr, won't block hook)
49
+ ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
50
+ exec "$RUNTIME_BIN" "$@"
51
+ fi
52
+
53
+ # Slow path: no cached binary — install + exec via npx
54
+ mkdir -p "$RUNTIME_DIR"
55
+ exec npx --yes --package thumbgate@latest -- thumbgate "$@"
56
+ `;
57
+ }
58
+
59
+ function installShim() {
60
+ fs.mkdirSync(SHIM_DIR, { recursive: true });
61
+ fs.writeFileSync(SHIM_PATH, shimContent(), { mode: 0o755 });
62
+ return SHIM_PATH;
63
+ }
64
+
65
+ function shimInstalled() {
66
+ try {
67
+ return fs.existsSync(SHIM_PATH) && (fs.statSync(SHIM_PATH).mode & 0o111) !== 0;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function shimPath() {
74
+ return SHIM_PATH;
75
+ }
76
+
77
+ module.exports = {
78
+ installShim,
79
+ shimInstalled,
80
+ shimPath,
81
+ shimContent,
82
+ SHIM_DIR,
83
+ SHIM_PATH,
84
+ };
@@ -12,19 +12,20 @@ const DEFAULT_MODEL = MODELS.FAST;
12
12
  const DEFAULT_MAX_TOKENS = 1024;
13
13
  const DEFAULT_CACHE_TTL = '5m';
14
14
 
15
- let _client = null;
15
+ let _anthropicClient = null;
16
+ let _geminiClient = null;
16
17
 
17
18
  function isAvailable() {
18
19
  return Boolean(process.env.ANTHROPIC_API_KEY);
19
20
  }
20
21
 
21
22
  function getClient() {
22
- if (_client) return _client;
23
+ if (_anthropicClient) return _anthropicClient;
23
24
  if (!isAvailable()) return null;
24
25
  try {
25
26
  const Anthropic = require('@anthropic-ai/sdk');
26
- _client = new Anthropic();
27
- return _client;
27
+ _anthropicClient = new Anthropic();
28
+ return _anthropicClient;
28
29
  } catch {
29
30
  return null;
30
31
  }
@@ -138,7 +139,92 @@ function parseClaudeJson(text) {
138
139
  }
139
140
  }
140
141
 
142
+ async function callGeminiInternal(options = {}) {
143
+ const env = process.env;
144
+ const { detectInferenceBackend } = require('./local-model-profile');
145
+ const providerMode = detectInferenceBackend(env).providerMode;
146
+
147
+ try {
148
+ const { GoogleGenAI } = require('@google/genai');
149
+ if (!_geminiClient) {
150
+ if (providerMode === 'vertex') {
151
+ _geminiClient = new GoogleGenAI({
152
+ enterprise: true,
153
+ project: env.VERTEX_PROJECT_ID || 'ai-revenue28-webhook',
154
+ location: env.VERTEX_LOCATION || 'us-central1',
155
+ });
156
+ } else {
157
+ _geminiClient = new GoogleGenAI({
158
+ apiKey: env.GEMINI_API_KEY,
159
+ });
160
+ }
161
+ }
162
+
163
+ const contents = convertMessagesToGemini(options.messages, options.userPrompt);
164
+ const config = {};
165
+ if (options.systemPrompt) {
166
+ config.systemInstruction = options.systemPrompt;
167
+ }
168
+ if (Number.isFinite(options.temperature)) {
169
+ config.temperature = options.temperature;
170
+ }
171
+ if (options.maxTokens) {
172
+ config.maxOutputTokens = options.maxTokens;
173
+ }
174
+
175
+ const response = await runStep('llm.callGemini', {
176
+ retries: 2,
177
+ logger: (msg) => console.warn(msg),
178
+ }, async () => _geminiClient.models.generateContent({
179
+ model: options.model,
180
+ contents,
181
+ config,
182
+ }));
183
+
184
+ return {
185
+ text: response.text || '',
186
+ usage: response.usageMetadata ? {
187
+ input_tokens: response.usageMetadata.promptTokenCount,
188
+ output_tokens: response.usageMetadata.candidatesTokenCount,
189
+ } : null,
190
+ stopReason: response.candidates?.[0]?.finishReason || null,
191
+ id: null,
192
+ model: options.model,
193
+ };
194
+ } catch (err) {
195
+ console.error('Gemini/Vertex AI execution error:', err);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function convertMessagesToGemini(messages, userPrompt) {
201
+ const list = Array.isArray(messages) && messages.length > 0
202
+ ? messages
203
+ : [{ role: 'user', content: userPrompt }];
204
+
205
+ return list.map((msg) => {
206
+ const role = msg.role === 'assistant' ? 'model' : 'user';
207
+ let text = '';
208
+ if (typeof msg.content === 'string') {
209
+ text = msg.content;
210
+ } else if (Array.isArray(msg.content)) {
211
+ text = msg.content.map((c) => c.text || '').join('');
212
+ } else if (msg.content && typeof msg.content === 'object') {
213
+ text = msg.content.text || JSON.stringify(msg.content);
214
+ }
215
+ return {
216
+ role,
217
+ parts: [{ text }],
218
+ };
219
+ });
220
+ }
221
+
141
222
  async function callClaudeInternal(options = {}) {
223
+ const modelName = options.model || '';
224
+ if (modelName.startsWith('gemini') || modelName.startsWith('vertex')) {
225
+ return callGeminiInternal(options);
226
+ }
227
+
142
228
  const client = getClient();
143
229
  if (!client) return null;
144
230
 
@@ -111,7 +111,8 @@ function isSparseAttentionFamily(modelFamily) {
111
111
 
112
112
  function resolveProviderMode(env = process.env) {
113
113
  const explicit = normalizeSlug(env.THUMBGATE_PROVIDER_MODE || env.THUMBGATE_MODEL_PROVIDER_MODE);
114
- if (explicit === 'local' || explicit === 'managed') return explicit;
114
+ if (explicit === 'local' || explicit === 'managed' || explicit === 'vertex') return explicit;
115
+ if (env.VERTEX_PROJECT_ID || env.VERTEX_API_ENDPOINT) return 'vertex';
115
116
  if (env.THUMBGATE_LOCAL_MODEL_FAMILY || env.THUMBGATE_LOCAL_MODEL_SERVER) return 'local';
116
117
  return 'managed';
117
118
  }
@@ -133,6 +134,7 @@ function resolveModelFamily(env = process.env) {
133
134
  }
134
135
 
135
136
  function buildBackendLabel(providerMode, modelFamily) {
137
+ if (providerMode === 'vertex') return 'Vertex AI secure cloud backend';
136
138
  if (providerMode === 'managed') return 'Managed API backend';
137
139
  if (modelFamily.startsWith('deepseek')) return 'Local DeepSeek sparse backend';
138
140
  if (modelFamily.startsWith('glm')) return 'Local GLM sparse backend';
@@ -148,14 +150,18 @@ function detectInferenceBackend(env = process.env) {
148
150
  && supportsSparseAttention
149
151
  && INDEXCACHE_SERVER_ENGINES.has(serverEngine);
150
152
  const indexCacheEnabled = indexCacheEligible && parseBoolean(env.THUMBGATE_INDEXCACHE_ENABLED, false);
151
- const id = providerMode === 'managed'
152
- ? 'managed-api'
153
- : supportsSparseAttention
154
- ? `local-${modelFamily}-sparse`
155
- : 'local-dense';
153
+ const id = providerMode === 'vertex'
154
+ ? 'vertex-api'
155
+ : providerMode === 'managed'
156
+ ? 'managed-api'
157
+ : supportsSparseAttention
158
+ ? `local-${modelFamily}-sparse`
159
+ : 'local-dense';
156
160
 
157
161
  let rationale = 'Baseline backend with no sparse-attention acceleration.';
158
- if (providerMode === 'managed') {
162
+ if (providerMode === 'vertex') {
163
+ rationale = 'Vertex AI secure cloud backend providing compliant enterprise Gemini models inside VPC boundary.';
164
+ } else if (providerMode === 'managed') {
159
165
  rationale = 'Managed API path does not expose sparse-attention kernel controls like IndexCache.';
160
166
  } else if (indexCacheEnabled) {
161
167
  rationale = `Local ${modelFamily} backend is sparse-attention capable and IndexCache-ready on ${serverEngine}.`;
@@ -336,7 +342,8 @@ function resolveModelRole(role, env) {
336
342
  const envKey = `THUMBGATE_MODEL_ROLE_${normalized.toUpperCase()}`;
337
343
  const modelFamily = resolveModelFamily(e);
338
344
  const isLocalGlm = modelFamily.startsWith('glm');
339
- const provider = isLocalGlm ? 'local' : 'gemini';
345
+ const providerMode = resolveProviderMode(e);
346
+ const provider = isLocalGlm ? 'local' : (providerMode === 'vertex' ? 'vertex' : 'gemini');
340
347
  const defaultModel = isLocalGlm ? (GLM_MODEL_ROLES[normalized] || MODEL_ROLES[normalized]) : MODEL_ROLES[normalized];
341
348
  const model = (e[envKey] && String(e[envKey]).trim()) || defaultModel;
342
349
  return { role: normalized, model, provider, envKey };
@@ -377,13 +377,17 @@ async function runMetaAgentLoop({ dryRun = false, verbose = false } = {}) {
377
377
  // measurement (silentFailureDerivedGates vs user-feedback-derived) is possible.
378
378
  candidates = candidates.map((c) => (c.origin ? c : { ...c, origin: 'user-feedback' }));
379
379
 
380
- // Step 3b: Silent-failure clustering — behind THUMBGATE_SILENT_FAILURE_CLUSTERING=1.
381
- // Candidates flow through the SAME scoring / fp-rate eval below; we do not
382
- // bypass any guardrail. Off by default to preserve existing behavior.
380
+ // Step 3b: Silent-failure clustering — DEFAULT-ON as of 2026-05-21
381
+ // (flipped from opt-in by PR #2289). Opt out via
382
+ // THUMBGATE_SILENT_FAILURE_CLUSTERING=0 (or NODE_ENV=test). Candidates flow
383
+ // through the SAME scoring / fp-rate eval below; we do not bypass any
384
+ // guardrail. The point of this clustering is to cover the case where users
385
+ // are lazy and never give thumbs-down — keeping it opt-in meant the users
386
+ // who need it most never got the benefit.
383
387
  let silentFailureStats = null;
384
- if (process.env.THUMBGATE_SILENT_FAILURE_CLUSTERING === '1') {
388
+ const { isSilentFailureClusteringEnabled, generateSilentFailureCandidates } = require('./silent-failure-cluster');
389
+ if (isSilentFailureClusteringEnabled()) {
385
390
  try {
386
- const { generateSilentFailureCandidates } = require('./silent-failure-cluster');
387
391
  const sfResult = generateSilentFailureCandidates({ feedbackLogPath });
388
392
  silentFailureStats = sfResult.stats;
389
393
  if (sfResult.candidates && sfResult.candidates.length > 0) {
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+
3
+ // scripts/noop-detect.js
4
+ //
5
+ // No-op / redundant-action detection for ThumbGate.
6
+ //
7
+ // Given an action's pre/post state (file diff, command exit code + output),
8
+ // this module decides whether the action actually changed anything OR whether
9
+ // it is byte-identical to an attempt already seen this session. Both are strong,
10
+ // cheap "the agent is looping" repeat signals that plug into
11
+ // track_action -> prevention_rules.
12
+ //
13
+ // Design goals:
14
+ // * Pure / file-local. No edits to shared modules from inside here.
15
+ // * Normalize volatile fields (timestamps, shas, ANSI, trailing whitespace)
16
+ // before hashing so non-deterministic output does not defeat detection.
17
+ // * Guard partial writes: a truncated after-state that is a strict prefix of
18
+ // the before-state must NOT be reported as a no-op.
19
+ //
20
+ // Persistence lives beside session-actions.json (derived from
21
+ // gates-engine.SESSION_ACTIONS_PATH) so it picks up THUMBGATE_STATE_DIR
22
+ // overrides and shares the same TTL semantics.
23
+
24
+ const crypto = require('crypto');
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const gatesEngine = require('./gates-engine');
29
+
30
+ // Match the same 1h session window the engine uses for session actions.
31
+ const ATTEMPT_TTL_MS = 60 * 60 * 1000;
32
+
33
+ // -- volatile-field normalization ------------------------------------------
34
+ //
35
+ // Each pattern strips a class of non-deterministic noise so that two outputs
36
+ // which differ only by a timestamp / sha / uuid / ANSI color / trailing
37
+ // whitespace hash-equal. Exported so the test suite can assert the contract.
38
+ const VOLATILE_PATTERNS = [
39
+ // ANSI escape / color codes (e.g. \x1b[0m). Strip first so later patterns
40
+ // see clean text.
41
+ { name: 'ansi', re: /\x1b\[[0-9;]*[A-Za-z]/g, replacement: '' },
42
+ // ISO-8601 timestamps: 2026-05-31T12:34:56(.123)?(Z|+00:00)
43
+ {
44
+ name: 'iso8601',
45
+ re: /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g,
46
+ replacement: '<TS>',
47
+ },
48
+ // UUIDs (dashed form) — must run before the hexblob pattern, otherwise the
49
+ // trailing 12-char hex segment gets rewritten first and the UUID never matches.
50
+ {
51
+ name: 'uuid',
52
+ re: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
53
+ replacement: '<UUID>',
54
+ },
55
+ // Epoch integers wider than 10 digits (ms/ns timestamps) — run before the
56
+ // hexblob pattern so all-decimal epochs are not swallowed as hex first.
57
+ { name: 'epoch', re: /\b\d{11,}\b/g, replacement: '<EPOCH>' },
58
+ // Long hex blobs (commit shas, uuids without dashes, content hashes): 12+ hex chars.
59
+ { name: 'hexblob', re: /\b[0-9a-fA-F]{12,}\b/g, replacement: '<HEX>' },
60
+ // Trailing whitespace is stripped in normalizeVolatile() via a bounded linear
61
+ // scan (not a regex) to avoid super-linear backtracking on adversarial input.
62
+ ];
63
+
64
+ // Strip trailing spaces/tabs from a single line via a bounded reverse scan.
65
+ // Linear in line length; replaces /[ \t]+$/ without any regex backtracking.
66
+ function stripTrailingSpaceTab(line) {
67
+ let end = line.length;
68
+ while (end > 0) {
69
+ const c = line.charCodeAt(end - 1);
70
+ if (c !== 32 && c !== 9) break; // 32 = space, 9 = tab
71
+ end -= 1;
72
+ }
73
+ return line.slice(0, end);
74
+ }
75
+
76
+ // Normalize a string by applying every volatile pattern, then trimming a
77
+ // trailing newline so "foo\n" and "foo" are equivalent.
78
+ function normalizeVolatile(value) {
79
+ if (value === null || value === undefined) return '';
80
+ let out = String(value);
81
+ for (const pattern of VOLATILE_PATTERNS) {
82
+ out = out.replace(pattern.re, pattern.replacement);
83
+ }
84
+ // Strip trailing space/tab per line without a backtracking-prone regex.
85
+ out = out.split('\n').map(stripTrailingSpaceTab).join('\n');
86
+ // Normalize CRLF, then strip the trailing run of newlines via a bounded
87
+ // linear scan (replaces /\n+$/ without regex backtracking).
88
+ out = out.replace(/\r\n/g, '\n');
89
+ let end = out.length;
90
+ while (end > 0 && out.charCodeAt(end - 1) === 10) end -= 1; // 10 = \n
91
+ return out.slice(0, end);
92
+ }
93
+
94
+ function sha256(value) {
95
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
96
+ }
97
+
98
+ // -- state hashing ----------------------------------------------------------
99
+ //
100
+ // computeActionStateHash produces a single stable fingerprint for an action's
101
+ // observable state. For file actions the fingerprint is the normalized content;
102
+ // for command actions it is exit code + normalized stdout/stderr.
103
+ function computeActionStateHash(action = {}) {
104
+ const kind = action.kind === 'command' ? 'command' : 'file';
105
+
106
+ if (kind === 'command') {
107
+ const exitCode = action.exitCode === undefined || action.exitCode === null
108
+ ? ''
109
+ : String(action.exitCode);
110
+ const parts = [
111
+ 'command',
112
+ `exit:${exitCode}`,
113
+ `stdout:${normalizeVolatile(action.stdout)}`,
114
+ `stderr:${normalizeVolatile(action.stderr)}`,
115
+ ];
116
+ return sha256(parts.join('\x00'));
117
+ }
118
+
119
+ // file kind: hash the relevant content (after-content is the canonical state
120
+ // for a single snapshot; for diff comparisons detectNoop compares before/after
121
+ // separately). Include filePath so two unrelated files never collide.
122
+ const content = action.afterContent !== undefined && action.afterContent !== null
123
+ ? action.afterContent
124
+ : action.beforeContent;
125
+ const parts = [
126
+ 'file',
127
+ `path:${action.filePath || ''}`,
128
+ `content:${normalizeVolatile(content)}`,
129
+ ];
130
+ return sha256(parts.join('\x00'));
131
+ }
132
+
133
+ // Hash just a content blob for before/after comparison (no path/kind framing).
134
+ function contentHash(content) {
135
+ return sha256(normalizeVolatile(content));
136
+ }
137
+
138
+ // -- no-op detection --------------------------------------------------------
139
+ //
140
+ // detectNoop({ before, after }) returns { noop, reason }.
141
+ // before/after: { kind, filePath, beforeContent/afterContent OR content,
142
+ // exitCode, stdout, stderr }
143
+ //
144
+ // We accept a flexible shape: the caller may pass a single action object with
145
+ // beforeContent/afterContent, or split before/after sub-objects. Both forms
146
+ // resolve to the same decision.
147
+ function detectNoop(input = {}) {
148
+ const before = input.before || {};
149
+ const after = input.after || {};
150
+
151
+ // Determine kind from whichever side declares it (default file).
152
+ const kind = (before.kind || after.kind || input.kind) === 'command'
153
+ ? 'command'
154
+ : 'file';
155
+
156
+ if (kind === 'command') {
157
+ const beforeExit = before.exitCode;
158
+ const afterExit = after.exitCode;
159
+ if (beforeExit !== undefined && afterExit !== undefined && beforeExit !== afterExit) {
160
+ return { noop: false, reason: 'exit-code-changed' };
161
+ }
162
+ const beforeOut = contentHash(`${normalizeVolatile(before.stdout)}\x00${normalizeVolatile(before.stderr)}`);
163
+ const afterOut = contentHash(`${normalizeVolatile(after.stdout)}\x00${normalizeVolatile(after.stderr)}`);
164
+ if (beforeOut === afterOut) {
165
+ return { noop: true, reason: 'command-output-unchanged' };
166
+ }
167
+ return { noop: false, reason: 'command-output-changed' };
168
+ }
169
+
170
+ // file kind.
171
+ const beforeContent = before.content !== undefined ? before.content
172
+ : (before.beforeContent !== undefined ? before.beforeContent : input.beforeContent);
173
+ const afterContent = after.content !== undefined ? after.content
174
+ : (after.afterContent !== undefined ? after.afterContent : input.afterContent);
175
+
176
+ const beforeStr = beforeContent === undefined || beforeContent === null ? '' : String(beforeContent);
177
+ const afterStr = afterContent === undefined || afterContent === null ? '' : String(afterContent);
178
+
179
+ // Partial-write guard: an after-state that is a strict, shorter prefix of the
180
+ // before-state is a truncation, not a no-op — even though hashes differ, we
181
+ // make the intent explicit so callers never mistake it.
182
+ if (afterStr.length > 0 && afterStr.length < beforeStr.length && beforeStr.startsWith(afterStr)) {
183
+ return { noop: false, reason: 'partial-write-truncation' };
184
+ }
185
+
186
+ if (contentHash(beforeStr) === contentHash(afterStr)) {
187
+ return { noop: true, reason: 'file-content-unchanged' };
188
+ }
189
+ return { noop: false, reason: 'file-content-changed' };
190
+ }
191
+
192
+ // -- attempt persistence ----------------------------------------------------
193
+ //
194
+ // Stores (sessionId -> { "actionId\x00stateHash": timestamp }) so a repeated
195
+ // (actionId, stateHash) tuple within the TTL window is a strong repeat signal.
196
+ function attemptsPath() {
197
+ const sessionActionsPath = gatesEngine.SESSION_ACTIONS_PATH;
198
+ return path.join(path.dirname(sessionActionsPath), 'noop-attempts.json');
199
+ }
200
+
201
+ function loadAttempts() {
202
+ try {
203
+ const raw = fs.readFileSync(attemptsPath(), 'utf8');
204
+ const parsed = JSON.parse(raw);
205
+ if (!parsed || typeof parsed !== 'object') return {};
206
+ return parsed;
207
+ } catch (e) {
208
+ return {};
209
+ }
210
+ }
211
+
212
+ function pruneExpired(store, now) {
213
+ let changed = false;
214
+ for (const sessionId of Object.keys(store)) {
215
+ const bucket = store[sessionId];
216
+ if (!bucket || typeof bucket !== 'object') {
217
+ delete store[sessionId];
218
+ changed = true;
219
+ continue;
220
+ }
221
+ for (const key of Object.keys(bucket)) {
222
+ const ts = bucket[key];
223
+ if (typeof ts !== 'number' || now - ts >= ATTEMPT_TTL_MS) {
224
+ delete bucket[key];
225
+ changed = true;
226
+ }
227
+ }
228
+ if (Object.keys(bucket).length === 0) {
229
+ delete store[sessionId];
230
+ changed = true;
231
+ }
232
+ }
233
+ return changed;
234
+ }
235
+
236
+ function saveAttempts(store) {
237
+ const file = attemptsPath();
238
+ fs.mkdirSync(path.dirname(file), { recursive: true });
239
+ fs.writeFileSync(file, JSON.stringify(store, null, 2) + '\n');
240
+ }
241
+
242
+ function attemptKey(actionId, stateHash) {
243
+ return `${String(actionId)}\x00${String(stateHash)}`;
244
+ }
245
+
246
+ // recordActionAttempt persists that (sessionId, actionId, stateHash) was seen.
247
+ // Returns { recorded: boolean, alreadySeen: boolean }.
248
+ function recordActionAttempt(sessionId, actionId, stateHash) {
249
+ const sid = String(sessionId || 'default');
250
+ const now = Date.now();
251
+ const store = loadAttempts();
252
+ pruneExpired(store, now);
253
+
254
+ if (!store[sid] || typeof store[sid] !== 'object') store[sid] = {};
255
+ const key = attemptKey(actionId, stateHash);
256
+ const alreadySeen = Object.prototype.hasOwnProperty.call(store[sid], key);
257
+ store[sid][key] = now;
258
+ saveAttempts(store);
259
+ return { recorded: true, alreadySeen };
260
+ }
261
+
262
+ // isRepeatAttempt returns true when this exact (actionId, stateHash) tuple was
263
+ // already recorded for this session within the TTL window.
264
+ function isRepeatAttempt(sessionId, actionId, stateHash) {
265
+ const sid = String(sessionId || 'default');
266
+ const now = Date.now();
267
+ const store = loadAttempts();
268
+ const bucket = store[sid];
269
+ if (!bucket || typeof bucket !== 'object') return false;
270
+ const ts = bucket[attemptKey(actionId, stateHash)];
271
+ if (typeof ts !== 'number') return false;
272
+ return now - ts < ATTEMPT_TTL_MS;
273
+ }
274
+
275
+ module.exports = {
276
+ VOLATILE_PATTERNS,
277
+ ATTEMPT_TTL_MS,
278
+ normalizeVolatile,
279
+ computeActionStateHash,
280
+ detectNoop,
281
+ recordActionAttempt,
282
+ isRepeatAttempt,
283
+ // Exposed for tests / introspection.
284
+ attemptsPath,
285
+ };