@blockrun/franklin 3.15.76 → 3.15.78

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.
@@ -52,6 +52,23 @@ export interface LLMClientOptions {
52
52
  chain: Chain;
53
53
  debug?: boolean;
54
54
  }
55
+ /**
56
+ * Replace Unicode box-drawing characters with their ASCII equivalents.
57
+ *
58
+ * Models occasionally emit U+2502 (`│`) and U+2500 (`─`) in markdown tables
59
+ * — sometimes mixed with ASCII `|` / `-` in the same table. No markdown
60
+ * renderer parses the mix, and the "table" displays as run-on text. Verified
61
+ * 2026-05-06 in a real session: opus-4.7 emitted a CRCL fundamentals table
62
+ * with `│` data rows and `|` separator, ignoring the system-prompt nudge
63
+ * added in 3.15.76. The unconditional swap fixes the rendering at the
64
+ * streaming boundary so every downstream surface (user terminal, conversation
65
+ * history, audit log) gets the corrected version.
66
+ *
67
+ * Trade: the rare case where a user genuinely wants box-drawing in output
68
+ * (e.g. asking what U+2502 looks like) loses fidelity. Acceptable — that
69
+ * case has no real-world frequency, the broken-tables case has weekly.
70
+ */
71
+ export declare function sanitizeTableUnicode(s: string): string;
55
72
  /**
56
73
  * Extract the most human-readable message from an error body.
57
74
  * Some gateways wrap provider errors multiple times, e.g.
package/dist/agent/llm.js CHANGED
@@ -28,6 +28,27 @@ function parseTimeoutEnv(name) {
28
28
  const parsed = raw ? Number.parseInt(raw, 10) : NaN;
29
29
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
30
30
  }
31
+ /**
32
+ * Replace Unicode box-drawing characters with their ASCII equivalents.
33
+ *
34
+ * Models occasionally emit U+2502 (`│`) and U+2500 (`─`) in markdown tables
35
+ * — sometimes mixed with ASCII `|` / `-` in the same table. No markdown
36
+ * renderer parses the mix, and the "table" displays as run-on text. Verified
37
+ * 2026-05-06 in a real session: opus-4.7 emitted a CRCL fundamentals table
38
+ * with `│` data rows and `|` separator, ignoring the system-prompt nudge
39
+ * added in 3.15.76. The unconditional swap fixes the rendering at the
40
+ * streaming boundary so every downstream surface (user terminal, conversation
41
+ * history, audit log) gets the corrected version.
42
+ *
43
+ * Trade: the rare case where a user genuinely wants box-drawing in output
44
+ * (e.g. asking what U+2502 looks like) loses fidelity. Acceptable — that
45
+ * case has no real-world frequency, the broken-tables case has weekly.
46
+ */
47
+ export function sanitizeTableUnicode(s) {
48
+ if (!s)
49
+ return s;
50
+ return s.replace(/│/g, '|').replace(/─/g, '-');
51
+ }
31
52
  function getModelRequestTimeoutMs() {
32
53
  // 180s budget for *time-to-headers* (the gateway flushes SSE headers only
33
54
  // once the upstream model emits its first token). Reasoning-class models
@@ -545,6 +566,15 @@ export class ModelClient {
545
566
  const appendText = (text) => {
546
567
  if (!text)
547
568
  return;
569
+ // Sanitize Unicode box-drawing chars to ASCII pipe/dash. 3.15.76's
570
+ // system-prompt nudge asked models not to emit U+2502 / U+2500 in
571
+ // tables — opus-4.7 ignored it 2026-05-06, shipped a CRCL analysis
572
+ // table where data rows used `│` and the separator used `|`. No
573
+ // markdown renderer parses that mix; the table displayed as run-on
574
+ // text. Normalize at the streaming boundary so the user, the model
575
+ // history (next turn the model sees its own corrected output), and
576
+ // the audit log all match.
577
+ text = sanitizeTableUnicode(text);
548
578
  currentText += text;
549
579
  if (textEmission.mode === 'undecided') {
550
580
  const trimmed = currentText.trimStart();
@@ -177,6 +177,32 @@ function sanitizeHistory(history) {
177
177
  * Detect media-related errors (image too large, too many images, PDF too large).
178
178
  * These can be recovered by stripping media blocks and retrying.
179
179
  */
180
+ /**
181
+ * True when the assistant's last emitted text segment ends with a question
182
+ * mark (ASCII `?` or fullwidth `?`). Used to render an end-of-turn marker
183
+ * so users don't read the post-question silence as "Franklin died." Trim
184
+ * trailing whitespace + closing punctuation that doesn't change intent
185
+ * (newlines, single closing quote/paren) before checking.
186
+ */
187
+ function endedWithQuestion(parts) {
188
+ if (!parts || parts.length === 0)
189
+ return false;
190
+ // Walk back to the last text segment. Skip thinking/tool_use parts.
191
+ for (let i = parts.length - 1; i >= 0; i--) {
192
+ const p = parts[i];
193
+ if (p.type !== 'text')
194
+ continue;
195
+ const text = p.text;
196
+ if (typeof text !== 'string')
197
+ return false;
198
+ // Strip trailing whitespace + the ~3 closing chars that commonly
199
+ // follow a question without changing it (")", "'", "\"", "*", ")",
200
+ // "*", whitespace).
201
+ const trimmed = text.replace(/[\s)\]'"*`)]+$/u, '');
202
+ return /[??]$/.test(trimmed);
203
+ }
204
+ return false;
205
+ }
180
206
  function isMediaSizeError(msg) {
181
207
  return ((msg.includes('image exceeds') && msg.includes('maximum')) ||
182
208
  (msg.includes('image dimensions exceed')) ||
@@ -1737,6 +1763,17 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1737
1763
  if (lastRoutedCategory && lastRoutedModel) {
1738
1764
  recordOutcome(lastRoutedCategory, lastRoutedModel, 'continued', turnToolCalls);
1739
1765
  }
1766
+ // End-of-turn marker for question-shaped responses. Real-world UX
1767
+ // problem 2026-05-06: agent finishes a turn with "要我查一下 X 吗?"
1768
+ // and stops; the user reads the silence as "Franklin died" twice in
1769
+ // one hour. The Ink input box is already on screen but it's easy to
1770
+ // miss after a long output scroll. A single trailing italic line
1771
+ // makes the wait state explicit. Only fires when the model's last
1772
+ // emitted text ends with `?` or `?` so non-question turns don't
1773
+ // get a noisy hint.
1774
+ if (endedWithQuestion(responseParts)) {
1775
+ onEvent({ kind: 'text_delta', text: '\n*▸ awaiting your reply (or type a new message)*\n' });
1776
+ }
1740
1777
  onEvent({ kind: 'turn_done', reason: 'completed' });
1741
1778
  break;
1742
1779
  }
@@ -16,6 +16,38 @@ function formatUsd(n) {
16
16
  return `$${(n / 1e3).toFixed(1)}K`;
17
17
  return `$${n.toFixed(2)}`;
18
18
  }
19
+ /**
20
+ * US-listed equity tickers that ALSO have meaningful tokenized listings on-chain.
21
+ * When TradingSignal is called with one of these, the crypto-leg data we return
22
+ * is the tokenized variant — not the spot equity. We surface a notice in the
23
+ * output so the agent knows to also pull TradingMarket stockPrice market='us'
24
+ * for the equity side, and can compute the basis spread (premium/discount of
25
+ * tokenized vs spot — that spread is real alpha for some flows).
26
+ *
27
+ * Conservative list: high-liquidity US equities that have shown up as actively
28
+ * traded tokenized variants. Add more as they materialize. Verified 2026-05-06
29
+ * via a real session where the agent asked TradingSignal for CRCL, got the
30
+ * tokenized $0-cap leg back, and correctly recovered to "ignore this, pull
31
+ * Pyth" — but the user lost an extra $0.005 + a confused turn before recovery.
32
+ */
33
+ const KNOWN_DUAL_LISTED_EQUITIES = new Set([
34
+ 'CRCL', // Circle Internet Group
35
+ 'COIN', // Coinbase
36
+ 'MSTR', // Strategy (formerly MicroStrategy)
37
+ 'PLTR', // Palantir
38
+ 'TSLA', // Tesla
39
+ 'AAPL', // Apple
40
+ 'NVDA', // NVIDIA
41
+ 'MSFT', // Microsoft
42
+ 'AMZN', // Amazon
43
+ 'GOOGL', // Alphabet
44
+ 'META', // Meta
45
+ 'JPM', // JPMorgan Chase
46
+ 'BRK', // Berkshire Hathaway (BRK.A / BRK.B)
47
+ 'HOOD', // Robinhood
48
+ 'SQ', // Block
49
+ 'PYPL', // PayPal
50
+ ]);
19
51
  // MACD needs slow EMA (26) + signal EMA (9) = 35 closes minimum for the
20
52
  // signal/histogram to be defined. Default was 30, which left signal=NaN
21
53
  // and trend stuck at 'neutral' on every call — see the 2026-05-03 BTC
@@ -126,9 +158,17 @@ async function executeSignal(input, _ctx) {
126
158
  bullSignals.push('price below lower Bollinger');
127
159
  if (Number.isFinite(bbResult.middle) && bbResult.position === 'above')
128
160
  bearSignals.push('price above upper Bollinger');
161
+ // Dual-listing notice: prepend before the body when the ticker is also a
162
+ // known US equity. Doesn't suppress the crypto/tokenized data — that data
163
+ // is its own legitimate signal — just labels it correctly so the agent
164
+ // knows to also fetch the spot equity for the basis spread.
165
+ const dualListingNote = KNOWN_DUAL_LISTED_EQUITIES.has(upper)
166
+ ? `> ⚠ \`${upper}\` is also a US-listed equity. The data below is the **crypto / tokenized leg** (CoinGecko). For the spot equity (NYSE / NASDAQ) call \`TradingMarket\` with \`action: stockPrice, market: "us"\`. Run both in parallel to compute the basis spread (premium/discount of tokenized vs spot — that spread is the signal).\n`
167
+ : '';
129
168
  const output = [
130
169
  `## ${upper} Signal Report`,
131
170
  '',
171
+ ...(dualListingNote ? [dualListingNote] : []),
132
172
  `**Price:** $${price.toLocaleString()} USD (${change24h > 0 ? '+' : ''}${change24h.toFixed(2)}% 24h)`,
133
173
  `**Market Cap:** ${formatUsd(marketCap)}`,
134
174
  `**24h Volume:** ${formatUsd(volume24h)}`,
@@ -153,7 +193,7 @@ async function executeSignal(input, _ctx) {
153
193
  export const tradingSignalCapability = {
154
194
  spec: {
155
195
  name: 'TradingSignal',
156
- description: 'Get current price, technical indicators (RSI, MACD, Bollinger Bands, volatility), and a verdict (bullish / bearish / neutral with confidence) for a cryptocurrency. Always returns a Verdict section with bull/bear signal lists — echo it directly. When MACD signal/histogram report "insufficient data", say so explicitly; do NOT default to "wait and see".',
196
+ description: 'Get current price, technical indicators (RSI, MACD, Bollinger Bands, volatility), and a verdict (bullish / bearish / neutral with confidence) for a cryptocurrency. Always returns a Verdict section with bull/bear signal lists — echo it directly. When MACD signal/histogram report "insufficient data", say so explicitly; do NOT default to "wait and see". For tickers that ALSO trade as US equities (CRCL, COIN, MSTR, TSLA, AAPL, NVDA, etc.) the response includes a dual-listing note: TradingSignal returns the tokenized/crypto leg, and you should fire TradingMarket stockPrice market="us" in parallel to also get the spot equity. The basis spread between the two is itself the signal.',
157
197
  input_schema: {
158
198
  type: 'object',
159
199
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.76",
3
+ "version": "3.15.78",
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": {