@blockrun/franklin 3.15.8 → 3.15.10

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.
@@ -111,6 +111,28 @@ export function classifyAgentError(message) {
111
111
  suggestion: 'The model is overloaded. Try /model to switch, or wait and /retry.',
112
112
  };
113
113
  }
114
+ // Reasoning / thinking-mode format errors — NOT transient.
115
+ // DeepSeek V4 family and similar thinking-enabled models reject requests
116
+ // when the message history's reasoning_content fields don't match the
117
+ // upstream's expected shape (typically: tool-call assistant messages must
118
+ // carry reasoning_content; non-tool-call ones must not, or vice versa).
119
+ // The fix is to drop the polluting history, not to swap models — every
120
+ // thinking-enabled model has the same constraint just with different
121
+ // specifics. /clear forces a fresh context that won't have the bad shape.
122
+ // Classified BEFORE the generic schema branch below so we surface the
123
+ // right suggestion.
124
+ if (includesAny(err, [
125
+ 'reasoning_content',
126
+ 'reasoning content',
127
+ 'thinking mode must',
128
+ 'message format incompatible',
129
+ 'reasoning_format_error',
130
+ ])) {
131
+ return {
132
+ category: 'schema', label: 'Schema', isTransient: false, maxRetries: 0,
133
+ suggestion: 'Thinking-mode history is incompatible with this model. Use /clear to reset and retry, or /model to switch to a non-thinking model.',
134
+ };
135
+ }
114
136
  // Schema / tool-definition errors — NOT transient, retrying won't help.
115
137
  // These can be wrapped in 5xx responses (e.g. '503: 400 Invalid schema'),
116
138
  // so classify them BEFORE the generic server-error branch below.
@@ -65,9 +65,36 @@ Flag as tool-use refusal:
65
65
 
66
66
  VERDICT: GROUNDED | PARTIAL | UNGROUNDED
67
67
 
68
- If not GROUNDED, list each issue on its own line starting with "- " and the tool that should have been called, like:
69
- - Claim: "<the ungrounded part, quoted briefly>" → missing tool: <TradingMarket | ExaAnswer | ExaSearch | WebSearch | ...>
70
- - Refusal: "<the refusal phrase, quoted briefly>" should have called: <tool name>
68
+ If not GROUNDED, list each issue on its own line starting with "- " and the tool that should have been called.
69
+
70
+ ## Picking the right tool strict domain rules
71
+
72
+ **Default for any factual claim:** WebSearch or ExaSearch. These are the
73
+ right answer for the OVERWHELMING majority of "the model said a number it
74
+ didn't look up" cases — current events, statistics, prices for non-crypto
75
+ goods (real estate, retail, salaries), people, companies, news, etc.
76
+
77
+ **Use specialized tools ONLY when the claim's domain matches:**
78
+ - TradingMarket / TradingSignal — ONLY for cryptocurrency tickers (BTC, ETH, SOL, etc). Never for stocks, real estate, currencies, commodities outside crypto.
79
+ - DefiLlamaProtocol / DefiLlamaYields / DefiLlamaPrice — ONLY for DeFi protocols, TVL, yields, on-chain token prices.
80
+ - SearchX — ONLY for X.com / Twitter posts and accounts.
81
+ - ExaAnswer — research questions where you want a synthesized answer with citations.
82
+ - WebFetch — claims that quote a SPECIFIC URL the model already named.
83
+
84
+ **Anti-patterns to never produce:**
85
+ - Real-estate price → TradingMarket (TradingMarket is crypto-only — wrong domain)
86
+ - Stock ticker → TradingMarket (also crypto-only — use WebSearch instead)
87
+ - Generic news / statistics → TradingMarket (use WebSearch)
88
+ - Person's biography → TradingMarket (use WebSearch)
89
+
90
+ When unsure: name **WebSearch**. It's the safe default for factual grounding.
91
+
92
+ ## Format examples
93
+
94
+ - Claim: "<the ungrounded part, quoted briefly>" → missing tool: WebSearch
95
+ - Claim: "BTC at $67k" → missing tool: TradingMarket
96
+ - Claim: "Westlake $/sqft is $719" → missing tool: WebSearch
97
+ - Refusal: "<the refusal phrase, quoted briefly>" → should have called: WebSearch
71
98
 
72
99
  Empty line between verdict and list. No other text. No preamble. No apology. Be terse.`;
73
100
  // ─── Trigger policy ──────────────────────────────────────────────────────
@@ -8,6 +8,7 @@ import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchore
8
8
  import { handleSlashCommand } from './commands.js';
9
9
  import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js';
10
10
  import { reduceTokens } from './reduce.js';
11
+ import { redactSecrets, stashSecretsToEnv, formatRedactionWarning } from './secret-redact.js';
11
12
  import { PermissionManager } from './permissions.js';
12
13
  import { StreamingExecutor } from './streaming-executor.js';
13
14
  import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputTokens } from './optimize.js';
@@ -241,6 +242,39 @@ export function looksLikeGatewayErrorAsText(parts) {
241
242
  return { match: false, message: '' };
242
243
  return { match: true, message: m[1].trim() };
243
244
  }
245
+ /**
246
+ * Domain check for the grounding-retry force-tool path. A specialized tool
247
+ * (TradingMarket, DefiLlama*, jupiter*, base0x*, SearchX) should only be
248
+ * pinned by tool_choice when the user prompt actually references that
249
+ * tool's domain — otherwise we let the smart generator pick from any tool.
250
+ *
251
+ * The motivating bug: a real-estate question ("可以还价 20% 吗") had its
252
+ * answer flagged as ungrounded for citing $/sqft figures. The cheap
253
+ * evaluator model picked TradingMarket as the missing tool because it
254
+ * was the first example in the evaluator prompt. Forcing TradingMarket
255
+ * (a crypto-only tool) on a housing question made the retry useless.
256
+ *
257
+ * This function returns false for specialized tools when the prompt has
258
+ * no matching domain keywords; the caller falls back to "any" tool.
259
+ * General-purpose tools (WebSearch, ExaSearch, ExaAnswer, WebFetch,
260
+ * ExaReadUrls) always pass — they're domain-agnostic.
261
+ */
262
+ function isToolRelevantToPrompt(toolName, promptLower) {
263
+ // Crypto trading tools — need a ticker, "crypto", "coin", "swap", etc.
264
+ if (/^(Trading|DefiLlama|Jupiter|Base0x|Base0xGasless)/i.test(toolName)) {
265
+ return /\b(btc|eth|sol|xrp|doge|usdc|usdt|crypto|coin|token|defi|tvl|yield|swap|jupiter|uniswap|pump\.fun|solana|base chain|polygon|ethereum|币|代币|链上|做空|做多)\b/i.test(promptLower);
266
+ }
267
+ // X.com search — need an @handle, "twitter", "tweet", "X.com"
268
+ if (/^SearchX$/i.test(toolName) || /^PostToX$/i.test(toolName)) {
269
+ return /(@\w+|twitter|x\.com|tweet|推特)/i.test(promptLower);
270
+ }
271
+ // Image / video / music gen — need a creative-content request
272
+ if (/^(ImageGen|VideoGen|MusicGen)$/i.test(toolName)) {
273
+ return /\b(image|picture|photo|video|clip|music|song|generate|create|render|draw|画|图|视频|音乐|歌)\b/i.test(promptLower);
274
+ }
275
+ // General-purpose / file / shell tools — always relevant.
276
+ return true;
277
+ }
244
278
  /**
245
279
  * Calculate backoff delay with jitter to avoid thundering herd.
246
280
  * Base: exponential (2^attempt * 1000ms), jitter: ±25%.
@@ -443,6 +477,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
443
477
  input = cmdResult.rewritten;
444
478
  }
445
479
  }
480
+ // ── Secret redaction at the input boundary ──
481
+ // Catch GitHub PATs / API keys / private keys before they enter
482
+ // history, get persisted, or hit the model. Detected values are
483
+ // stashed on process.env (predictable name like GITHUB_TOKEN) so
484
+ // subsequent Bash tool calls can still use them via `$GITHUB_TOKEN`
485
+ // — the user keeps the convenience of "remember this credential"
486
+ // without the chat-history exposure that just happened.
487
+ const { redactedText, matches: secretMatches } = redactSecrets(input);
488
+ if (secretMatches.length > 0) {
489
+ const envVarsSet = stashSecretsToEnv(secretMatches);
490
+ onEvent({
491
+ kind: 'text_delta',
492
+ text: formatRedactionWarning(secretMatches, envVarsSet),
493
+ });
494
+ input = redactedText;
495
+ }
446
496
  lastUserInput = input;
447
497
  // Push the user's clean message; any harness-injected annotations
448
498
  // (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
@@ -1349,11 +1399,23 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1349
1399
  // Hard enforcement: set tool_choice so the model can't fabricate
1350
1400
  // citations in lieu of running tools (the round-2 failure mode
1351
1401
  // from the Tampa→Miami log). If the evaluator named exactly one
1352
- // available tool, pin to it; otherwise force "any" tool use.
1402
+ // available tool AND that tool's domain matches the user's
1403
+ // prompt, pin to it; otherwise force "any" tool use and let
1404
+ // the generator pick the right one.
1405
+ //
1406
+ // Domain validation guards against the cheap evaluator model
1407
+ // hallucinating a wrong specialized tool (e.g., suggesting
1408
+ // TradingMarket for a real-estate question because the prompt
1409
+ // listed it as the first example tool). Specialized tools —
1410
+ // crypto trading, DeFi, swap quotes, X.com search — only get
1411
+ // pinned when their domain keywords appear in the user prompt;
1412
+ // otherwise we drop down to "any tool" and let the smart
1413
+ // generator model decide based on tool descriptions.
1353
1414
  const namedTools = extractMissingToolNames(gResult);
1354
1415
  const availableNames = new Set(buildCallToolDefs().map(t => t.name));
1355
1416
  const matched = namedTools.filter(n => availableNames.has(n));
1356
- if (matched.length === 1) {
1417
+ const promptForDomainCheck = (lastUserInput || '').toLowerCase();
1418
+ if (matched.length === 1 && isToolRelevantToPrompt(matched[0], promptForDomainCheck)) {
1357
1419
  forceToolChoiceNextRound = { type: 'tool', name: matched[0] };
1358
1420
  }
1359
1421
  else if (availableNames.size > 0) {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Secret detection + redaction for user-submitted text.
3
+ *
4
+ * Why this exists: a user pasted a GitHub PAT (`ghp_...`) directly into
5
+ * chat as a way to give Franklin authenticated access to the GitHub API.
6
+ * The model correctly refused to use the raw value and warned the user,
7
+ * but by then the token had already entered:
8
+ * - the LLM API request body (sent to the gateway + upstream provider)
9
+ * - the persisted session file on disk
10
+ * - any later compaction summary (which would re-send it to the model)
11
+ *
12
+ * What the user actually wants is for Franklin to **remember the credential
13
+ * and keep using it**, not refuse it. So this module's job is two-fold:
14
+ *
15
+ * 1. Strip the raw value out of the conversation so it never reaches
16
+ * the model, history, or disk.
17
+ * 2. Stash it on `process.env` under a predictable name so subsequent
18
+ * Bash / WebFetch tool calls can reference it via `$GITHUB_TOKEN`,
19
+ * `$ANTHROPIC_API_KEY`, etc. — no chat round-trip needed.
20
+ *
21
+ * Conservative pattern set: each entry matches a token format with an
22
+ * unambiguous prefix + length, so false positives are rare. Anything that
23
+ * could plausibly be a normal long string (random hex, base64 blobs) is
24
+ * deliberately not in here. False positives are worse than missed
25
+ * detections — silently mangling a hex hash a user pasted would be
26
+ * confusing and there's no recovery path.
27
+ */
28
+ export interface RedactionMatch {
29
+ label: string;
30
+ description: string;
31
+ /** First 4 chars of the secret + ellipsis — for user-facing display. */
32
+ preview: string;
33
+ /** Suggested env var name (e.g. GITHUB_TOKEN). */
34
+ envVar: string;
35
+ /** The actual secret value. INTERNAL USE ONLY — never log this. */
36
+ value: string;
37
+ }
38
+ export interface RedactionResult {
39
+ /** Input with each secret replaced by [REDACTED:label]. */
40
+ redactedText: string;
41
+ /** What got redacted. Includes raw `value` for the caller to stash. */
42
+ matches: RedactionMatch[];
43
+ }
44
+ /**
45
+ * Scan `input` for secret patterns and return a redacted copy plus a
46
+ * description of what was caught. Secrets are replaced with the literal
47
+ * string `[REDACTED:<label>]` so the model can still see *something* was
48
+ * there (helpful when the user's message refers to the token in context),
49
+ * just not the value.
50
+ *
51
+ * No I/O, no logging — pure transformation. The caller decides how to
52
+ * surface the warning to the user and whether to stash values on
53
+ * process.env (recommended for CLI usage so Bash tool calls can reference
54
+ * $GITHUB_TOKEN etc).
55
+ */
56
+ export declare function redactSecrets(input: string): RedactionResult;
57
+ /**
58
+ * Stash matched secrets onto `process.env` so subsequent tool calls
59
+ * (Bash, WebFetch with `$GITHUB_TOKEN`-style references) can use them
60
+ * without the value ever round-tripping through chat history.
61
+ *
62
+ * Returns the names of env vars that were set, deduped, in stash order.
63
+ * Safe to call on an empty match list (no-op).
64
+ */
65
+ export declare function stashSecretsToEnv(matches: RedactionMatch[]): string[];
66
+ /**
67
+ * Build a one-paragraph warning + usage hint to surface to the user when
68
+ * their input had secrets redacted and stashed. Names what was caught
69
+ * (with previews, never values) and tells them how to actually use the
70
+ * stashed credential going forward.
71
+ */
72
+ export declare function formatRedactionWarning(matches: RedactionMatch[], envVarsSet: string[]): string;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Secret detection + redaction for user-submitted text.
3
+ *
4
+ * Why this exists: a user pasted a GitHub PAT (`ghp_...`) directly into
5
+ * chat as a way to give Franklin authenticated access to the GitHub API.
6
+ * The model correctly refused to use the raw value and warned the user,
7
+ * but by then the token had already entered:
8
+ * - the LLM API request body (sent to the gateway + upstream provider)
9
+ * - the persisted session file on disk
10
+ * - any later compaction summary (which would re-send it to the model)
11
+ *
12
+ * What the user actually wants is for Franklin to **remember the credential
13
+ * and keep using it**, not refuse it. So this module's job is two-fold:
14
+ *
15
+ * 1. Strip the raw value out of the conversation so it never reaches
16
+ * the model, history, or disk.
17
+ * 2. Stash it on `process.env` under a predictable name so subsequent
18
+ * Bash / WebFetch tool calls can reference it via `$GITHUB_TOKEN`,
19
+ * `$ANTHROPIC_API_KEY`, etc. — no chat round-trip needed.
20
+ *
21
+ * Conservative pattern set: each entry matches a token format with an
22
+ * unambiguous prefix + length, so false positives are rare. Anything that
23
+ * could plausibly be a normal long string (random hex, base64 blobs) is
24
+ * deliberately not in here. False positives are worse than missed
25
+ * detections — silently mangling a hex hash a user pasted would be
26
+ * confusing and there's no recovery path.
27
+ */
28
+ const SECRET_PATTERNS = [
29
+ // ── GitHub ──
30
+ // Personal access tokens, OAuth tokens, app installation/user-to-server
31
+ // tokens, fine-grained PATs. All have unique prefixes the user can verify.
32
+ {
33
+ label: 'github_pat',
34
+ pattern: /\bghp_[A-Za-z0-9]{36,}\b/g,
35
+ description: 'GitHub personal access token',
36
+ envVar: 'GITHUB_TOKEN',
37
+ },
38
+ {
39
+ label: 'github_oauth',
40
+ pattern: /\bgho_[A-Za-z0-9]{36,}\b/g,
41
+ description: 'GitHub OAuth token',
42
+ envVar: 'GITHUB_TOKEN',
43
+ },
44
+ {
45
+ label: 'github_app',
46
+ pattern: /\bghs_[A-Za-z0-9]{36,}\b/g,
47
+ description: 'GitHub App installation token',
48
+ envVar: 'GITHUB_TOKEN',
49
+ },
50
+ {
51
+ label: 'github_user',
52
+ pattern: /\bghu_[A-Za-z0-9]{36,}\b/g,
53
+ description: 'GitHub user-to-server token',
54
+ envVar: 'GITHUB_TOKEN',
55
+ },
56
+ {
57
+ label: 'github_pat_fine',
58
+ pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g,
59
+ description: 'GitHub fine-grained PAT',
60
+ envVar: 'GITHUB_TOKEN',
61
+ },
62
+ // ── Anthropic ──
63
+ {
64
+ label: 'anthropic_api',
65
+ pattern: /\bsk-ant-(?:api|admin)\d+-[A-Za-z0-9_-]{20,}\b/g,
66
+ description: 'Anthropic API key',
67
+ envVar: 'ANTHROPIC_API_KEY',
68
+ },
69
+ // ── OpenAI ──
70
+ // sk-proj- (project keys, current format) is unambiguous. The legacy
71
+ // `sk-` + 48 chars format is more ambiguous so we require ≥48 chars to
72
+ // avoid clashing with normal short hex strings.
73
+ {
74
+ label: 'openai_project',
75
+ pattern: /\bsk-proj-[A-Za-z0-9_-]{40,}\b/g,
76
+ description: 'OpenAI project API key',
77
+ envVar: 'OPENAI_API_KEY',
78
+ },
79
+ {
80
+ label: 'openai_api',
81
+ pattern: /\bsk-[A-Za-z0-9]{48,}\b/g,
82
+ description: 'OpenAI API key',
83
+ envVar: 'OPENAI_API_KEY',
84
+ },
85
+ // ── AWS ──
86
+ {
87
+ label: 'aws_access_key',
88
+ pattern: /\bAKIA[0-9A-Z]{16}\b/g,
89
+ description: 'AWS access key ID',
90
+ envVar: 'AWS_ACCESS_KEY_ID',
91
+ },
92
+ // AWS secret keys are 40 base64 chars but have no prefix — too ambiguous
93
+ // to match safely. Skipped on purpose.
94
+ // ── Google ──
95
+ {
96
+ label: 'google_api',
97
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
98
+ description: 'Google Cloud / Firebase API key',
99
+ envVar: 'GOOGLE_API_KEY',
100
+ },
101
+ // ── Slack ──
102
+ {
103
+ label: 'slack_token',
104
+ pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
105
+ description: 'Slack token',
106
+ envVar: 'SLACK_TOKEN',
107
+ },
108
+ // ── Stripe ──
109
+ {
110
+ label: 'stripe_live',
111
+ pattern: /\bsk_live_[A-Za-z0-9]{20,}\b/g,
112
+ description: 'Stripe live secret key',
113
+ envVar: 'STRIPE_SECRET_KEY',
114
+ },
115
+ {
116
+ label: 'stripe_test',
117
+ pattern: /\bsk_test_[A-Za-z0-9]{20,}\b/g,
118
+ description: 'Stripe test secret key',
119
+ envVar: 'STRIPE_SECRET_KEY',
120
+ },
121
+ // ── Twilio ──
122
+ {
123
+ label: 'twilio_account',
124
+ pattern: /\bAC[a-f0-9]{32}\b/g,
125
+ description: 'Twilio account SID',
126
+ envVar: 'TWILIO_ACCOUNT_SID',
127
+ },
128
+ // ── Private keys ──
129
+ // Multi-line PEM blocks. Match begin/end markers + content. envVar:
130
+ // PRIVATE_KEY is generic — if a user has multiple they'll need to rename.
131
+ {
132
+ label: 'private_key',
133
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----/g,
134
+ description: 'PEM private key',
135
+ envVar: 'PRIVATE_KEY',
136
+ },
137
+ // ── Cryptocurrency private keys ──
138
+ // 64-char hex strings preceded by an obvious key context. Standalone hex
139
+ // strings are too ambiguous (could be hashes) to redact silently.
140
+ {
141
+ label: 'eth_private_key',
142
+ pattern: /\b(?:private[_\s-]?key|priv[_\s-]?key|secret[_\s-]?key)\s*[:=]\s*0x[a-fA-F0-9]{64}\b/gi,
143
+ description: 'Ethereum-style private key',
144
+ envVar: 'WALLET_PRIVATE_KEY',
145
+ },
146
+ ];
147
+ /**
148
+ * Scan `input` for secret patterns and return a redacted copy plus a
149
+ * description of what was caught. Secrets are replaced with the literal
150
+ * string `[REDACTED:<label>]` so the model can still see *something* was
151
+ * there (helpful when the user's message refers to the token in context),
152
+ * just not the value.
153
+ *
154
+ * No I/O, no logging — pure transformation. The caller decides how to
155
+ * surface the warning to the user and whether to stash values on
156
+ * process.env (recommended for CLI usage so Bash tool calls can reference
157
+ * $GITHUB_TOKEN etc).
158
+ */
159
+ export function redactSecrets(input) {
160
+ let redactedText = input;
161
+ const matches = [];
162
+ // Dedupe by exact value, not by label — a user might paste two distinct
163
+ // GitHub tokens and we should preserve both for the env-var stash even
164
+ // though the description would read identically.
165
+ const seenValues = new Set();
166
+ for (const { label, pattern, description, envVar } of SECRET_PATTERNS) {
167
+ // Reset lastIndex on each pattern — RegExp objects with /g preserve it
168
+ // across calls, which would skip matches if the same regex were reused.
169
+ pattern.lastIndex = 0;
170
+ let match;
171
+ while ((match = pattern.exec(input)) !== null) {
172
+ const secret = match[0];
173
+ if (seenValues.has(secret))
174
+ continue;
175
+ seenValues.add(secret);
176
+ matches.push({
177
+ label,
178
+ description,
179
+ preview: secret.slice(0, 4) + '…',
180
+ envVar,
181
+ value: secret,
182
+ });
183
+ // Replace every occurrence of this exact secret in redactedText.
184
+ // Using the literal secret as a search string handles the common
185
+ // case where the same token appears multiple times in one message.
186
+ redactedText = redactedText.split(secret).join(`[REDACTED:${label}]`);
187
+ }
188
+ }
189
+ return { redactedText, matches };
190
+ }
191
+ /**
192
+ * Stash matched secrets onto `process.env` so subsequent tool calls
193
+ * (Bash, WebFetch with `$GITHUB_TOKEN`-style references) can use them
194
+ * without the value ever round-tripping through chat history.
195
+ *
196
+ * Returns the names of env vars that were set, deduped, in stash order.
197
+ * Safe to call on an empty match list (no-op).
198
+ */
199
+ export function stashSecretsToEnv(matches) {
200
+ const set = [];
201
+ for (const m of matches) {
202
+ // Don't clobber an env var the user has already exported in their
203
+ // shell — their existing value is presumably the right one and
204
+ // accidentally overwriting it could cause silent breakage.
205
+ if (process.env[m.envVar] && process.env[m.envVar] !== m.value) {
206
+ continue;
207
+ }
208
+ process.env[m.envVar] = m.value;
209
+ if (!set.includes(m.envVar))
210
+ set.push(m.envVar);
211
+ }
212
+ return set;
213
+ }
214
+ /**
215
+ * Build a one-paragraph warning + usage hint to surface to the user when
216
+ * their input had secrets redacted and stashed. Names what was caught
217
+ * (with previews, never values) and tells them how to actually use the
218
+ * stashed credential going forward.
219
+ */
220
+ export function formatRedactionWarning(matches, envVarsSet) {
221
+ if (matches.length === 0)
222
+ return '';
223
+ const list = matches
224
+ .map((m) => `• ${m.description} (${m.preview}) → \`$${m.envVar}\``)
225
+ .join('\n');
226
+ const skipped = matches.length - envVarsSet.length;
227
+ const skippedNote = skipped > 0
228
+ ? `\n_(${skipped} value${skipped === 1 ? '' : 's'} not stashed because the env var was already set in your shell — your existing export is preserved.)_`
229
+ : '';
230
+ return (`\n⚠️ **Secret detected, redacted from chat, and stashed for this session.**\n` +
231
+ `${list}${skippedNote}\n\n` +
232
+ `The raw value never reached the model, the conversation history, or the session file. ` +
233
+ `Tool calls (Bash / WebFetch) can use the env var name — for example: ` +
234
+ `\`gh api user --header "Authorization: Bearer $GITHUB_TOKEN"\`.\n\n` +
235
+ `**This still counts as exposed.** Anything you typed is in your terminal scrollback, ` +
236
+ `and any prior session file may have captured it before this redaction was added. ` +
237
+ `Treat the secret as compromised and rotate it now.\n\n` +
238
+ `Next time, set the credential via shell export before launching Franklin ` +
239
+ `(\`export GITHUB_TOKEN=...\`) instead of pasting it into chat.\n`);
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.8",
3
+ "version": "3.15.10",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {