dsh-tacit 0.2.3 → 0.4.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/client/client.js CHANGED
@@ -50,6 +50,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
50
50
  'status.autoOn': '自动学习已开启 · 今日 {today}/{budget}',
51
51
  'status.autoOff': '自动学习已关闭 — 仅在你点击「分析」时学习。',
52
52
  'status.autoHint': '混乱的轮次(重试、工具错误、压缩)以及你纠正智能体的下一条消息会被自动分析,无需点击。',
53
+ 'status.trendCorrections': '你纠正智能体:{a} → {b}',
53
54
  'status.trendMessy': '混乱轮次:{a} → {b}',
54
55
  'status.trendTokens': '每轮 tokens:{a} → {b}',
55
56
  'status.trendHint': '(最早 {n} 轮 vs 最近 {n} 轮,真实数据)',
@@ -64,7 +65,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
64
65
  'settings.learnGood': '也从混乱之后的顺利提示词中学习',
65
66
  'bootstrap.btn': '从我最近 20 轮中学习',
66
67
  'bootstrap.running': '学习中… {done}/{total}',
67
- 'bootstrap.hint': ' $0.02–0.05,一次性。已顺利完成的提示词会被跳过。',
68
+ 'bootstrap.runningUsd': '学习中… {done}/{total} · {usd}',
69
+ 'bootstrap.hint': '分析你最近完成的轮次:提示词至少 8 个字符、不是单纯的「继续」类消息(「继续」「好的」…)、且还没有报告——最多 20 条。',
70
+ 'bootstrap.estimateDoc': '使用 deepseek-v4-flash 约 $0.02–0.05,一次性。',
71
+ 'bootstrap.preview': '有 {eligible} 轮符合条件 · 按目录价约 {usd}',
72
+ 'bootstrap.previewMeasured': '(根据你最近 {samples} 次分析的实测数据)',
73
+ 'bootstrap.previewDoc': '(文档估算——还没有足够的实测分析)',
68
74
  'steer.enrich': '发送前补充学到的上下文(实验性,每次发送一次小调用)',
69
75
  'steer.title': '智能体被告知的关于你的信息',
70
76
  'steer.desc': '这些指令会注入每个新会话的系统提示词,让智能体替你补上你通常没说的内容。',
@@ -75,6 +81,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
75
81
  'steer.remove': '删除',
76
82
  'steer.preview': '实际注入的文本',
77
83
  'steer.distilled': '自动提炼',
84
+ 'steer.queued': '等待试用',
78
85
  'steer.trial': '试用 {n}/{total}',
79
86
  'steer.active': '已生效',
80
87
  'steer.retired': '已淘汰 · {reason}',
@@ -132,6 +139,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
132
139
  'preview.improved': '改进后',
133
140
  'preview.rationale': '改动说明',
134
141
  'preview.pending': '正在分析草稿…',
142
+ 'preview.unchanged': '草稿已经完整,无需修改。',
135
143
  'preview.apply': '应用改进',
136
144
  'preview.cancel': '取消',
137
145
  'feedback.title': '这次改进有帮助吗?',
@@ -142,6 +150,133 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
142
150
  'feedback.noted': '已记录,谢谢!',
143
151
  'panel.styleRules': '已学习的风格规则',
144
152
  'panel.styleRulesEmpty': '还没有学习到风格规则——应用改进后给出 👎 反馈(附一句话原因),累计 3 次即可提炼出规则。',
153
+ 'card.overview': '概览',
154
+ 'card.usage': '用量',
155
+ 'card.pricing': '价格',
156
+ 'card.learning': '学习',
157
+ 'card.guidance': '智能体指令',
158
+ 'card.improve': '改进与反馈',
159
+ 'card.history': '分析历史',
160
+ 'card.privacy': '数据与隐私',
161
+ 'card.expand': '展开这一节',
162
+ 'card.collapse': '收起这一节',
163
+ 'overview.model': '模型:{model}',
164
+ 'usage.empty': '还没有计费调用——用量从 {since} 开始记录。',
165
+ 'usage.today': '今日',
166
+ 'usage.month': '本月',
167
+ 'usage.last30': '最近 30 天',
168
+ 'usage.since': '自 {day} 起',
169
+ 'usage.calls': '计费调用(30 天)',
170
+ 'usage.avgAnalysis': '单次分析中位成本',
171
+ 'usage.cachedRate': '输入缓存命中率(30 天)',
172
+ 'usage.unpriced': '无价目调用(30 天)',
173
+ 'usage.unpricedShort': '{n} 次无价目',
174
+ 'usage.priceUnavailable': '无价目',
175
+ 'usage.chartLabel': '每日花费,最近 {n} 天',
176
+ 'usage.chartSummary': '最近 {n} 天:合计 {total};最高 {day} {max}',
177
+ 'usage.barTitle': '{day} · {usd} · {calls} 次调用',
178
+ 'usage.series7': '7 天',
179
+ 'usage.series30': '30 天',
180
+ 'usage.breakdown': '按操作分类(最近 30 天)',
181
+ 'usage.filters': '筛选',
182
+ 'usage.loading': '正在载入这次运行的调用明细…',
183
+ 'usage.page': '第 {page} / {pages} 页',
184
+ 'usage.prev': '上一页',
185
+ 'usage.next': '下一页',
186
+ 'usage.label': '真实用量 · 按目录价计算',
187
+ 'usage.col.time': '时间',
188
+ 'usage.col.op': '操作',
189
+ 'usage.col.scope': '范围',
190
+ 'usage.scopeAll': '全部会话',
191
+ 'usage.col.model': '模型',
192
+ 'usage.col.status': '状态',
193
+ 'usage.col.calls': '计费调用',
194
+ 'usage.col.tokens': 'Tokens',
195
+ 'usage.col.cost': '花费',
196
+ 'filter.range': '时间范围',
197
+ 'filter.type': '操作',
198
+ 'filter.status': '状态',
199
+ 'filter.model': '模型',
200
+ 'filter.workspace': '工作区',
201
+ 'filter.session': '会话 ID',
202
+ 'filter.all': '全部',
203
+ 'range.today': '今日',
204
+ 'range.7d': '最近 7 天',
205
+ 'range.30d': '最近 30 天',
206
+ 'range.month': '本月',
207
+ 'range.all': '全部保留期',
208
+ 'runtype.bootstrap': '引导批量',
209
+ 'runtype.analysis': '分析',
210
+ 'runtype.analysis-batch': '批量分析',
211
+ 'runtype.improve': '改进',
212
+ 'runtype.directive-distillation': '指令提炼',
213
+ 'runtype.style-distillation': '风格提炼',
214
+ 'runtype.prompt-enrichment': '提示词补充',
215
+ 'op.analysis': '分析',
216
+ 'op.analysis-repair': '分析修复',
217
+ 'op.directive-distillation': '指令提炼',
218
+ 'op.style-distillation': '风格提炼',
219
+ 'op.improve': '改进',
220
+ 'op.improve-repair': '改进修复',
221
+ 'op.enrichment': '上下文补充',
222
+ 'status.success': '成功',
223
+ 'status.partial': '部分成功',
224
+ 'status.failed': '失败',
225
+ 'status.ok': '成功',
226
+ 'status.unmetered': '未计量',
227
+ 'status.running': '进行中',
228
+ 'attempt.effort': '推理强度 {effort}',
229
+ 'attempt.finish': '结束 {finish} {code}',
230
+ 'attempt.source': '{source} · {tier}',
231
+ 'attempt.duration': '{ms} 毫秒',
232
+ 'warn.daily': '今日 {spent} / 每日上限 {limit}({pct}%)',
233
+ 'warn.monthly': '本月 {spent} / 每月上限 {limit}({pct}%)',
234
+ 'improve.explain': '「改进」会在你发送前重写草稿。给某次重写点 👎 并写一句原因,Tacit 会从中提炼出持久的风格规则。',
235
+ 'privacy.stored': '分析报告、指令和风格规则只保存在本机的 Tacit 数据目录里。一条用量记录只包含:时间、操作、会话 ID、轮次编号、工作区名称、模型、服务商、token 计数与目录价——绝不包含你的提示词或模型回复的内容。清除操作只会删除 Tacit 自己的文件。',
236
+ 'privacy.retention': '详细用量记录保留天数',
237
+ 'privacy.warnDaily': '每日花费提醒阈值(美元)',
238
+ 'privacy.warnMonthly': '每月花费提醒阈值(美元)',
239
+ 'privacy.warnHint': '填 0 表示关闭提醒。达到阈值的 80% 时开始提醒,超过阈值后标记为已超出。',
240
+ 'privacy.clearUsage': '清除用量记录',
241
+ 'privacy.apply': '应用',
242
+ 'privacy.applyTo': '{action}:{field}',
243
+ 'confirm.reportsTitle': '清除所有分析报告?',
244
+ 'confirm.reportsBody': '这会删除 Tacit 为所有会话写下的全部分析报告。指令、风格规则和用量记录会保留。此操作无法撤销。',
245
+ 'confirm.usageTitle': '清除用量记录?',
246
+ 'confirm.usageBody': '这会删除 Tacit 的用量账本——每一次记录的调用、它的 token 计数和目录价——并重新开始计量窗口。分析报告和指令会保留。此操作无法撤销。',
247
+ 'confirm.cancel': '取消',
248
+ 'confirm.clear': '清除',
249
+ 'pricing.summary': '{model} {tier} {rates} / 100 万 · {source}({asOf})',
250
+ 'pricing.rateTable': '每 100 万 tokens 的目录价',
251
+ 'pricing.model': '模型',
252
+ 'pricing.tier': '时段',
253
+ 'pricing.cacheHit': '缓存命中输入',
254
+ 'pricing.cacheMiss': '输入',
255
+ 'pricing.output': '输出',
256
+ 'pricing.reasoning': '推理',
257
+ 'pricing.reasoningSameAsOutput': '按输出计费',
258
+ 'pricing.peak': '高峰',
259
+ 'pricing.offPeak': '非高峰',
260
+ 'pricing.tierNow': '当前时段:{tier}',
261
+ 'pricing.schedule': '高峰时段为 UTC 01:00–04:00 与 06:00–10:00,其余时间均为非高峰;时段按调用开始的时刻判定。',
262
+ 'pricing.weekendRule': '自 2026-08-22 16:00 UTC 起,北京日历的周末(UTC+8 的周六与周日)全天按非高峰计费。',
263
+ 'pricing.source': '价目来源',
264
+ 'pricing.sourceBundled': '内置价目表',
265
+ 'pricing.sourceCostMeter': 'cost-meter 服务',
266
+ 'pricing.refreshedAt': '刷新于 {time}',
267
+ 'pricing.never': '从未刷新',
268
+ 'pricing.formula': '费用 =(输入 × 输入价 + 缓存命中输入 × 缓存价 + 输出 × 输出价)÷ 1,000,000',
269
+ 'pricing.accuracy': '目录价;以服务商账单为准',
270
+ 'pricing.refresh': '刷新价目',
271
+ 'pricing.refreshing': '刷新中…',
272
+ 'pricing.error': '无法读取价目来源:{error}',
273
+ 'notice.bootstrap': '引导完成 · 已分析 {analyzed} 条 · 跳过 {skipped} 条 · {calls} 次计费调用 · {tokens} tokens · 目录价 {usd}',
274
+ 'notice.analyze': '已分析 #{turn} · {calls} 次调用 · {tokens} tokens · {usd}',
275
+ 'notice.improve': '已改进草稿 · {calls} 次调用 · {tokens} tokens · {usd}',
276
+ 'notice.batch': '已分析 {analyzed}/{requested} 条 · {calls} 次调用 · {tokens} tokens · {usd}',
277
+ 'notice.usageCleared': '用量记录已清除 · 删除 {n} 天的记录 · 计量窗口重新开始',
278
+ 'notice.pricingRefreshed': '价目已刷新 · {source}({asOf})',
279
+ 'notice.unpriced': ' · {n} 次无价目',
145
280
  'err.bad-request': '请求无效。',
146
281
  'err.bad-json': '请求体无效。',
147
282
  'err.no-session': '当前会话不可用(可能尚未在本进程恢复)。',
@@ -149,6 +284,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
149
284
  'err.busy': '该轮次的分析已在运行中。',
150
285
  'err.no-llm': '无法访问模型服务。',
151
286
  'err.no-api-key': 'DeepSeek API Key 缺失或无效(请在 设置 → 模型 中配置)。',
287
+ 'err.no-credit': '模型服务商余额或配额已耗尽。',
152
288
  'err.rate-limited': '请求被限流,请稍后重试。',
153
289
  'err.timeout': '模型调用超时,请重试。',
154
290
  'err.empty-response': '模型没有返回内容,请重试。',
@@ -164,6 +300,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
164
300
  'status.autoOn': 'Auto-learning on · {today}/{budget} today',
165
301
  'status.autoOff': 'Auto-learning off — Tacit only learns when you click Analyze.',
166
302
  'status.autoHint': 'Messy turns (retries, tool errors, compactions) and turns you correct in your next message are analyzed automatically — no clicks.',
303
+ 'status.trendCorrections': 'You corrected the agent: {a} → {b}',
167
304
  'status.trendMessy': 'Messy turns: {a} → {b}',
168
305
  'status.trendTokens': 'Tokens/turn: {a} → {b}',
169
306
  'status.trendHint': '(first {n} turns vs. latest {n}, measured)',
@@ -178,7 +315,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
178
315
  'settings.learnGood': 'Also learn from a clean prompt right after a messy turn',
179
316
  'bootstrap.btn': 'Learn from my last 20 turns',
180
317
  'bootstrap.running': 'Learning… {done}/{total}',
181
- 'bootstrap.hint': ' $0.02–0.05, one time. Skips prompts that already went fine on their own.',
318
+ 'bootstrap.runningUsd': 'Learning… {done}/{total} · {usd}',
319
+ 'bootstrap.hint': 'Analyzes your newest finished turns whose prompt is at least 8 characters, is not a bare continuation (“continue”, “ok”…), and has no report yet — up to 20.',
320
+ 'bootstrap.estimateDoc': '≈ $0.02–0.05 with deepseek-v4-flash, one time',
321
+ 'bootstrap.preview': '{eligible} turn(s) eligible · ≈{usd} at list price',
322
+ 'bootstrap.previewMeasured': '(measured from your last {samples} analyses)',
323
+ 'bootstrap.previewDoc': '(documented estimate — not enough measured analyses yet)',
182
324
  'steer.enrich': 'Add learned context before each send (experimental — one small call per send)',
183
325
  'steer.title': 'What the agent is told about you',
184
326
  'steer.desc': 'These directives ride every new session\'s system prompt so the agent fills in what you usually leave unsaid.',
@@ -189,6 +331,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
189
331
  'steer.remove': 'Remove',
190
332
  'steer.preview': 'Exact text injected',
191
333
  'steer.distilled': 'distilled',
334
+ 'steer.queued': 'waiting for trial',
192
335
  'steer.trial': 'trial {n}/{total}',
193
336
  'steer.active': 'active',
194
337
  'steer.retired': 'retired · {reason}',
@@ -246,6 +389,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
246
389
  'preview.improved': 'Improved',
247
390
  'preview.rationale': 'What changed',
248
391
  'preview.pending': 'Analyzing your draft…',
392
+ 'preview.unchanged': 'Your draft is already complete — nothing to change.',
249
393
  'preview.apply': 'Apply improvement',
250
394
  'preview.cancel': 'Cancel',
251
395
  'feedback.title': 'Was this better?',
@@ -256,6 +400,133 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
256
400
  'feedback.noted': 'Noted — thanks!',
257
401
  'panel.styleRules': 'Learned style rules',
258
402
  'panel.styleRulesEmpty': 'No style rules yet — apply an improvement and rate it 👎 with a one-line reason; after 3, Tacit distills durable rules from them.',
403
+ 'card.overview': 'Overview',
404
+ 'card.usage': 'Usage',
405
+ 'card.pricing': 'Pricing',
406
+ 'card.learning': 'Learning',
407
+ 'card.guidance': 'Agent guidance',
408
+ 'card.improve': 'Improve & feedback',
409
+ 'card.history': 'Analysis history',
410
+ 'card.privacy': 'Data & privacy',
411
+ 'card.expand': 'Expand this section',
412
+ 'card.collapse': 'Collapse this section',
413
+ 'overview.model': 'Model: {model}',
414
+ 'usage.empty': 'No billed calls yet — usage has been tracked since {since}.',
415
+ 'usage.today': 'Today',
416
+ 'usage.month': 'This month',
417
+ 'usage.last30': 'Last 30 days',
418
+ 'usage.since': 'Since {day}',
419
+ 'usage.calls': 'Billed calls (30 d)',
420
+ 'usage.avgAnalysis': 'Median cost per analysis',
421
+ 'usage.cachedRate': 'Cached input rate (30 d)',
422
+ 'usage.unpriced': 'Unpriced calls (30 d)',
423
+ 'usage.unpricedShort': '{n} unpriced',
424
+ 'usage.priceUnavailable': 'No price',
425
+ 'usage.chartLabel': 'Daily spend, last {n} days',
426
+ 'usage.chartSummary': 'Last {n} days: total {total}; highest {day} {max}',
427
+ 'usage.barTitle': '{day} · {usd} · {calls} calls',
428
+ 'usage.series7': '7 d',
429
+ 'usage.series30': '30 d',
430
+ 'usage.breakdown': 'By operation (last 30 days)',
431
+ 'usage.filters': 'Filters',
432
+ 'usage.loading': 'Loading this run\u2019s calls…',
433
+ 'usage.page': 'Page {page} of {pages}',
434
+ 'usage.prev': 'Previous',
435
+ 'usage.next': 'Next',
436
+ 'usage.label': 'Measured usage · list-price cost',
437
+ 'usage.col.time': 'Time',
438
+ 'usage.col.op': 'Operation',
439
+ 'usage.col.scope': 'Scope',
440
+ 'usage.scopeAll': 'all sessions',
441
+ 'usage.col.model': 'Model',
442
+ 'usage.col.status': 'Status',
443
+ 'usage.col.calls': 'Billed calls',
444
+ 'usage.col.tokens': 'Tokens',
445
+ 'usage.col.cost': 'Cost',
446
+ 'filter.range': 'Range',
447
+ 'filter.type': 'Operation',
448
+ 'filter.status': 'Status',
449
+ 'filter.model': 'Model',
450
+ 'filter.workspace': 'Workspace',
451
+ 'filter.session': 'Session id',
452
+ 'filter.all': 'All',
453
+ 'range.today': 'Today',
454
+ 'range.7d': 'Last 7 days',
455
+ 'range.30d': 'Last 30 days',
456
+ 'range.month': 'This month',
457
+ 'range.all': 'Everything retained',
458
+ 'runtype.bootstrap': 'Bootstrap batch',
459
+ 'runtype.analysis': 'Analysis',
460
+ 'runtype.analysis-batch': 'Analysis batch',
461
+ 'runtype.improve': 'Improve',
462
+ 'runtype.directive-distillation': 'Directive distillation',
463
+ 'runtype.style-distillation': 'Style distillation',
464
+ 'runtype.prompt-enrichment': 'Prompt enrichment',
465
+ 'op.analysis': 'analysis',
466
+ 'op.analysis-repair': 'analysis repair',
467
+ 'op.directive-distillation': 'directive distillation',
468
+ 'op.style-distillation': 'style distillation',
469
+ 'op.improve': 'improve',
470
+ 'op.improve-repair': 'improve repair',
471
+ 'op.enrichment': 'enrichment',
472
+ 'status.success': 'success',
473
+ 'status.partial': 'partial',
474
+ 'status.failed': 'failed',
475
+ 'status.ok': 'ok',
476
+ 'status.unmetered': 'unmetered',
477
+ 'status.running': 'running',
478
+ 'attempt.effort': 'effort {effort}',
479
+ 'attempt.finish': 'finish {finish} {code}',
480
+ 'attempt.source': '{source} · {tier}',
481
+ 'attempt.duration': '{ms} ms',
482
+ 'warn.daily': 'Today {spent} of the {limit} daily cap ({pct}%)',
483
+ 'warn.monthly': 'This month {spent} of the {limit} monthly cap ({pct}%)',
484
+ 'improve.explain': 'Improve rewrites your draft before you send it. Rate a rewrite 👎 with a one-line reason and Tacit distills a durable style rule from it.',
485
+ 'privacy.stored': 'Analysis reports, directives and style rules are stored on this machine only, in Tacit’s own data directory. A usage record holds the time, the operation, the session id, the turn number, the workspace name, the model, the provider, token counts and the list price — never your prompt or the response text. Clearing only ever removes Tacit’s own files.',
486
+ 'privacy.retention': 'Keep detailed usage history (days)',
487
+ 'privacy.warnDaily': 'Warn above this daily spend (USD)',
488
+ 'privacy.warnMonthly': 'Warn above this monthly spend (USD)',
489
+ 'privacy.warnHint': '0 turns the warning off. Tacit warns at 80 % of the amount and marks it exceeded above it.',
490
+ 'privacy.clearUsage': 'Clear usage history',
491
+ 'privacy.apply': 'Apply',
492
+ 'privacy.applyTo': '{action}: {field}',
493
+ 'confirm.reportsTitle': 'Clear all analysis reports?',
494
+ 'confirm.reportsBody': 'This deletes every analysis report Tacit has written, for every session. Your directives, style rules and usage history are kept. This cannot be undone.',
495
+ 'confirm.usageTitle': 'Clear usage history?',
496
+ 'confirm.usageBody': 'This deletes Tacit’s usage ledger — every recorded call, its token counts and its list price — and restarts the tracking window. Your analysis reports and directives are kept. This cannot be undone.',
497
+ 'confirm.cancel': 'Cancel',
498
+ 'confirm.clear': 'Clear',
499
+ 'pricing.summary': '{model} {tier} {rates} per 1M · {source} ({asOf})',
500
+ 'pricing.rateTable': 'List price per 1M tokens',
501
+ 'pricing.model': 'Model',
502
+ 'pricing.tier': 'Tier',
503
+ 'pricing.cacheHit': 'Cached input',
504
+ 'pricing.cacheMiss': 'Input',
505
+ 'pricing.output': 'Output',
506
+ 'pricing.reasoning': 'Reasoning',
507
+ 'pricing.reasoningSameAsOutput': 'billed as output',
508
+ 'pricing.peak': 'peak',
509
+ 'pricing.offPeak': 'off-peak',
510
+ 'pricing.tierNow': 'Right now: {tier}',
511
+ 'pricing.schedule': 'Peak is 01:00–04:00 and 06:00–10:00 UTC; every other hour is off-peak. The tier is decided when a call starts.',
512
+ 'pricing.weekendRule': 'Beijing-calendar weekends (Saturday and Sunday in UTC+8) are off-peak all day, from 2026-08-22 16:00 UTC.',
513
+ 'pricing.source': 'Price source',
514
+ 'pricing.sourceBundled': 'bundled table',
515
+ 'pricing.sourceCostMeter': 'cost-meter service',
516
+ 'pricing.refreshedAt': 'refreshed {time}',
517
+ 'pricing.never': 'never refreshed',
518
+ 'pricing.formula': 'Cost = (input × input rate + cached input × cached rate + output × output rate) ÷ 1,000,000',
519
+ 'pricing.accuracy': 'List price; your provider invoice is the billing authority',
520
+ 'pricing.refresh': 'Refresh prices',
521
+ 'pricing.refreshing': 'Refreshing…',
522
+ 'pricing.error': 'Could not read the price source: {error}',
523
+ 'notice.bootstrap': 'Bootstrap complete · {analyzed} analyzed · {skipped} skipped · {calls} billed calls · {tokens} tokens · {usd} list price',
524
+ 'notice.analyze': 'Analyzed #{turn} · {calls} call(s) · {tokens} tokens · {usd}',
525
+ 'notice.improve': 'Improved your draft · {calls} call(s) · {tokens} tokens · {usd}',
526
+ 'notice.batch': 'Analyzed {analyzed} of {requested} · {calls} calls · {tokens} tokens · {usd}',
527
+ 'notice.usageCleared': 'Usage history cleared · {n} day file(s) removed · tracking restarts now',
528
+ 'notice.pricingRefreshed': 'Prices refreshed · {source} ({asOf})',
529
+ 'notice.unpriced': ' · {n} unpriced',
259
530
  'err.bad-request': 'Invalid request.',
260
531
  'err.bad-json': 'Invalid request body.',
261
532
  'err.no-session': 'The session is not available (it may not be restored in this process).',
@@ -263,6 +534,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
263
534
  'err.busy': 'An analysis for this turn is already running.',
264
535
  'err.no-llm': 'The model service is unavailable.',
265
536
  'err.no-api-key': 'The DeepSeek API key is missing or invalid (configure it in Settings → Models).',
537
+ 'err.no-credit': 'Your provider balance or quota is exhausted.',
266
538
  'err.rate-limited': 'The request was rate-limited; try again shortly.',
267
539
  'err.timeout': 'The model call timed out; please retry.',
268
540
  'err.empty-response': 'The model returned no content; please retry.',
@@ -372,6 +644,29 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
372
644
  trend: null, // measured early-vs-recent trend
373
645
  bootstrap: null, // {running, done, total}
374
646
  coached: [], // cross-session coached-prompt entries
647
+ // Which Settings cards are expanded. Open state lives here (not in React
648
+ // state) so it survives re-renders and is seedable from the SSR suite.
649
+ sections: {
650
+ overview: true,
651
+ usage: true,
652
+ pricing: false,
653
+ learning: false,
654
+ guidance: false,
655
+ improve: false,
656
+ history: false,
657
+ privacy: false,
658
+ },
659
+ notice: null, // {text} — a short-lived result line, cleared after 5s
660
+ // A destructive action waiting on its confirmation dialog:
661
+ // null | {kind:'reports'|'usage'}.
662
+ confirm: null,
663
+ preview: null, // the last /bootstrap-preview envelope
664
+ usage: null, // the last /usage envelope
665
+ usageFilters: { range: '30d', type: '', status: '', model: '', workspace: '', sessionId: '', page: 1, pageSize: 20 },
666
+ usageRuns: {}, // runId → the full run (attempts included), fetched on first expand
667
+ usageExpanded: new Set(), // runIds whose attempt rows are open
668
+ usageSeries: '30', // '7' | '30' — which sparkline the strip shows
669
+ pricingRefreshing: false, // a /pricing-refresh call is in flight
375
670
  initStarted: false,
376
671
  initDone: false,
377
672
  error: null,
@@ -382,6 +677,215 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
382
677
  for (const listener of rootStore.listeners) listener()
383
678
  }
384
679
 
680
+ /** Expand/collapse one Settings card; unknown ids are ignored. */
681
+ function toggleSection(id) {
682
+ const key = String(id)
683
+ if (rootStore.sections === null || typeof rootStore.sections !== 'object'
684
+ || typeof rootStore.sections[key] !== 'boolean') return
685
+ rootStore.sections[key] = !rootStore.sections[key]
686
+ notifyRoot()
687
+ }
688
+
689
+ let noticeTimer = null
690
+
691
+ /**
692
+ * Show one result line (e.g. "Bootstrap complete · 7 analyzed"). It clears
693
+ * itself after 5s; the timer is skipped where there is no scheduler (SSR).
694
+ */
695
+ function setRootNotice(text) {
696
+ rootStore.notice = typeof text === 'string' && text.length > 0 ? { text } : null
697
+ notifyRoot()
698
+ if (typeof setTimeout !== 'function' || rootStore.notice === null) return
699
+ if (noticeTimer !== null && typeof clearTimeout === 'function') clearTimeout(noticeTimer)
700
+ noticeTimer = setTimeout(() => {
701
+ noticeTimer = null
702
+ rootStore.notice = null
703
+ notifyRoot()
704
+ }, 5000)
705
+ unrefTimer(noticeTimer)
706
+ }
707
+
708
+ /**
709
+ * Node hands back a Timeout object that keeps the process alive; the
710
+ * browser hands back a number. Unref'ing where it exists means a test that
711
+ * merely touches one of these paths can never hold the runner open.
712
+ */
713
+ function unrefTimer(handle) {
714
+ if (handle !== null && typeof handle === 'object' && typeof handle.unref === 'function') handle.unref()
715
+ }
716
+
717
+ /**
718
+ * The bound translator, captured once when the plugin applies.
719
+ *
720
+ * A notice states what a call actually cost, so it is written where those
721
+ * figures land — inside the store actions. Those run far from any `kit`
722
+ * (a click in the conversation tab, a poll), and there is exactly one
723
+ * translator per plugin instance, so it is held here rather than threaded
724
+ * through every call site.
725
+ */
726
+ let translate = null
727
+
728
+ function setTranslator(fn) {
729
+ translate = typeof fn === 'function' ? fn : null
730
+ }
731
+
732
+ /**
733
+ * A result line with the run's measured figures: billed calls, total
734
+ * tokens and list-price cost. A run that priced nothing says so instead of
735
+ * claiming `$0.0000`, and the count of unpriced calls rides as a suffix —
736
+ * an unmetered call has no price, not a price of zero.
737
+ */
738
+ function runNotice(t, key, vars, run) {
739
+ const source = run !== null && typeof run === 'object' ? run : {}
740
+ const count = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
741
+ const totals = {
742
+ // `attempts` rides along so a run whose every call went unmetered
743
+ // reads as an unknown cost rather than a free one.
744
+ attempts: count(source.attempts),
745
+ billedCalls: count(source.billedCalls),
746
+ unpricedCalls: count(source.unpricedCalls),
747
+ usdKnown: count(source.usdKnown),
748
+ }
749
+ const text = t(key, {
750
+ ...(vars === null || typeof vars !== 'object' ? {} : vars),
751
+ calls: fmtTokens(totals.billedCalls),
752
+ tokens: fmtTokens(tokensTotal(source.tokens)),
753
+ usd: usageSpend({ t }, totals),
754
+ })
755
+ return totals.unpricedCalls > 0 ? text + t('notice.unpriced', { n: String(totals.unpricedCalls) }) : text
756
+ }
757
+
758
+ /**
759
+ * Record a run's result line on a session store, where the conversation
760
+ * tab's live region reads it. It stays on that store: a tab action has no
761
+ * business announcing itself on the Settings page.
762
+ */
763
+ function setStoreNotice(store, key, vars, run) {
764
+ if (translate === null || store === null || typeof store !== 'object') return
765
+ store.notice = { code: key, text: runNotice(translate, key, vars, run) }
766
+ }
767
+
768
+ // ── Usage ledger (the Settings → Usage card) ───────────────────────────
769
+
770
+ /** The filter payload for `/usage`: defaults always, empty strings never. */
771
+ function usageQuery() {
772
+ const filters = rootStore.usageFilters !== null && typeof rootStore.usageFilters === 'object' ? rootStore.usageFilters : {}
773
+ const query = {
774
+ range: typeof filters.range === 'string' && filters.range.length > 0 ? filters.range : '30d',
775
+ page: typeof filters.page === 'number' && filters.page > 0 ? Math.floor(filters.page) : 1,
776
+ pageSize: typeof filters.pageSize === 'number' && filters.pageSize > 0 ? Math.floor(filters.pageSize) : 20,
777
+ }
778
+ for (const key of ['type', 'status', 'model', 'workspace', 'sessionId']) {
779
+ const value = filters[key]
780
+ if (typeof value === 'string' && value.length > 0) query[key] = value
781
+ }
782
+ return query
783
+ }
784
+
785
+ /** Read the whole cost panel in one call; a failure keeps the last envelope. */
786
+ async function fetchUsage() {
787
+ try {
788
+ const result = await api('/usage', usageQuery())
789
+ if (result !== null && typeof result === 'object' && result.ok === true) rootStore.usage = result
790
+ } catch {
791
+ // A stale panel beats a blank one; the next poll tries again.
792
+ }
793
+ notifyRoot()
794
+ }
795
+
796
+ /** Narrow the runs list. Any change but an explicit page jump goes back to page 1. */
797
+ function setUsageFilter(patch) {
798
+ if (patch === null || typeof patch !== 'object') return
799
+ const next = { ...rootStore.usageFilters, ...patch }
800
+ if (patch.page === undefined) next.page = 1
801
+ rootStore.usageFilters = next
802
+ notifyRoot()
803
+ fetchUsage()
804
+ }
805
+
806
+ /** Open/close one run's attempt rows, fetching its detail the first time. */
807
+ async function toggleUsageRun(runId) {
808
+ const key = String(runId)
809
+ if (key.length === 0) return
810
+ if (rootStore.usageExpanded.has(key)) {
811
+ rootStore.usageExpanded.delete(key)
812
+ notifyRoot()
813
+ return
814
+ }
815
+ rootStore.usageExpanded.add(key)
816
+ notifyRoot()
817
+ if (rootStore.usageRuns[key] !== undefined) return
818
+ try {
819
+ const result = await api('/usage-run', { runId: key })
820
+ if (result !== null && typeof result === 'object' && result.ok === true
821
+ && result.run !== null && typeof result.run === 'object') {
822
+ rootStore.usageRuns[key] = result.run
823
+ }
824
+ } catch {
825
+ // The row keeps its loading line; closing and reopening retries.
826
+ }
827
+ notifyRoot()
828
+ }
829
+
830
+ /**
831
+ * Re-read the prices from the cost-meter service. The card renders the
832
+ * rates the `/usage` envelope carries, so the refreshed table only shows
833
+ * once that envelope has been re-fetched.
834
+ */
835
+ async function refreshPricing(t) {
836
+ if (rootStore.pricingRefreshing) return
837
+ rootStore.pricingRefreshing = true
838
+ rootStore.error = null
839
+ notifyRoot()
840
+ try {
841
+ const result = await api('/pricing-refresh', {})
842
+ if (result !== null && typeof result === 'object' && result.ok === true) {
843
+ const pricing = result.pricing !== null && typeof result.pricing === 'object' ? result.pricing : {}
844
+ if (typeof t === 'function') {
845
+ setRootNotice(t('notice.pricingRefreshed', {
846
+ source: t(pricing.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled'),
847
+ asOf: typeof pricing.asOf === 'string' && pricing.asOf.length > 0 ? pricing.asOf : '—',
848
+ }))
849
+ }
850
+ } else {
851
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
852
+ }
853
+ } catch (error) {
854
+ rootStore.error = errorOf(error)
855
+ } finally {
856
+ rootStore.pricingRefreshing = false
857
+ }
858
+ await fetchUsage()
859
+ }
860
+
861
+ /** Which sparkline the bar strip shows; anything unknown means 30 days. */
862
+ function setUsageSeries(value) {
863
+ rootStore.usageSeries = value === '7' ? '7' : '30'
864
+ notifyRoot()
865
+ }
866
+
867
+ /** One shared 10s poll, reference-counted so remounts never stack timers. */
868
+ let usageTimer = null
869
+ let usageMounts = 0
870
+
871
+ function startUsagePolling() {
872
+ usageMounts += 1
873
+ if (usageTimer !== null || typeof setInterval !== 'function') return
874
+ usageTimer = setInterval(() => {
875
+ // A hidden tab costs nothing: the next visible tick catches up.
876
+ if (typeof document !== 'undefined' && document !== null && document.hidden === true) return
877
+ fetchUsage()
878
+ }, 10000)
879
+ unrefTimer(usageTimer)
880
+ }
881
+
882
+ function stopUsagePolling() {
883
+ usageMounts = usageMounts > 0 ? usageMounts - 1 : 0
884
+ if (usageMounts > 0 || usageTimer === null) return
885
+ if (typeof clearInterval === 'function') clearInterval(usageTimer)
886
+ usageTimer = null
887
+ }
888
+
385
889
  function useRootVersion() {
386
890
  const [version, setVersion] = useState(0)
387
891
  useEffect(() => {
@@ -420,6 +924,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
420
924
  rootStore.error = errorOf(error)
421
925
  rootStore.initDone = true
422
926
  }
927
+ fetchUsage()
423
928
  notifyRoot()
424
929
  }
425
930
 
@@ -438,6 +943,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
438
943
  } catch {
439
944
  // Stale view beats a broken panel.
440
945
  }
946
+ fetchUsage()
441
947
  notifyRoot()
442
948
  }
443
949
 
@@ -456,6 +962,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
456
962
  } catch (error) {
457
963
  rootStore.error = errorOf(error)
458
964
  }
965
+ // The warning thresholds live in the config, so the bars may have moved.
966
+ fetchUsage()
459
967
  notifyRoot()
460
968
  }
461
969
 
@@ -471,6 +979,78 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
471
979
  } catch (error) {
472
980
  rootStore.error = errorOf(error)
473
981
  }
982
+ fetchUsage()
983
+ // Turns without a report are eligible again, so a preview that last read
984
+ // `eligible: 0` would otherwise keep the Bootstrap button disabled.
985
+ await fetchBootstrapPreview()
986
+ notifyRoot()
987
+ }
988
+
989
+ /**
990
+ * Delete the usage ledger and restart the tracking window. The runs the
991
+ * panel had expanded are dropped with it — their ids no longer exist.
992
+ */
993
+ async function clearUsageHistory() {
994
+ rootStore.error = null
995
+ try {
996
+ const result = await api('/usage-clear', {})
997
+ if (result !== null && typeof result === 'object' && result.ok) {
998
+ rootStore.usageRuns = {}
999
+ rootStore.usageExpanded = new Set()
1000
+ rootStore.usageFilters = { ...rootStore.usageFilters, page: 1 }
1001
+ if (translate !== null) setRootNotice(translate('notice.usageCleared', { n: fmtTokens(usageNum(result.removed)) }))
1002
+ } else {
1003
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
1004
+ }
1005
+ } catch (error) {
1006
+ rootStore.error = errorOf(error)
1007
+ }
1008
+ await fetchUsage()
1009
+ // The measured basis of the bootstrap estimate came from the ledger this
1010
+ // just deleted; re-price it rather than quoting figures that are gone.
1011
+ await fetchBootstrapPreview()
1012
+ notifyRoot()
1013
+ }
1014
+
1015
+ /**
1016
+ * Ask before deleting. The two clears are the only irreversible actions in
1017
+ * the panel, so neither fires straight off a click: the kind is parked here
1018
+ * and the dialog does the asking.
1019
+ */
1020
+ function openConfirm(kind) {
1021
+ if (kind !== 'reports' && kind !== 'usage') return
1022
+ // Captured before the dialog steals focus, so closing it can hand focus
1023
+ // back to the button that opened it.
1024
+ captureConfirmOpener()
1025
+ rootStore.confirm = { kind }
1026
+ notifyRoot()
1027
+ }
1028
+
1029
+ function closeConfirm() {
1030
+ rootStore.confirm = null
1031
+ notifyRoot()
1032
+ }
1033
+
1034
+ /** Run the parked destructive action, whichever it is, and close the dialog. */
1035
+ async function confirmAction() {
1036
+ const kind = rootStore.confirm !== null && typeof rootStore.confirm === 'object' ? rootStore.confirm.kind : null
1037
+ rootStore.confirm = null
1038
+ notifyRoot()
1039
+ if (kind === 'reports') await clearAllRoot()
1040
+ else if (kind === 'usage') await clearUsageHistory()
1041
+ }
1042
+
1043
+ /**
1044
+ * "What would Bootstrap do, and what would it cost?" — read-only, no model
1045
+ * call. A failure leaves `preview` alone: the documented estimate stands in.
1046
+ */
1047
+ async function fetchBootstrapPreview() {
1048
+ try {
1049
+ const result = await api('/bootstrap-preview', { limit: 20 })
1050
+ if (result !== null && typeof result === 'object' && result.ok === true) rootStore.preview = result
1051
+ } catch {
1052
+ // One missing hint line; the next mount tries again.
1053
+ }
474
1054
  notifyRoot()
475
1055
  }
476
1056
 
@@ -517,10 +1097,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
517
1097
  notify(store)
518
1098
  }
519
1099
 
520
- async function bootstrapAll() {
1100
+ /** `t` is the caller's bound translator, used only for the result notice. */
1101
+ async function bootstrapAll(t) {
521
1102
  if (rootStore.bootstrap !== null && typeof rootStore.bootstrap === 'object' && rootStore.bootstrap.running) return
522
1103
  rootStore.bootstrap = { running: true, done: 0, total: 0 }
523
1104
  rootStore.error = null
1105
+ rootStore.notice = null
524
1106
  notifyRoot()
525
1107
  pollBootstrap((state) => {
526
1108
  if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') rootStore.bootstrap = state.bootstrap
@@ -528,7 +1110,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
528
1110
  })
529
1111
  try {
530
1112
  const result = await api('/bootstrap', { limit: 20 })
531
- if (!(result !== null && typeof result === 'object' && result.ok)) {
1113
+ if (result !== null && typeof result === 'object' && result.ok) {
1114
+ if (typeof t === 'function') {
1115
+ setRootNotice(runNotice(t, 'notice.bootstrap', {
1116
+ analyzed: String(typeof result.analyzed === 'number' ? result.analyzed : 0),
1117
+ skipped: String(typeof result.skipped === 'number' ? result.skipped : 0),
1118
+ }, result.run))
1119
+ }
1120
+ } else {
532
1121
  rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
533
1122
  }
534
1123
  } catch (error) {
@@ -536,7 +1125,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
536
1125
  }
537
1126
  rootStore.initStarted = false
538
1127
  rootStore.initDone = false
1128
+ // `initRootStore` refetches the ledger itself, so there is no `fetchUsage`
1129
+ // here: a second call would only duplicate the one the batch already
1130
+ // makes worth issuing.
539
1131
  await initRootStore()
1132
+ // Fewer turns are eligible now, and the ledger has fresh measurements to
1133
+ // price the next batch from.
1134
+ await fetchBootstrapPreview()
540
1135
  }
541
1136
 
542
1137
  async function editDirectives(payload) {
@@ -552,6 +1147,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
552
1147
  } catch (error) {
553
1148
  rootStore.error = errorOf(error)
554
1149
  }
1150
+ fetchUsage()
555
1151
  notifyRoot()
556
1152
  }
557
1153
 
@@ -583,12 +1179,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
583
1179
  store.inFlight[String(turn)] = true
584
1180
  store.expanded.add(turn)
585
1181
  store.error = null
1182
+ store.notice = null
586
1183
  notify(store)
587
1184
  api('/analyze', { sessionId: store.sessionId, turn })
588
1185
  .then((result) => {
589
1186
  if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
590
1187
  store.reports[String(turn)] = result.report
591
1188
  store.error = null
1189
+ // What that one analysis cost, from the envelope's own run record.
1190
+ setStoreNotice(store, 'notice.analyze', { turn: String(turn) }, result.run)
592
1191
  } else {
593
1192
  store.error = {
594
1193
  code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
@@ -608,54 +1207,68 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
608
1207
  })
609
1208
  }
610
1209
 
611
- /** Run one /analyze call and settle when it finishes (batch building block). */
612
- function analyzeTurnAsync(store, turn) {
613
- return new Promise((resolve) => {
614
- store.inFlight[String(turn)] = true
615
- notify(store)
616
- api('/analyze', { sessionId: store.sessionId, turn })
617
- .then((result) => {
618
- if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
619
- store.reports[String(turn)] = result.report
620
- store.selection.delete(turn)
621
- store.expanded.add(turn)
622
- store.error = null
623
- } else {
624
- store.error = {
625
- code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
626
- detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
627
- }
628
- }
629
- if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
630
- store.profile = result.profile
631
- }
632
- })
633
- .catch((error) => {
634
- store.error = errorOf(error)
635
- })
636
- .finally(() => {
637
- delete store.inFlight[String(turn)]
638
- notify(store)
639
- resolve()
640
- })
641
- })
642
- }
643
-
644
- /** Coach every ticked prompt sequentially (the user-chosen 20). */
1210
+ /**
1211
+ * Coach every ticked prompt in ONE `/analyze-batch` call.
1212
+ *
1213
+ * The host runs the whole selection under a single ledger run, so the tab
1214
+ * reports one price for the batch instead of N separate analyses — and the
1215
+ * turns settle together rather than one row at a time.
1216
+ */
645
1217
  async function coachSelected(store) {
646
1218
  if (store.batchRunning) return
647
1219
  const turns = [...store.selection].sort((a, b) => a - b)
648
1220
  if (turns.length === 0) return
649
1221
  store.batchRunning = true
650
1222
  store.error = null
1223
+ store.notice = null
1224
+ // Every ticked row shows its spinner for the whole call: the batch has no
1225
+ // per-turn progress to report.
1226
+ for (const turn of turns) store.inFlight[String(turn)] = true
651
1227
  notify(store)
652
- for (const turn of turns) {
653
- if (store.inFlight[String(turn)]) continue
654
- await analyzeTurnAsync(store, turn)
1228
+ try {
1229
+ const result = await api('/analyze-batch', { sessionId: store.sessionId, turns })
1230
+ if (result !== null && typeof result === 'object' && result.ok) {
1231
+ const results = Array.isArray(result.results) ? result.results : []
1232
+ let analyzed = 0
1233
+ let failure = null
1234
+ for (const entry of results) {
1235
+ if (entry === null || typeof entry !== 'object') continue
1236
+ const turn = typeof entry.turn === 'number' ? entry.turn : null
1237
+ if (entry.ok === true && entry.report !== null && typeof entry.report === 'object') {
1238
+ if (turn !== null) {
1239
+ store.reports[String(turn)] = entry.report
1240
+ store.expanded.add(turn)
1241
+ }
1242
+ analyzed += 1
1243
+ continue
1244
+ }
1245
+ // `busy` means an analysis of that turn was already running — a
1246
+ // race with the auto-analyzer, not a failure of this batch.
1247
+ const code = typeof entry.code === 'string' && entry.code.length > 0 ? entry.code : 'call-failed'
1248
+ if (failure === null && code !== 'busy') failure = { code, detail: '' }
1249
+ }
1250
+ if (failure !== null) store.error = failure
1251
+ if (result.profile !== null && typeof result.profile === 'object') store.profile = result.profile
1252
+ // What the whole batch cost, from the run the host closed for it.
1253
+ setStoreNotice(store, 'notice.batch', { analyzed: String(analyzed), requested: String(turns.length) }, result.run)
1254
+ } else {
1255
+ store.error = {
1256
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
1257
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
1258
+ }
1259
+ }
1260
+ } catch (error) {
1261
+ store.error = errorOf(error)
655
1262
  }
1263
+ for (const turn of turns) delete store.inFlight[String(turn)]
1264
+ store.selection.clear()
656
1265
  store.batchRunning = false
657
1266
  store.selecting = false
658
1267
  notify(store)
1268
+ // The batch just spent money, and analyzed turns stop being eligible for
1269
+ // a bootstrap; the Settings page should say both.
1270
+ fetchUsage()
1271
+ fetchBootstrapPreview()
659
1272
  }
660
1273
 
661
1274
  function toggleSelecting(store) {
@@ -673,11 +1286,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
673
1286
  function improveDraft(store, draft) {
674
1287
  if (store.preview.open || typeof draft !== 'string' || draft.trim().length === 0) return
675
1288
  store.preview = { open: true, pending: true, original: draft, data: null, error: null }
1289
+ store.notice = null
676
1290
  notify(store)
677
1291
  api('/improve', { sessionId: store.sessionId, draft })
678
1292
  .then((result) => {
679
1293
  if (result !== null && typeof result === 'object' && result.ok && typeof result.improved === 'string' && result.improved.trim().length > 0) {
680
1294
  store.preview = { ...store.preview, pending: false, data: result }
1295
+ setStoreNotice(store, 'notice.improve', {}, result.run)
681
1296
  } else {
682
1297
  store.preview = {
683
1298
  ...store.preview,
@@ -887,6 +1502,193 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
887
1502
  }
888
1503
  }
889
1504
 
1505
+ // ── Usage formatting & narrowing (money is formatted ONLY here) ─────────
1506
+
1507
+ /**
1508
+ * List-price money, always four decimals — a coach analysis costs a few
1509
+ * hundredths of a cent, and two decimals would round every honest figure
1510
+ * to `$0.00`. A positive amount too small to show at four decimals reads
1511
+ * `< $0.0001` rather than a zero it is not.
1512
+ */
1513
+ function fmtUsd(n) {
1514
+ const value = Number(n)
1515
+ if (!Number.isFinite(value)) return '—'
1516
+ if (value > 0 && value < 0.0001) return '< $0.0001'
1517
+ if (value < 0) return '-$' + Math.abs(value).toFixed(4)
1518
+ return '$' + value.toFixed(4)
1519
+ }
1520
+
1521
+ /**
1522
+ * A published rate, per 1M tokens. Rates are quoted, not measured, so they
1523
+ * read as they are quoted: three decimals at most and no trailing zeros —
1524
+ * `$0.007`, `$0.22`, `$1.32`. `fmtUsd`'s four fixed decimals belong to
1525
+ * spend, where every hundredth of a cent is a real figure.
1526
+ */
1527
+ function fmtRate(n) {
1528
+ // Strict about the type: `Number(null)` is 0, and a rate nobody quoted
1529
+ // must not print as a free one.
1530
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '—'
1531
+ const trimmed = n.toFixed(3).replace(/\.?0+$/, '')
1532
+ if (n > 0 && Number(trimmed) === 0) return '< $0.001'
1533
+ return '$' + trimmed
1534
+ }
1535
+
1536
+ /**
1537
+ * Spend for one bucket of calls. A bucket that billed calls but priced
1538
+ * none of them says so — `$0.0000` would claim the calls were free, and an
1539
+ * unmetered call is never `$0.00`. Same for a bucket that made calls and
1540
+ * billed none of them: every call came back without usage, so its cost is
1541
+ * unknown, not zero. A genuinely idle bucket (no attempts) stays `$0.0000`.
1542
+ */
1543
+ function usageSpend(kit, totals) {
1544
+ const attempts = usageNum(totals.attempts)
1545
+ const billed = usageNum(totals.billedCalls)
1546
+ if (billed > 0 && usageNum(totals.unpricedCalls) >= billed) return kit.t('usage.priceUnavailable')
1547
+ if (attempts > 0 && billed === 0) return kit.t('usage.priceUnavailable')
1548
+ return fmtUsd(totals.usdKnown)
1549
+ }
1550
+
1551
+ /** Thousands-separated token counts, grouped here so no locale data is needed. */
1552
+ function fmtTokens(n) {
1553
+ const value = Number(n)
1554
+ if (!Number.isFinite(value)) return '—'
1555
+ const grouped = String(Math.round(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
1556
+ return (value < 0 ? '-' : '') + grouped
1557
+ }
1558
+
1559
+ /** A `YYYY-MM-DD` day, from either a day key (passed through) or an epoch ms. */
1560
+ function fmtDay(day) {
1561
+ if (typeof day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(day)) return day
1562
+ const value = Number(day)
1563
+ if (!Number.isFinite(value) || value <= 0) return '—'
1564
+ const date = new Date(value)
1565
+ if (Number.isNaN(date.getTime())) return '—'
1566
+ const pad = (part) => String(part).padStart(2, '0')
1567
+ return String(date.getFullYear()) + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
1568
+ }
1569
+
1570
+ /** A 0..1 ratio as a whole percentage; `null`/non-finite stays an em dash. */
1571
+ function fmtPct(x) {
1572
+ const value = Number(x)
1573
+ if (!Number.isFinite(value)) return '—'
1574
+ return String(Math.round(value * 100)) + '%'
1575
+ }
1576
+
1577
+ /**
1578
+ * Billed tokens in one bucket set: uncached input + output + cache reads +
1579
+ * cache writes. `reasoningTokens` is a subset of `outputTokens`, so adding
1580
+ * it would count every reasoning token twice.
1581
+ */
1582
+ function tokensTotal(buckets) {
1583
+ if (buckets === null || typeof buckets !== 'object') return 0
1584
+ const num = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0)
1585
+ return num(buckets.inputTokens) + num(buckets.outputTokens) + num(buckets.cacheReadTokens) + num(buckets.cacheWriteTokens)
1586
+ }
1587
+
1588
+ function usageNum(value) {
1589
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
1590
+ }
1591
+
1592
+ function usageTokens(value) {
1593
+ const source = value !== null && typeof value === 'object' ? value : {}
1594
+ return {
1595
+ inputTokens: usageNum(source.inputTokens),
1596
+ outputTokens: usageNum(source.outputTokens),
1597
+ cacheReadTokens: usageNum(source.cacheReadTokens),
1598
+ cacheWriteTokens: usageNum(source.cacheWriteTokens),
1599
+ reasoningTokens: usageNum(source.reasoningTokens),
1600
+ }
1601
+ }
1602
+
1603
+ /** One period card, zero-filled; the two derived figures stay nullable. */
1604
+ function usagePeriod(value) {
1605
+ const source = value !== null && typeof value === 'object' ? value : {}
1606
+ return {
1607
+ attempts: usageNum(source.attempts),
1608
+ billedCalls: usageNum(source.billedCalls),
1609
+ unmeteredCalls: usageNum(source.unmeteredCalls),
1610
+ unpricedCalls: usageNum(source.unpricedCalls),
1611
+ tokens: usageTokens(source.tokens),
1612
+ usdKnown: usageNum(source.usdKnown),
1613
+ avgAnalysisUsd: typeof source.avgAnalysisUsd === 'number' && Number.isFinite(source.avgAnalysisUsd) ? source.avgAnalysisUsd : null,
1614
+ cachedInputRate: typeof source.cachedInputRate === 'number' && Number.isFinite(source.cachedInputRate) ? source.cachedInputRate : null,
1615
+ }
1616
+ }
1617
+
1618
+ function usageBuckets(value) {
1619
+ const out = {}
1620
+ if (value === null || typeof value !== 'object') return out
1621
+ for (const [key, totals] of Object.entries(value)) out[key] = usagePeriod(totals)
1622
+ return out
1623
+ }
1624
+
1625
+ function usageSeries(value) {
1626
+ if (!Array.isArray(value)) return []
1627
+ return value
1628
+ .filter((point) => point !== null && typeof point === 'object' && typeof point.day === 'string')
1629
+ .map((point) => ({ day: point.day, usdKnown: usageNum(point.usdKnown), billedCalls: usageNum(point.billedCalls) }))
1630
+ }
1631
+
1632
+ function usageWarning(value) {
1633
+ const source = value !== null && typeof value === 'object' ? value : {}
1634
+ const level = source.level === 'warn' || source.level === 'exceeded' ? source.level : 'none'
1635
+ return { limit: usageNum(source.limit), spent: usageNum(source.spent), level }
1636
+ }
1637
+
1638
+ function usageRunItem(value) {
1639
+ const source = value !== null && typeof value === 'object' ? value : {}
1640
+ const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
1641
+ return {
1642
+ runId: text('runId'),
1643
+ type: text('type'),
1644
+ status: text('status'),
1645
+ attempts: usageNum(source.attempts),
1646
+ billedCalls: usageNum(source.billedCalls),
1647
+ unmeteredCalls: usageNum(source.unmeteredCalls),
1648
+ unpricedCalls: usageNum(source.unpricedCalls),
1649
+ tokens: usageTokens(source.tokens),
1650
+ usdKnown: usageNum(source.usdKnown),
1651
+ trigger: text('trigger'),
1652
+ startedAt: usageNum(source.startedAt),
1653
+ endedAt: usageNum(source.endedAt),
1654
+ sessionId: text('sessionId'),
1655
+ turn: typeof source.turn === 'number' ? source.turn : null,
1656
+ workspace: text('workspace'),
1657
+ model: text('model'),
1658
+ provider: text('provider'),
1659
+ }
1660
+ }
1661
+
1662
+ /**
1663
+ * The `/usage` envelope, narrowed. `null` for anything that is not a
1664
+ * successful report, so the card can render its empty state instead of
1665
+ * reaching into `undefined`.
1666
+ */
1667
+ function usageOf(value) {
1668
+ if (value === null || typeof value !== 'object' || value.ok !== true) return null
1669
+ const runs = value.runs !== null && typeof value.runs === 'object' ? value.runs : {}
1670
+ const warnings = value.warnings !== null && typeof value.warnings === 'object' ? value.warnings : {}
1671
+ return {
1672
+ trackingSince: usageNum(value.trackingSince),
1673
+ pricing: value.pricing !== null && typeof value.pricing === 'object' ? value.pricing : {},
1674
+ today: usagePeriod(value.today),
1675
+ month: usagePeriod(value.month),
1676
+ last7: usagePeriod(value.last7),
1677
+ last30: usagePeriod(value.last30),
1678
+ lifetime: usagePeriod(value.lifetime),
1679
+ byType: usageBuckets(value.byType),
1680
+ byModel: usageBuckets(value.byModel),
1681
+ series7: usageSeries(value.series7),
1682
+ series30: usageSeries(value.series30),
1683
+ warnings: { daily: usageWarning(warnings.daily), monthly: usageWarning(warnings.monthly) },
1684
+ runs: {
1685
+ items: (Array.isArray(runs.items) ? runs.items : []).map(usageRunItem),
1686
+ page: usageNum(runs.page) > 0 ? Math.floor(usageNum(runs.page)) : 1,
1687
+ pageSize: usageNum(runs.pageSize) > 0 ? Math.floor(usageNum(runs.pageSize)) : 20,
1688
+ total: usageNum(runs.total),
1689
+ },
1690
+ }
1691
+ }
890
1692
  // ── Components ─────────────────────────────────────────────────────────
891
1693
 
892
1694
  function makeKit(t) {
@@ -894,20 +1696,46 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
894
1696
  }
895
1697
 
896
1698
  /** "Learned from N prompts · Auto-learning on · x/y today" status card. */
897
- /** "Learn from my last 20 turns" — spinner label while a bootstrap runs. */
898
- function BootstrapButton(kit, { bootstrap, onClick }) {
1699
+ /**
1700
+ * "Learn from my last 20 turns" — while a bootstrap runs the label counts
1701
+ * the turns and what the batch has cost so far.
1702
+ *
1703
+ * The service ships `usdKnown: 0` from the first tick, so the money is
1704
+ * gated on a billed call, not on the field being present: before that the
1705
+ * label carries no figure at all, and a batch whose billed calls all came
1706
+ * back unpriced says so through `usageSpend` rather than claiming `$0.0000`.
1707
+ */
1708
+ function BootstrapButton(kit, { bootstrap, onClick, disabled }) {
899
1709
  const { t } = kit
900
1710
  const running = bootstrap !== null && bootstrap !== undefined && typeof bootstrap === 'object' && bootstrap.running === true
1711
+ const progress = running
1712
+ ? {
1713
+ done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0),
1714
+ total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0),
1715
+ }
1716
+ : null
1717
+ const billed = running ? usageNum(bootstrap.billedCalls) : 0
1718
+ const label = () => {
1719
+ if (!running) return t('bootstrap.btn')
1720
+ if (billed === 0) return t('bootstrap.running', progress)
1721
+ return t('bootstrap.runningUsd', {
1722
+ ...progress,
1723
+ usd: usageSpend(kit, {
1724
+ billedCalls: billed,
1725
+ unpricedCalls: usageNum(bootstrap.unpricedCalls),
1726
+ usdKnown: usageNum(bootstrap.usdKnown),
1727
+ }),
1728
+ })
1729
+ }
901
1730
  return h('span', { className: 'tacit-bootstrap' },
902
1731
  h('button', {
903
1732
  type: 'button',
904
1733
  className: 'tacit-btn tacit-btn-sm',
905
- disabled: running,
1734
+ // Also disabled when the caller knows there is nothing eligible left.
1735
+ disabled: running || disabled === true,
906
1736
  title: t('bootstrap.hint'),
907
1737
  onClick,
908
- }, running
909
- ? t('bootstrap.running', { done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0), total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0) })
910
- : t('bootstrap.btn')))
1738
+ }, label()))
911
1739
  }
912
1740
 
913
1741
  function pct(value) {
@@ -931,6 +1759,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
931
1759
  h('div', { className: 'tacit-progress-text' }, autoOn ? t('status.autoHint') : t('status.autoOff')),
932
1760
  hasTrend
933
1761
  ? h('div', { className: 'tacit-trend' },
1762
+ h('span', { className: 'tacit-chip' }, t('status.trendCorrections', { a: pct(trend.early.correctionRate), b: pct(trend.recent.correctionRate) })),
934
1763
  h('span', { className: 'tacit-chip' }, t('status.trendMessy', { a: pct(trend.early.messyRate), b: pct(trend.recent.messyRate) })),
935
1764
  h('span', { className: 'tacit-chip' }, t('status.trendTokens', { a: fmt(trend.early.tokensPerTurn), b: fmt(trend.recent.tokensPerTurn) })),
936
1765
  h('span', { className: 'tacit-progress-text' }, t('status.trendHint', { n: String(trend.window) })))
@@ -1204,6 +2033,14 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1204
2033
  error !== null && typeof error === 'object'
1205
2034
  ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1206
2035
  : null,
2036
+ // Analyze and Improve report what they cost here. Always mounted and
2037
+ // empty when idle: a live region that appears together with its text
2038
+ // is missed by screen readers. The settings clear notice carries a
2039
+ // code and a count instead of text, and stays this panel's business.
2040
+ h('div', { className: 'tacit-settings-notice', role: 'status' },
2041
+ store.notice !== null && typeof store.notice === 'object' && typeof store.notice.text === 'string'
2042
+ ? store.notice.text
2043
+ : ''),
1207
2044
  turns.length === 0
1208
2045
  ? h('div', { className: 'tacit-empty' }, t('empty'))
1209
2046
  : h('div', null,
@@ -1264,6 +2101,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1264
2101
  const preview = store.preview
1265
2102
  const onApply = () => applyImproved(store, props.inputActions)
1266
2103
  const onCancel = () => closePreview(store)
2104
+ const improvedText = preview.data !== null && typeof preview.data === 'object' && typeof preview.data.improved === 'string'
2105
+ ? preview.data.improved
2106
+ : ''
2107
+ // The model returns a finished draft verbatim (its fixed point): show
2108
+ // that instead of two identical columns, and offer no Apply so a no-op
2109
+ // rewrite never counts as applied.
2110
+ const unchanged = !preview.pending && preview.error === null && improvedText.trim() === preview.original.trim()
1267
2111
 
1268
2112
  return h('div', { className: 'tacit-modal-backdrop' },
1269
2113
  h('div', { className: 'tacit-modal-card' },
@@ -1276,20 +2120,24 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1276
2120
  ? h('div', { className: 'tacit-error' },
1277
2121
  t('err.' + String(preview.error.code), { detail: String(preview.error.detail || '') }))
1278
2122
  : h('div', null,
1279
- h('div', { className: 'tacit-modal-cols' },
1280
- h('div', { className: 'tacit-modal-col' },
1281
- h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
1282
- h('pre', { className: 'tacit-pre' }, preview.original)),
1283
- h('div', { className: 'tacit-modal-col' },
1284
- h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
1285
- h('pre', { className: 'tacit-pre' }, preview.data !== null && typeof preview.data.improved === 'string' ? preview.data.improved : ''))),
2123
+ unchanged
2124
+ ? h('div', { className: 'tacit-modal-unchanged' }, t('preview.unchanged'))
2125
+ : h('div', { className: 'tacit-modal-cols' },
2126
+ h('div', { className: 'tacit-modal-col' },
2127
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
2128
+ h('pre', { className: 'tacit-pre' }, preview.original)),
2129
+ h('div', { className: 'tacit-modal-col' },
2130
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
2131
+ h('pre', { className: 'tacit-pre' }, improvedText))),
1286
2132
  preview.data !== null && typeof preview.data === 'object' && typeof preview.data.rationale === 'string' && preview.data.rationale.length > 0
1287
2133
  ? h('div', { className: 'tacit-modal-rationale' },
1288
2134
  h('div', { className: 'tacit-modal-col-title' }, t('preview.rationale')),
1289
2135
  h('div', { className: 'tacit-modal-rationale-text' }, preview.data.rationale))
1290
2136
  : null,
1291
2137
  h('div', { className: 'tacit-modal-actions' },
1292
- h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
2138
+ unchanged
2139
+ ? null
2140
+ : h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
1293
2141
  h('button', { type: 'button', className: 'tacit-btn', onClick: onCancel }, t('preview.cancel'))))))
1294
2142
  }
1295
2143
  }
@@ -1364,6 +2212,126 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1364
2212
  }
1365
2213
  }
1366
2214
 
2215
+ // ── Collapsible section card (Settings → Tacit) ────────────────────────
2216
+
2217
+ /**
2218
+ * One accessible collapsible card.
2219
+ *
2220
+ * A `<details>` would be shorter, but its open state lives in the DOM: the
2221
+ * store could not drive it and the SSR suite could not seed it. So this is
2222
+ * the button/`aria-expanded`/`aria-controls` pattern instead, with the body
2223
+ * always in the DOM and only the `hidden` attribute toggled — the content
2224
+ * stays findable (browser find-in-page aside) and nothing remounts.
2225
+ */
2226
+ function SectionCard(kit, { id, title, summary, count, open, onToggle, children }) {
2227
+ const { t } = kit
2228
+ const key = String(id)
2229
+ const isOpen = open === true
2230
+ const bodyId = 'tacit-card-' + key + '-body'
2231
+ const label = isOpen ? t('card.collapse') : t('card.expand')
2232
+ const kids = Array.isArray(children) ? children : (children === undefined || children === null ? [] : [children])
2233
+ return h('div', { className: 'tacit-card' },
2234
+ h('button', {
2235
+ type: 'button',
2236
+ className: 'tacit-card-head',
2237
+ id: 'tacit-card-' + key + '-head',
2238
+ 'aria-expanded': isOpen,
2239
+ 'aria-controls': bodyId,
2240
+ onClick: onToggle,
2241
+ },
2242
+ h('span', { className: 'tacit-card-title' }, title),
2243
+ typeof summary === 'string' && summary.length > 0
2244
+ ? h('span', { className: 'tacit-card-summary' }, summary)
2245
+ : null,
2246
+ count !== undefined && count !== null
2247
+ ? h('span', { className: 'tacit-card-count' }, String(count))
2248
+ : null,
2249
+ h('span', { className: 'tacit-card-chevron', 'aria-label': label, title: label }, isOpen ? '▾' : '▸')),
2250
+ h('div', { className: 'tacit-card-body', id: bodyId, hidden: !isOpen }, ...kids))
2251
+ }
2252
+
2253
+ // ── Confirm dialog (destructive actions) ───────────────────────────────
2254
+
2255
+ /**
2256
+ * What had focus when a destructive action was requested.
2257
+ *
2258
+ * It is captured by the store action that opens the dialog rather than by
2259
+ * the dialog itself: React focuses the `autoFocus`ed Cancel button while it
2260
+ * commits, so by the time an effect runs the opener is already gone.
2261
+ */
2262
+ let confirmOpener = null
2263
+
2264
+ /**
2265
+ * The two focus stops of the dialog, addressed by id rather than by ref:
2266
+ * refs would add hooks to a component whose hook count must not move.
2267
+ */
2268
+ const CONFIRM_CANCEL_ID = 'tacit-confirm-cancel'
2269
+ const CONFIRM_ACCEPT_ID = 'tacit-confirm-accept'
2270
+
2271
+ function captureConfirmOpener() {
2272
+ confirmOpener = typeof document !== 'undefined' && document !== null ? document.activeElement : null
2273
+ }
2274
+
2275
+ /**
2276
+ * One small modal confirmation, rendered unconditionally by its owner (and
2277
+ * `null` when closed) so React's hook count never changes between renders.
2278
+ * Hook-free but for the focus restore, which is the one thing it cannot do
2279
+ * with markup alone.
2280
+ *
2281
+ * Cancel comes first and takes focus: the safe answer is the one a keyboard
2282
+ * or screen-reader user reaches without looking.
2283
+ */
2284
+ function ConfirmDialog(kit, { open, title, body, confirmLabel, onConfirm, onCancel }) {
2285
+ const { t } = kit
2286
+ const isOpen = open === true
2287
+ useEffect(() => {
2288
+ if (!isOpen || typeof document === 'undefined') return undefined
2289
+ const opener = confirmOpener
2290
+ return () => {
2291
+ if (opener !== null && typeof opener.focus === 'function') opener.focus()
2292
+ }
2293
+ }, [isOpen])
2294
+ if (!isOpen) return null
2295
+ const cancel = typeof onCancel === 'function' ? onCancel : () => {}
2296
+ return h('div', {
2297
+ className: 'tacit-modal-backdrop',
2298
+ onClick: cancel,
2299
+ // Escape and Tab are both handled here: a keydown from either button
2300
+ // bubbles to the backdrop. With exactly two stops in the dialog,
2301
+ // forward and backward tabbing both land on the other button, so
2302
+ // `shiftKey` needs no branch of its own.
2303
+ onKeyDown: (event) => {
2304
+ if (event.key === 'Escape') {
2305
+ // Stopped here: the host's Settings modal also closes on Escape, and
2306
+ // cancelling a confirm must not take the whole settings page with it.
2307
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
2308
+ cancel()
2309
+ return
2310
+ }
2311
+ if (event.key !== 'Tab' || typeof document === 'undefined' || document === null) return
2312
+ const cancelButton = document.getElementById(CONFIRM_CANCEL_ID)
2313
+ const acceptButton = document.getElementById(CONFIRM_ACCEPT_ID)
2314
+ if (cancelButton === null || acceptButton === null) return
2315
+ const next = document.activeElement === acceptButton ? cancelButton : acceptButton
2316
+ event.preventDefault()
2317
+ if (typeof next.focus === 'function') next.focus()
2318
+ },
2319
+ },
2320
+ h('div', {
2321
+ className: 'tacit-modal-card',
2322
+ role: 'dialog',
2323
+ 'aria-modal': 'true',
2324
+ 'aria-labelledby': 'tacit-confirm-title',
2325
+ // A click on the card is not a click on the backdrop.
2326
+ onClick: (event) => event.stopPropagation(),
2327
+ },
2328
+ h('h3', { className: 'tacit-modal-title', id: 'tacit-confirm-title' }, title),
2329
+ h('p', { className: 'tacit-confirm-body' }, body),
2330
+ h('div', { className: 'tacit-confirm-actions' },
2331
+ h('button', { type: 'button', id: CONFIRM_CANCEL_ID, className: 'tacit-btn', autoFocus: true, onClick: cancel }, t('confirm.cancel')),
2332
+ h('button', { type: 'button', id: CONFIRM_ACCEPT_ID, className: 'tacit-btn tacit-btn-danger', onClick: onConfirm }, confirmLabel))))
2333
+ }
2334
+
1367
2335
  // ── Global panel (Settings → Tacit section) ────────────────────────────
1368
2336
 
1369
2337
  function DirectivesEditor(kit) {
@@ -1424,14 +2392,16 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1424
2392
  typeof entry.workspace === 'string' && entry.workspace.length > 0
1425
2393
  ? h('span', { className: 'tacit-chip tacit-chip-scope', title: entry.workspace }, t('steer.workspace', { name: labelOf(entry.workspace) }))
1426
2394
  : null,
1427
- entry.status === 'candidate'
1428
- ? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
1429
- n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
1430
- total: String(config !== null && typeof config.directiveTrialTurns === 'number' ? config.directiveTrialTurns : 10),
1431
- }))
1432
- : entry.status === 'retired'
1433
- ? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('steer.retired', { reason: String(entry.retiredReason || '') }))
1434
- : h('span', { className: 'tacit-chip tacit-chip-ok' }, t('steer.active')),
2395
+ entry.status === 'queued'
2396
+ ? h('span', { className: 'tacit-chip tacit-chip-muted' }, t('steer.queued'))
2397
+ : entry.status === 'candidate'
2398
+ ? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
2399
+ n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
2400
+ total: String(config !== null && typeof config.directiveTrialTurns === 'number' ? config.directiveTrialTurns : 10),
2401
+ }))
2402
+ : entry.status === 'retired'
2403
+ ? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('steer.retired', { reason: String(entry.retiredReason || '') }))
2404
+ : h('span', { className: 'tacit-chip tacit-chip-ok' }, t('steer.active')),
1435
2405
  h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => editDirectives({ action: 'remove', id: entry.id }) }, t('steer.remove'))))),
1436
2406
  h('div', { className: 'tacit-settings-row' },
1437
2407
  h('input', {
@@ -1465,6 +2435,26 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1465
2435
  }
1466
2436
  }
1467
2437
 
2438
+ /** Days offered by the retention select; the host clamps to 7–365 anyway. */
2439
+ const RETENTION_DAYS = [7, 14, 30, 90, 180, 365]
2440
+
2441
+ /**
2442
+ * The `/bootstrap-preview` envelope, narrowed. `null` for anything that is
2443
+ * not a successful preview, so the Overview card falls back to the
2444
+ * documented estimate rather than pricing an envelope it cannot read.
2445
+ */
2446
+ function previewOf(value) {
2447
+ if (value === null || typeof value !== 'object' || value.ok !== true) return null
2448
+ if (typeof value.eligible !== 'number' || !Number.isFinite(value.eligible)) return null
2449
+ const estimate = value.estimate !== null && typeof value.estimate === 'object' ? value.estimate : {}
2450
+ return {
2451
+ eligible: Math.max(0, Math.floor(value.eligible)),
2452
+ usd: typeof estimate.usd === 'number' && Number.isFinite(estimate.usd) ? estimate.usd : null,
2453
+ basis: estimate.basis === 'measured' ? 'measured' : 'doc',
2454
+ samples: typeof estimate.samples === 'number' && Number.isFinite(estimate.samples) ? Math.max(0, Math.floor(estimate.samples)) : 0,
2455
+ }
2456
+ }
2457
+
1468
2458
  function CoachPanel(kit) {
1469
2459
  const { t, fmtTime } = kit
1470
2460
  const DirectivesEditorView = DirectivesEditor(kit)
@@ -1481,6 +2471,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1481
2471
  const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
1482
2472
  const [learnGood, setLearnGood] = useState(config !== null && config.learnFromGood !== false)
1483
2473
  const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
2474
+ // The two USD thresholds follow the budget field's text-state + Apply
2475
+ // pattern: money is typed a character at a time, and clamping mid-typing
2476
+ // fights the user. `0` is a real value here (the warning off), so the
2477
+ // fallback is `??`, never `||`.
2478
+ const [dailyText, setDailyText] = useState(config !== null && typeof config.costWarnDailyUsd === 'number' ? String(config.costWarnDailyUsd) : '0')
2479
+ const [monthlyText, setMonthlyText] = useState(config !== null && typeof config.costWarnMonthlyUsd === 'number' ? String(config.costWarnMonthlyUsd) : '0')
1484
2480
 
1485
2481
  useEffect(() => {
1486
2482
  if (config !== null) {
@@ -1489,6 +2485,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1489
2485
  if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
1490
2486
  if (typeof config.learnFromGood === 'boolean') setLearnGood(config.learnFromGood)
1491
2487
  if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
2488
+ if (typeof config.costWarnDailyUsd === 'number') setDailyText(String(config.costWarnDailyUsd))
2489
+ if (typeof config.costWarnMonthlyUsd === 'number') setMonthlyText(String(config.costWarnMonthlyUsd))
1492
2490
  }
1493
2491
  }, [config])
1494
2492
 
@@ -1516,6 +2514,17 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1516
2514
  setLive(next)
1517
2515
  updateRootConfig({ liveSuggestions: next })
1518
2516
  }
2517
+ /** One USD threshold: never negative, `0` = off, no rounding to whole dollars. */
2518
+ const applyWarn = (key, text, setText) => {
2519
+ const typed = String(text).trim()
2520
+ // An empty field means "off". Anything that is not a number at all is
2521
+ // a typo: silently writing 0 would turn a warning off behind the
2522
+ // user's back, so the entry is left alone instead.
2523
+ if (typed.length > 0 && !Number.isFinite(Number(typed))) return
2524
+ const value = Math.max(0, typed.length === 0 ? 0 : Number(typed))
2525
+ setText(String(value))
2526
+ updateRootConfig({ [key]: value })
2527
+ }
1519
2528
 
1520
2529
  const coached = Array.isArray(rootStore.coached) ? rootStore.coached : []
1521
2530
  const error = rootStore.error
@@ -1526,13 +2535,98 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1526
2535
  ? rootStore.profile.directives.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.text === 'string')
1527
2536
  : []
1528
2537
 
2538
+ const sections = rootStore.sections !== null && typeof rootStore.sections === 'object' ? rootStore.sections : {}
2539
+ const notice = rootStore.notice !== null && typeof rootStore.notice === 'object' && typeof rootStore.notice.text === 'string'
2540
+ ? rootStore.notice
2541
+ : null
2542
+ // Rates ride the /usage envelope, so the Pricing card reads them there
2543
+ // rather than calling a route of its own on every render.
2544
+ const usageReport = usageOf(rootStore.usage)
2545
+ const usagePricing = usageReport === null ? null : usageReport.pricing
2546
+ const preview = previewOf(rootStore.preview)
2547
+ const confirm = rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'usage'
2548
+ ? 'usage'
2549
+ : (rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'reports' ? 'reports' : null)
2550
+ const retention = config !== null && typeof config.costHistoryDays === 'number' && Number.isFinite(config.costHistoryDays) && config.costHistoryDays > 0
2551
+ ? config.costHistoryDays
2552
+ : 30
2553
+ // A value set by hand in the YAML need not be one of the offered days.
2554
+ // It gets an option of its own, in place, rather than being displayed
2555
+ // as a neighbour it is not.
2556
+ const retentionDays = RETENTION_DAYS.includes(retention)
2557
+ ? RETENTION_DAYS
2558
+ : [...RETENTION_DAYS, retention].sort((a, b) => a - b)
2559
+ /** Every card is titled by `card.<id>` and driven by `rootStore.sections`. */
2560
+ const card = (id, children, extra) => SectionCard(kit, {
2561
+ id,
2562
+ title: t('card.' + id),
2563
+ open: sections[id] === true,
2564
+ onToggle: () => toggleSection(id),
2565
+ children,
2566
+ ...(extra !== undefined ? extra : {}),
2567
+ })
2568
+
1529
2569
  return h('div', { className: 'tacit-panel' },
1530
- StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
1531
- h('div', { className: 'tacit-settings-row' },
1532
- BootstrapButton(kit, { bootstrap: rootStore.bootstrap, onClick: () => bootstrapAll() }),
1533
- h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
1534
- h('div', { className: 'tacit-settings' },
1535
- h('div', { className: 'tacit-settings-title' }, t('settings.title')),
2570
+ error !== null && typeof error === 'object'
2571
+ ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
2572
+ : null,
2573
+ // Above the cards, and always mounted: a live region that appears
2574
+ // together with its text is missed by screen readers, and one nested
2575
+ // in the Overview body would be silent while that card is collapsed.
2576
+ h('div', { className: 'tacit-settings-notice', role: 'status' }, notice === null ? '' : notice.text),
2577
+ card('overview', [
2578
+ StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
2579
+ config !== null && typeof config.model === 'string' && config.model.length > 0
2580
+ ? h('div', { className: 'tacit-settings-row' },
2581
+ h('span', { className: 'tacit-chip' }, t('overview.model', { model: config.model })))
2582
+ : null,
2583
+ h('div', { className: 'tacit-settings-row' },
2584
+ BootstrapButton(kit, {
2585
+ bootstrap: rootStore.bootstrap,
2586
+ // Only a loaded preview may disable the button: before one
2587
+ // lands, "0 eligible" is an absence of data, not a fact.
2588
+ disabled: preview !== null && preview.eligible === 0,
2589
+ onClick: () => bootstrapAll(t),
2590
+ }),
2591
+ h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
2592
+ // What the run would actually cost, priced from the ledger once it
2593
+ // holds enough analyses; until the preview lands, the documented
2594
+ // figure stands in.
2595
+ preview === null
2596
+ ? h('p', { className: 'tacit-panel-hint' }, t('bootstrap.estimateDoc'))
2597
+ : h('p', { className: 'tacit-panel-hint' },
2598
+ // An estimate the host could not price says so; `fmtUsd(null)`
2599
+ // would read `$0.0000` and claim the run is free.
2600
+ t('bootstrap.preview', {
2601
+ eligible: String(preview.eligible),
2602
+ usd: preview.usd === null ? t('usage.priceUnavailable') : fmtUsd(preview.usd),
2603
+ }),
2604
+ ' ',
2605
+ preview.basis === 'measured'
2606
+ ? t('bootstrap.previewMeasured', { samples: String(preview.samples) })
2607
+ : t('bootstrap.previewDoc')),
2608
+ ]),
2609
+ card('usage', [
2610
+ UsageCard(kit, {
2611
+ usage: rootStore.usage,
2612
+ config,
2613
+ filters: rootStore.usageFilters,
2614
+ series: rootStore.usageSeries,
2615
+ expanded: rootStore.usageExpanded,
2616
+ runs: rootStore.usageRuns,
2617
+ onFilter: (patch) => setUsageFilter(patch),
2618
+ onToggleRun: (runId) => toggleUsageRun(runId),
2619
+ onSeries: (value) => setUsageSeries(value),
2620
+ }),
2621
+ ]),
2622
+ card('pricing', [
2623
+ PricingCard(kit, {
2624
+ pricing: usagePricing,
2625
+ refreshing: rootStore.pricingRefreshing === true,
2626
+ onRefresh: () => refreshPricing(t),
2627
+ }),
2628
+ ], { summary: pricingSummary(kit, usagePricing) }),
2629
+ card('learning', [
1536
2630
  h('div', { className: 'tacit-settings-row' },
1537
2631
  h('label', { className: 'tacit-settings-label' }, t('settings.model')),
1538
2632
  h('select', {
@@ -1558,39 +2652,107 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1558
2652
  onChange: (event) => setBudgetText(event.target.value),
1559
2653
  }),
1560
2654
  h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
2655
+ ]),
2656
+ card('guidance', [
2657
+ h(DirectivesEditorView, {
2658
+ workspaces: rootStore.workspaces, config, directives, steering: rootStore.steering }),
2659
+ ]),
2660
+ card('improve', [
1561
2661
  h('div', { className: 'tacit-settings-row' },
1562
2662
  h('label', { className: 'tacit-settings-label' }, t('settings.live')),
1563
2663
  h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
2664
+ h('div', { className: 'tacit-panel-section' },
2665
+ h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
2666
+ styleRules.length === 0
2667
+ ? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
2668
+ : h('div', { className: 'tacit-rules-list' },
2669
+ styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
2670
+ h('p', { className: 'tacit-panel-hint' }, t('improve.explain')),
2671
+ ]),
2672
+ card('history', [
2673
+ h('div', { className: 'tacit-panel-section' },
2674
+ h('div', { className: 'tacit-report-title' }, t('panel.coached')),
2675
+ coached.length === 0
2676
+ ? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
2677
+ : h('div', { className: 'tacit-coached-list' },
2678
+ coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
2679
+ h('div', { className: 'tacit-coached-meta' },
2680
+ h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
2681
+ h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
2682
+ h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
2683
+ typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
2684
+ ? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
2685
+ : null,
2686
+ typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
2687
+ ? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
2688
+ : null)))),
2689
+ h('p', { className: 'tacit-panel-hint' }, t('panel.hint')),
2690
+ ], { count: coached.length }),
2691
+ card('privacy', [
2692
+ h('p', { className: 'tacit-panel-hint' }, t('privacy.stored')),
1564
2693
  h('div', { className: 'tacit-settings-row' },
1565
- h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => clearAllRoot() }, t('settings.clear')))),
1566
- error !== null && typeof error === 'object'
1567
- ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1568
- : null,
1569
- h(DirectivesEditorView, {
1570
- workspaces: rootStore.workspaces, config, directives, steering: rootStore.steering }),
1571
- h('div', { className: 'tacit-panel-section' },
1572
- h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
1573
- styleRules.length === 0
1574
- ? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
1575
- : h('div', { className: 'tacit-rules-list' },
1576
- styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
1577
- h('div', { className: 'tacit-panel-section' },
1578
- h('div', { className: 'tacit-report-title' }, t('panel.coached')),
1579
- coached.length === 0
1580
- ? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
1581
- : h('div', { className: 'tacit-coached-list' },
1582
- coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
1583
- h('div', { className: 'tacit-coached-meta' },
1584
- h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
1585
- h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
1586
- h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
1587
- typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
1588
- ? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
1589
- : null,
1590
- typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
1591
- ? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
1592
- : null)))),
1593
- h('div', { className: 'tacit-panel-hint' }, t('panel.hint')))
2694
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-retention' }, t('privacy.retention')),
2695
+ h('select', {
2696
+ id: 'tacit-retention',
2697
+ className: 'tacit-select',
2698
+ value: String(retention),
2699
+ onChange: (event) => updateRootConfig({ costHistoryDays: Number(event.target.value) }),
2700
+ },
2701
+ ...retentionDays.map((days) => h('option', { key: days, value: String(days) }, String(days))))),
2702
+ h('div', { className: 'tacit-settings-row' },
2703
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-daily' }, t('privacy.warnDaily')),
2704
+ h('input', {
2705
+ id: 'tacit-warn-daily',
2706
+ className: 'tacit-input',
2707
+ type: 'number',
2708
+ min: 0,
2709
+ step: '0.01',
2710
+ value: dailyText,
2711
+ onChange: (event) => setDailyText(event.target.value),
2712
+ }),
2713
+ h('button', {
2714
+ type: 'button',
2715
+ className: 'tacit-btn tacit-btn-sm',
2716
+ // Two identically labelled buttons in one card: the accessible
2717
+ // name has to say which threshold each one applies.
2718
+ 'aria-label': t('privacy.applyTo', { action: t('privacy.apply'), field: t('privacy.warnDaily') }),
2719
+ onClick: () => applyWarn('costWarnDailyUsd', dailyText, setDailyText),
2720
+ }, t('privacy.apply'))),
2721
+ h('div', { className: 'tacit-settings-row' },
2722
+ h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-monthly' }, t('privacy.warnMonthly')),
2723
+ h('input', {
2724
+ id: 'tacit-warn-monthly',
2725
+ className: 'tacit-input',
2726
+ type: 'number',
2727
+ min: 0,
2728
+ step: '0.01',
2729
+ value: monthlyText,
2730
+ onChange: (event) => setMonthlyText(event.target.value),
2731
+ }),
2732
+ h('button', {
2733
+ type: 'button',
2734
+ className: 'tacit-btn tacit-btn-sm',
2735
+ // Two identically labelled buttons in one card: the accessible
2736
+ // name has to say which threshold each one applies.
2737
+ 'aria-label': t('privacy.applyTo', { action: t('privacy.apply'), field: t('privacy.warnMonthly') }),
2738
+ onClick: () => applyWarn('costWarnMonthlyUsd', monthlyText, setMonthlyText),
2739
+ }, t('privacy.apply'))),
2740
+ h('p', { className: 'tacit-panel-hint' }, t('privacy.warnHint')),
2741
+ // Both clears are irreversible, so both go through the dialog.
2742
+ h('div', { className: 'tacit-settings-row' },
2743
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('reports') }, t('settings.clear')),
2744
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => openConfirm('usage') }, t('privacy.clearUsage'))),
2745
+ ]),
2746
+ // Always rendered (null while closed) so its one effect keeps this
2747
+ // component's hook count stable.
2748
+ ConfirmDialog(kit, {
2749
+ open: confirm !== null,
2750
+ title: t(confirm === 'usage' ? 'confirm.usageTitle' : 'confirm.reportsTitle'),
2751
+ body: t(confirm === 'usage' ? 'confirm.usageBody' : 'confirm.reportsBody'),
2752
+ confirmLabel: t('confirm.clear'),
2753
+ onConfirm: () => confirmAction(),
2754
+ onCancel: () => closeConfirm(),
2755
+ }))
1594
2756
  }
1595
2757
  }
1596
2758
 
@@ -1602,6 +2764,11 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1602
2764
  initRootStore()
1603
2765
  // Fresh read so freshly distilled style rules appear on open.
1604
2766
  refreshRootState()
2767
+ // What a bootstrap would cost right now, priced from the ledger.
2768
+ fetchBootstrapPreview()
2769
+ // One shared 10s /usage poll while any Tacit settings page is mounted.
2770
+ startUsagePolling()
2771
+ return stopUsagePolling
1605
2772
  }, [])
1606
2773
  return h('div', { className: 'tacit-root' },
1607
2774
  h('div', { className: 'tacit-head' }, h('div', { className: 'tacit-head-title' }, t('panel.title'))),
@@ -1609,6 +2776,463 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1609
2776
  }
1610
2777
  }
1611
2778
 
2779
+ // ── Usage dashboard (Settings → Usage card) ────────────────────────────
2780
+
2781
+ /** Every run type and op the ledger can tag, in the order the filters list them. */
2782
+ const USAGE_RUN_TYPES = ['bootstrap', 'analysis', 'analysis-batch', 'improve', 'directive-distillation', 'style-distillation', 'prompt-enrichment']
2783
+ const USAGE_RANGES = ['today', '7d', '30d', 'month', 'all']
2784
+ const USAGE_STATUSES = ['running', 'success', 'partial', 'failed']
2785
+ const USAGE_COLUMNS = ['time', 'op', 'scope', 'model', 'status', 'calls', 'tokens', 'cost']
2786
+
2787
+ /** One label/value tile; `note` is the small "n unpriced" line under the value. */
2788
+ function UsageTile(key, label, value, note) {
2789
+ return h('div', { key, className: 'tacit-tile' },
2790
+ h('span', { className: 'tacit-tile-label' }, label),
2791
+ h('span', { className: 'tacit-tile-value' }, value),
2792
+ typeof note === 'string' && note.length > 0 ? h('span', { className: 'tacit-tile-note' }, note) : null)
2793
+ }
2794
+
2795
+ /** Money tile: the period's spend plus its unpriced-call footnote. */
2796
+ function SpendTile(kit, id, label, totals) {
2797
+ const note = totals.unpricedCalls > 0 ? kit.t('usage.unpricedShort', { n: String(totals.unpricedCalls) }) : ''
2798
+ return UsageTile(id, label, usageSpend(kit, totals), note)
2799
+ }
2800
+
2801
+ /**
2802
+ * Daily spend as a bar per day. `role="img"` with one `<title>` per rect
2803
+ * carries the detail to a pointer; the visually hidden paragraph under it
2804
+ * carries the same shape to a screen reader, which cannot read the bars.
2805
+ */
2806
+ function BarStrip(kit, { series, which, onSeries }) {
2807
+ const { t } = kit
2808
+ const days = Array.isArray(series) ? series : []
2809
+ const count = days.length
2810
+ let max = 0
2811
+ let total = 0
2812
+ let peak = null
2813
+ for (const point of days) {
2814
+ if (point.usdKnown > max) max = point.usdKnown
2815
+ total += point.usdKnown
2816
+ if (peak === null || point.usdKnown > peak.usdKnown) peak = point
2817
+ }
2818
+ const bars = days.map((point, index) => {
2819
+ // A day with any spend at all keeps a visible 1px stub.
2820
+ const height = point.usdKnown > 0 && max > 0 ? Math.max(1, Math.round((point.usdKnown / max) * 46)) : 0
2821
+ return h('rect', {
2822
+ key: point.day,
2823
+ className: 'tacit-bar',
2824
+ x: String(index * 10 + 1),
2825
+ y: String(48 - height),
2826
+ width: '8',
2827
+ height: String(height),
2828
+ }, h('title', null, t('usage.barTitle', {
2829
+ day: fmtDay(point.day),
2830
+ usd: fmtUsd(point.usdKnown),
2831
+ calls: String(point.billedCalls),
2832
+ })))
2833
+ })
2834
+ const summary = t('usage.chartSummary', {
2835
+ n: String(count),
2836
+ total: fmtUsd(total),
2837
+ day: peak === null ? '—' : fmtDay(peak.day),
2838
+ max: peak === null ? '—' : fmtUsd(peak.usdKnown),
2839
+ })
2840
+ const seriesButton = (value, label) => h('button', {
2841
+ key: value,
2842
+ type: 'button',
2843
+ className: 'tacit-btn tacit-btn-sm',
2844
+ 'aria-pressed': which === value,
2845
+ onClick: () => onSeries(value),
2846
+ }, label)
2847
+ return h('div', { className: 'tacit-usage-chart' },
2848
+ h('svg', {
2849
+ className: 'tacit-bars',
2850
+ role: 'img',
2851
+ 'aria-label': t('usage.chartLabel', { n: String(count) }),
2852
+ viewBox: '0 0 ' + String(count * 10) + ' 48',
2853
+ preserveAspectRatio: 'none',
2854
+ }, bars),
2855
+ h('p', { className: 'tacit-visually-hidden' }, summary),
2856
+ h('div', { className: 'tacit-series-toggle' },
2857
+ seriesButton('7', t('usage.series7')),
2858
+ seriesButton('30', t('usage.series30'))))
2859
+ }
2860
+
2861
+ /** Spend per operation, costliest first, each row ending in a share bar. */
2862
+ function UsageBreakdown(kit, { byType }) {
2863
+ const { t } = kit
2864
+ const rows = Object.entries(byType).sort((a, b) => b[1].usdKnown - a[1].usdKnown)
2865
+ if (rows.length === 0) return null
2866
+ const top = rows[0][1].usdKnown
2867
+ return h('div', { className: 'tacit-usage-breakdown' },
2868
+ h('div', { className: 'tacit-report-title' }, t('usage.breakdown')),
2869
+ rows.map(([type, totals]) => {
2870
+ const share = top > 0 ? Math.round((totals.usdKnown / top) * 100) : 0
2871
+ return h('div', { key: type, className: 'tacit-breakdown-row' },
2872
+ h('span', { className: 'tacit-breakdown-name' }, t('runtype.' + type)),
2873
+ h('span', { className: 'tacit-breakdown-calls' }, fmtTokens(totals.billedCalls)),
2874
+ h('span', { className: 'tacit-breakdown-tokens' }, fmtTokens(tokensTotal(totals.tokens))),
2875
+ h('span', { className: 'tacit-breakdown-usd' }, usageSpend(kit, totals)),
2876
+ h('div', { className: 'tacit-share-track' },
2877
+ h('div', { className: 'tacit-share', style: { width: String(share) + '%' } })))
2878
+ }))
2879
+ }
2880
+
2881
+ /** One budget bar; nothing at all when that threshold is switched off (`limit` 0). */
2882
+ function WarnBar(kit, { id, warning }) {
2883
+ const { t } = kit
2884
+ if (!(warning.limit > 0)) return null
2885
+ const pct = Math.round((warning.spent / warning.limit) * 100)
2886
+ const label = t('warn.' + id, { spent: fmtUsd(warning.spent), limit: fmtUsd(warning.limit), pct: String(pct) })
2887
+ return h('div', {
2888
+ key: id,
2889
+ className: 'tacit-warn-bar tacit-warn-' + warning.level,
2890
+ role: 'progressbar',
2891
+ 'aria-valuemin': 0,
2892
+ 'aria-valuemax': warning.limit,
2893
+ // An overspend is real, but `aria-valuenow` past `aria-valuemax` is not a
2894
+ // valid range: the bar pins at the cap and the true figure rides valuetext.
2895
+ 'aria-valuenow': Math.min(warning.spent, warning.limit),
2896
+ 'aria-valuetext': fmtUsd(warning.spent),
2897
+ 'aria-label': label,
2898
+ },
2899
+ h('span', { className: 'tacit-warn-label' }, label),
2900
+ h('div', { className: 'tacit-warn-track' },
2901
+ h('div', { className: 'tacit-warn-fill', style: { width: String(Math.min(100, pct)) + '%' } })))
2902
+ }
2903
+
2904
+ /** Range/type/status/model selects plus the two free-text scopes. */
2905
+ function UsageFilters(kit, { filters, models, onFilter }) {
2906
+ const { t } = kit
2907
+ const select = (name, value, options) => h('label', { className: 'tacit-filter' },
2908
+ h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
2909
+ h('select', {
2910
+ className: 'tacit-select',
2911
+ value,
2912
+ onChange: (event) => onFilter({ [name]: event.target.value }),
2913
+ }, options.map((option) => h('option', { key: option.value, value: option.value }, option.label))))
2914
+ // A text scope applies on Enter or on leaving the field — not per keystroke,
2915
+ // which would fire a /usage call for every character typed. `name` is the
2916
+ // dictionary key, `field` the store/wire key: the session scope is labelled
2917
+ // `filter.session` but filtered on `sessionId`, which is what the server reads.
2918
+ const text = (name, field, value) => h('label', { className: 'tacit-filter' },
2919
+ h('span', { className: 'tacit-filter-label' }, t('filter.' + name)),
2920
+ h('input', {
2921
+ className: 'tacit-input',
2922
+ type: 'text',
2923
+ key: field + '-' + value,
2924
+ defaultValue: value,
2925
+ onBlur: (event) => {
2926
+ if (event.target.value !== value) onFilter({ [field]: event.target.value.trim() })
2927
+ },
2928
+ onKeyDown: (event) => {
2929
+ if (event.key === 'Enter') onFilter({ [field]: event.target.value.trim() })
2930
+ },
2931
+ }))
2932
+ const all = { value: '', label: t('filter.all') }
2933
+ return h('div', { className: 'tacit-usage-filters' },
2934
+ h('div', { className: 'tacit-report-title' }, t('usage.filters')),
2935
+ h('div', { className: 'tacit-filter-row' },
2936
+ select('range', filters.range, USAGE_RANGES.map((value) => ({ value, label: t('range.' + value) }))),
2937
+ select('type', filters.type, [all, ...USAGE_RUN_TYPES.map((value) => ({ value, label: t('runtype.' + value) }))]),
2938
+ select('status', filters.status, [all, ...USAGE_STATUSES.map((value) => ({ value, label: t('status.' + value) }))]),
2939
+ select('model', filters.model, [all, ...models.map((value) => ({ value, label: value }))]),
2940
+ text('workspace', 'workspace', filters.workspace),
2941
+ text('session', 'sessionId', filters.sessionId)))
2942
+ }
2943
+
2944
+ /** The five token buckets of one attempt, in the order the adapter reports them. */
2945
+ function attemptTokens(usage) {
2946
+ if (usage === null || typeof usage !== 'object') return '—'
2947
+ return 'in ' + fmtTokens(usage.inputTokens)
2948
+ + ' · out ' + fmtTokens(usage.outputTokens)
2949
+ + ' · cacheRead ' + fmtTokens(usage.cacheReadTokens)
2950
+ + ' · cacheWrite ' + fmtTokens(usage.cacheWriteTokens)
2951
+ + ' · reasoning ' + fmtTokens(usage.reasoningTokens)
2952
+ }
2953
+
2954
+ /** One `run()` of the coach model: what it was, how it ended, what it cost. */
2955
+ function AttemptRow(kit, attempt) {
2956
+ const { t } = kit
2957
+ const priced = attempt.priced !== null && typeof attempt.priced === 'object' ? attempt.priced : null
2958
+ const finish = String(attempt.finish || '')
2959
+ const code = String(attempt.code || '')
2960
+ return h('div', { key: String(attempt.id), className: 'tacit-attempt' },
2961
+ h('span', { className: 'tacit-chip' }, t('op.' + String(attempt.op))),
2962
+ typeof attempt.reasoningEffort === 'string' && attempt.reasoningEffort.length > 0
2963
+ ? h('span', { className: 'tacit-attempt-meta' }, t('attempt.effort', { effort: attempt.reasoningEffort }))
2964
+ : null,
2965
+ h('span', { className: 'tacit-chip tacit-status-' + String(attempt.status) }, t('status.' + String(attempt.status))),
2966
+ h('span', { className: 'tacit-attempt-meta' }, t('attempt.finish', { finish, code })),
2967
+ h('span', { className: 'tacit-attempt-meta' }, priced === null
2968
+ ? t('usage.priceUnavailable')
2969
+ : t('attempt.source', { source: String(priced.source), tier: String(priced.tier) })),
2970
+ h('span', { className: 'tacit-attempt-tokens' }, attemptTokens(attempt.usage)),
2971
+ h('span', { className: 'tacit-attempt-meta' }, t('attempt.duration', { ms: fmtTokens(attempt.durationMs) })),
2972
+ h('span', { className: 'tacit-attempt-usd' }, priced === null ? t('usage.priceUnavailable') : fmtUsd(priced.usd)))
2973
+ }
2974
+
2975
+ /** The expanded detail row of one run: its attempts, or the loading line. */
2976
+ function AttemptsRow(kit, { runId, run }) {
2977
+ const { t } = kit
2978
+ const attempts = run !== null && run !== undefined && typeof run === 'object' && Array.isArray(run.attempts)
2979
+ ? run.attempts.filter((attempt) => attempt !== null && typeof attempt === 'object')
2980
+ : null
2981
+ return h('div', { className: 'tacit-usage-attempts', id: 'tacit-run-' + runId, role: 'row' },
2982
+ h('div', { className: 'tacit-usage-cell tacit-usage-attempts-cell', role: 'cell', 'aria-colspan': USAGE_COLUMNS.length },
2983
+ attempts === null
2984
+ ? h('p', { className: 'tacit-panel-hint' }, t('usage.loading'))
2985
+ : attempts.map((attempt) => AttemptRow(kit, attempt))))
2986
+ }
2987
+
2988
+ /** One run row; the first cell is the disclosure button for its attempts. */
2989
+ function RunRow(kit, { item, open, onToggleRun }) {
2990
+ const { t, fmtTime } = kit
2991
+ // A bootstrap run carries no workspace because it covers every session,
2992
+ // which is worth saying; any other run type missing the field is simply
2993
+ // a row with nothing to scope by.
2994
+ const unscoped = item.type === 'bootstrap' ? t('usage.scopeAll') : '—'
2995
+ const scope = item.workspace.length > 0 && item.turn !== null
2996
+ ? item.workspace + ' · #' + String(item.turn)
2997
+ : (item.workspace.length > 0 ? item.workspace : (item.turn !== null ? '#' + String(item.turn) : unscoped))
2998
+ const cell = (key, child) => h('span', { key, className: 'tacit-usage-cell', role: 'cell' }, child)
2999
+ return h('div', { className: 'tacit-usage-row', role: 'row' },
3000
+ cell('time', h('button', {
3001
+ type: 'button',
3002
+ className: 'tacit-btn tacit-btn-sm tacit-run-toggle',
3003
+ 'aria-expanded': open,
3004
+ 'aria-controls': 'tacit-run-' + item.runId,
3005
+ onClick: () => onToggleRun(item.runId),
3006
+ }, (open ? '▾ ' : '▸ ') + fmtTime(item.startedAt))),
3007
+ cell('op', t('runtype.' + item.type)),
3008
+ cell('scope', scope),
3009
+ cell('model', item.model.length > 0 ? item.model : '—'),
3010
+ cell('status', h('span', { className: 'tacit-chip tacit-status-' + item.status }, t('status.' + item.status))),
3011
+ cell('calls', fmtTokens(item.billedCalls)),
3012
+ cell('tokens', fmtTokens(tokensTotal(item.tokens))),
3013
+ cell('cost', usageSpend(kit, item)))
3014
+ }
3015
+
3016
+ /** The runs table plus its pager. */
3017
+ function UsageRuns(kit, { runs, expanded, runDetails, onToggleRun, onFilter }) {
3018
+ const { t } = kit
3019
+ const pages = Math.max(1, Math.ceil(runs.total / Math.max(1, runs.pageSize)))
3020
+ const page = Math.min(Math.max(1, runs.page), pages)
3021
+ return h('div', { className: 'tacit-usage-runs' },
3022
+ h('div', { className: 'tacit-usage-table', role: 'table', 'aria-label': t('card.usage') },
3023
+ h('div', { className: 'tacit-usage-row tacit-usage-head', role: 'row' },
3024
+ USAGE_COLUMNS.map((column) => h('span', {
3025
+ key: column,
3026
+ className: 'tacit-usage-cell',
3027
+ role: 'columnheader',
3028
+ }, t('usage.col.' + column)))),
3029
+ runs.items.map((item) => {
3030
+ const open = expanded.has(item.runId)
3031
+ return h(React.Fragment, { key: item.runId },
3032
+ RunRow(kit, { item, open, onToggleRun }),
3033
+ open ? AttemptsRow(kit, { runId: item.runId, run: runDetails[item.runId] }) : null)
3034
+ })),
3035
+ h('div', { className: 'tacit-pager' },
3036
+ h('button', {
3037
+ type: 'button',
3038
+ className: 'tacit-btn tacit-btn-sm',
3039
+ disabled: page <= 1,
3040
+ onClick: () => onFilter({ page: page - 1 }),
3041
+ }, t('usage.prev')),
3042
+ h('span', { className: 'tacit-pager-label' }, t('usage.page', { page: String(page), pages: String(pages) })),
3043
+ h('button', {
3044
+ type: 'button',
3045
+ className: 'tacit-btn tacit-btn-sm',
3046
+ disabled: page >= pages,
3047
+ onClick: () => onFilter({ page: page + 1 }),
3048
+ }, t('usage.next'))))
3049
+ }
3050
+
3051
+ /**
3052
+ * The whole Usage card. A plain renderer, not a component: it is called
3053
+ * from `CoachPanelView`'s render, so it must never own hooks — every bit
3054
+ * of its state lives in `rootStore` and arrives as a prop.
3055
+ */
3056
+ function UsageCard(kit, props) {
3057
+ const { t } = kit
3058
+ const usage = usageOf(props.usage)
3059
+ const since = fmtDay(usage === null ? 0 : usage.trackingSince)
3060
+ if (usage === null || usage.lifetime.billedCalls === 0) {
3061
+ return h('div', { className: 'tacit-usage' },
3062
+ h('p', { className: 'tacit-panel-hint' }, t('usage.empty', { since })))
3063
+ }
3064
+ const filters = props.filters !== null && typeof props.filters === 'object' ? props.filters : {}
3065
+ const value = (key) => (typeof filters[key] === 'string' ? filters[key] : '')
3066
+ const expanded = props.expanded instanceof Set ? props.expanded : new Set()
3067
+ const runDetails = props.runs !== null && typeof props.runs === 'object' ? props.runs : {}
3068
+ const which = props.series === '7' ? '7' : '30'
3069
+ const last30 = usage.last30
3070
+ return h('div', { className: 'tacit-usage' },
3071
+ h('p', { className: 'tacit-panel-hint' }, t('usage.label')),
3072
+ h('div', { className: 'tacit-tiles' },
3073
+ SpendTile(kit, 'today', t('usage.today'), usage.today),
3074
+ SpendTile(kit, 'month', t('usage.month'), usage.month),
3075
+ SpendTile(kit, 'last30', t('usage.last30'), last30),
3076
+ SpendTile(kit, 'lifetime', t('usage.since', { day: since }), usage.lifetime)),
3077
+ h('div', { className: 'tacit-tiles' },
3078
+ UsageTile('calls', t('usage.calls'), fmtTokens(last30.billedCalls)),
3079
+ UsageTile('avg', t('usage.avgAnalysis'), last30.avgAnalysisUsd === null
3080
+ ? t('usage.priceUnavailable')
3081
+ : fmtUsd(last30.avgAnalysisUsd)),
3082
+ UsageTile('cached', t('usage.cachedRate'), fmtPct(last30.cachedInputRate)),
3083
+ UsageTile('unpriced', t('usage.unpriced'), fmtTokens(last30.unpricedCalls))),
3084
+ BarStrip(kit, { series: which === '7' ? usage.series7 : usage.series30, which, onSeries: props.onSeries }),
3085
+ UsageBreakdown(kit, { byType: usage.byType }),
3086
+ WarnBar(kit, { id: 'daily', warning: usage.warnings.daily }),
3087
+ WarnBar(kit, { id: 'monthly', warning: usage.warnings.monthly }),
3088
+ UsageFilters(kit, {
3089
+ filters: {
3090
+ range: value('range').length > 0 ? value('range') : '30d',
3091
+ type: value('type'),
3092
+ status: value('status'),
3093
+ model: value('model'),
3094
+ workspace: value('workspace'),
3095
+ sessionId: value('sessionId'),
3096
+ },
3097
+ models: Object.keys(usage.byModel).sort(),
3098
+ onFilter: props.onFilter,
3099
+ }),
3100
+ UsageRuns(kit, {
3101
+ runs: usage.runs,
3102
+ expanded,
3103
+ runDetails,
3104
+ onToggleRun: props.onToggleRun,
3105
+ onFilter: props.onFilter,
3106
+ }))
3107
+ }
3108
+
3109
+ // ── Pricing card (Settings → Pricing) ──────────────────────────────────
3110
+
3111
+ /** The two tiers and the three quoted rates, in the order the table lists them. */
3112
+ const PRICING_TIERS = ['offPeak', 'peak']
3113
+ const PRICING_COLUMNS = ['model', 'tier', 'cacheHit', 'cacheMiss', 'output', 'reasoning']
3114
+
3115
+ /**
3116
+ * The `usage.pricing` block, narrowed. Rates are copied per model and tier
3117
+ * so a missing or malformed triple renders an em dash instead of a number
3118
+ * the provider never quoted.
3119
+ */
3120
+ function pricingOf(value) {
3121
+ const source = value !== null && typeof value === 'object' ? value : {}
3122
+ const text = (key) => (typeof source[key] === 'string' ? source[key] : '')
3123
+ const rates = source.rates !== null && typeof source.rates === 'object' ? source.rates : {}
3124
+ const models = {}
3125
+ for (const [model, tiers] of Object.entries(rates)) {
3126
+ if (tiers === null || typeof tiers !== 'object') continue
3127
+ const entry = {}
3128
+ for (const tier of PRICING_TIERS) {
3129
+ const triple = tiers[tier] !== null && typeof tiers[tier] === 'object' ? tiers[tier] : {}
3130
+ const rate = (value) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined)
3131
+ entry[tier] = { cacheHit: rate(triple.cacheHit), cacheMiss: rate(triple.cacheMiss), output: rate(triple.output) }
3132
+ }
3133
+ models[model] = entry
3134
+ }
3135
+ return {
3136
+ source: source.source === 'costMeter' ? 'costMeter' : 'bundled',
3137
+ asOf: text('asOf'),
3138
+ refreshedAt: typeof source.refreshedAt === 'number' && source.refreshedAt > 0 ? source.refreshedAt : 0,
3139
+ tierNow: source.tierNow === 'peak' ? 'peak' : 'offPeak',
3140
+ error: text('error'),
3141
+ models,
3142
+ }
3143
+ }
3144
+
3145
+ /** `deepseek-v4-flash` → `flash`, for the one-line header summary. */
3146
+ function shortModel(model) {
3147
+ const parts = String(model).split('-').filter((part) => part.length > 0)
3148
+ return parts.length > 0 ? parts[parts.length - 1] : String(model)
3149
+ }
3150
+
3151
+ /** Which price source this table came from, as a phrase. */
3152
+ function pricingSourceLabel(kit, priced) {
3153
+ return kit.t(priced.source === 'costMeter' ? 'pricing.sourceCostMeter' : 'pricing.sourceBundled')
3154
+ }
3155
+
3156
+ /**
3157
+ * The line the collapsed card shows in its header: the flash model — the
3158
+ * default coach model — at the tier in force right now, so the headline
3159
+ * figure is the one a call started this minute would actually be billed at.
3160
+ */
3161
+ function pricingSummary(kit, pricing) {
3162
+ const { t } = kit
3163
+ const priced = pricingOf(pricing)
3164
+ const models = Object.keys(priced.models).sort()
3165
+ const model = models.find((name) => name.endsWith('flash'))
3166
+ const pick = model !== undefined ? model : models[0]
3167
+ if (pick === undefined) return ''
3168
+ const triple = priced.models[pick][priced.tierNow]
3169
+ return t('pricing.summary', {
3170
+ model: shortModel(pick),
3171
+ tier: t('pricing.' + priced.tierNow),
3172
+ rates: fmtRate(triple.cacheHit) + ' / ' + fmtRate(triple.cacheMiss) + ' / ' + fmtRate(triple.output),
3173
+ source: pricingSourceLabel(kit, priced),
3174
+ asOf: priced.asOf.length > 0 ? priced.asOf : '—',
3175
+ })
3176
+ }
3177
+
3178
+ /** One model at one tier. Reasoning is prose: it is billed as output, never quoted apart. */
3179
+ function PricingRow(kit, model, tier, triple) {
3180
+ const { t } = kit
3181
+ const cell = (key, child) => h('span', { key, className: 'tacit-pricing-cell', role: 'cell' }, child)
3182
+ return h('div', { key: model + '-' + tier, className: 'tacit-pricing-row', role: 'row' },
3183
+ h('span', { className: 'tacit-pricing-cell tacit-pricing-model', role: 'rowheader' }, model),
3184
+ cell('tier', t('pricing.' + tier)),
3185
+ cell('cacheHit', fmtRate(triple.cacheHit)),
3186
+ cell('cacheMiss', fmtRate(triple.cacheMiss)),
3187
+ cell('output', fmtRate(triple.output)),
3188
+ cell('reasoning', t('pricing.reasoningSameAsOutput')))
3189
+ }
3190
+
3191
+ /**
3192
+ * The Pricing card body. Like `UsageCard` a plain renderer, not a
3193
+ * component: it owns no hooks and no state. It renders whether or not its
3194
+ * card is open, because the `SectionCard` around it hides a collapsed body
3195
+ * rather than unmounting it, which is what keeps find-in-page working over
3196
+ * the rate table.
3197
+ */
3198
+ function PricingCard(kit, { pricing, onRefresh, refreshing }) {
3199
+ const { t, fmtTime } = kit
3200
+ const priced = pricingOf(pricing)
3201
+ const models = Object.keys(priced.models).sort()
3202
+ const when = priced.refreshedAt > 0
3203
+ ? t('pricing.refreshedAt', { time: fmtDay(priced.refreshedAt) + ' ' + fmtTime(priced.refreshedAt) })
3204
+ : t('pricing.never')
3205
+ const asOf = priced.asOf.length > 0 ? priced.asOf : '—'
3206
+ return h('div', { className: 'tacit-pricing' },
3207
+ h('div', { className: 'tacit-report-title', id: 'tacit-pricing-title' }, t('pricing.rateTable')),
3208
+ models.length === 0
3209
+ ? h('p', { className: 'tacit-panel-hint' }, t('usage.priceUnavailable'))
3210
+ : h('div', { className: 'tacit-pricing-table', role: 'table', 'aria-labelledby': 'tacit-pricing-title' },
3211
+ h('div', { className: 'tacit-pricing-row tacit-pricing-head', role: 'row' },
3212
+ PRICING_COLUMNS.map((column) => h('span', {
3213
+ key: column,
3214
+ className: 'tacit-pricing-cell',
3215
+ role: 'columnheader',
3216
+ }, t('pricing.' + column)))),
3217
+ models.map((model) => PRICING_TIERS.map((tier) => PricingRow(kit, model, tier, priced.models[model][tier])))),
3218
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.tierNow', { tier: t('pricing.' + priced.tierNow) })),
3219
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.schedule')),
3220
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.weekendRule')),
3221
+ h('p', { className: 'tacit-panel-hint' },
3222
+ t('pricing.source') + ': ' + pricingSourceLabel(kit, priced) + ' (' + asOf + ') · ' + when),
3223
+ priced.error.length > 0
3224
+ ? h('p', { className: 'tacit-pricing-error' }, t('pricing.error', { error: priced.error }))
3225
+ : null,
3226
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.formula')),
3227
+ h('p', { className: 'tacit-panel-hint' }, t('pricing.accuracy')),
3228
+ h('div', { className: 'tacit-settings-row' },
3229
+ h('button', {
3230
+ type: 'button',
3231
+ className: 'tacit-btn tacit-btn-sm tacit-pricing-refresh',
3232
+ disabled: refreshing === true,
3233
+ onClick: onRefresh,
3234
+ }, refreshing === true ? t('pricing.refreshing') : t('pricing.refresh'))))
3235
+ }
1612
3236
  // ── CSS (injected once) ────────────────────────────────────────────────
1613
3237
 
1614
3238
  const css = '.tacit-root{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);padding:16px 20px 32px;font-size:13px;overflow-y:auto}'
@@ -1619,6 +3243,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1619
3243
  + '.tacit-btn{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;cursor:pointer;padding:4px 10px}.tacit-btn:hover{border-color:var(--dsw-alias-label-primary)}.tacit-btn:disabled{opacity:.55;cursor:default}'
1620
3244
  + '.tacit-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}.tacit-btn-primary:hover{border-color:transparent;filter:brightness(1.1)}'
1621
3245
  + '.tacit-btn-sm{padding:2px 8px;font-size:11px}'
3246
+ + '.tacit-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent)}.tacit-btn-danger:hover{border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent)}'
1622
3247
  + '.tacit-row{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}'
1623
3248
  + '.tacit-row-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.tacit-row-heading{display:flex;align-items:baseline;gap:8px;font-weight:600}.tacit-row-turn{font-size:13px}.tacit-row-time{color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}.tacit-row-live{color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:600}'
1624
3249
  + '.tacit-row-chips{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto}.tacit-chip{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px}.tacit-chip-warn{color:var(--dsw-alias-state-warn-primary)}'
@@ -1640,6 +3265,10 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1640
3265
  + '.tacit-settings{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}'
1641
3266
  + '.tacit-settings-title{font-size:12px;font-weight:600}.tacit-settings-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-settings-label{font-size:12px;color:var(--dsw-alias-label-secondary);min-width:150px}'
1642
3267
  + '.tacit-settings-notice{font-size:11px;color:var(--dsw-alias-state-success-primary)}'
3268
+ // The idle live region has to stay mounted to announce, but as an empty
3269
+ // flex item it still claims one of the panel's 10px gaps; -5px a side
3270
+ // gives that back.
3271
+ + '.tacit-panel>.tacit-settings-notice:empty{margin-block:-5px}'
1643
3272
  + '.tacit-select,.tacit-input{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:3px 8px}.tacit-input{width:90px}'
1644
3273
  + '.tacit-improve-btn{font:inherit;font-size:13px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-secondary);background:transparent;border:0;border-radius:24px;cursor:pointer;height:28px;padding:0 8px;white-space:nowrap;display:inline-flex;align-items:center;gap:4px}.tacit-improve-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}'
1645
3274
  + '.tacit-check{width:14px;height:14px;accent-color:var(--dsw-alias-brand-primary);flex:none;margin:2px 4px 0 0}'
@@ -1653,12 +3282,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1653
3282
  + ''
1654
3283
  + '.tacit-modal-backdrop{z-index:200;background:#00000073;display:flex;justify-content:center;align-items:center;position:fixed;inset:0}'
1655
3284
  + '.tacit-modal-card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:min(760px,100vw - 48px);max-height:min(84vh,800px);box-shadow:var(--dsw-shadow-lv3,0 12px 32px #0006);color:var(--dsw-alias-label-primary);border-radius:12px;padding:16px 18px 18px;font-size:13px;display:flex;flex-direction:column;gap:10px;overflow-y:auto}'
1656
- + '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin-right:auto}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
3285
+ + '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin:0 auto 0 0}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
1657
3286
  + '.tacit-modal-pending{color:var(--dsw-alias-label-secondary);padding:16px 0;text-align:center}'
3287
+ + '.tacit-modal-unchanged{color:var(--dsw-alias-label-primary);padding:16px 0;text-align:center}'
1658
3288
  + '.tacit-modal-cols{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tacit-modal-col{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;min-width:0;max-height:300px;overflow-y:auto}'
1659
3289
  + '.tacit-modal-col-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}'
1660
3290
  + '.tacit-modal-rationale-text{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.6}.tacit-modal-savings{color:var(--dsw-alias-state-success-primary);font-size:12px;font-weight:600}'
1661
3291
  + '.tacit-modal-actions{display:flex;gap:8px;justify-content:flex-end}'
3292
+ + '.tacit-confirm-body{margin:0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary)}'
3293
+ + '.tacit-confirm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:4px}'
1662
3294
  + '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:0 2px 8px;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
1663
3295
  + '.tacit-feedback-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-feedback-title{color:var(--dsw-alias-label-secondary)}'
1664
3296
  + '.tacit-feedback-vote{font:inherit;font-size:14px;line-height:1;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 8px}.tacit-feedback-vote:hover{border-color:var(--dsw-alias-brand-primary)}.tacit-feedback-vote-active{border-color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}'
@@ -1668,6 +3300,46 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1668
3300
  + '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}.tacit-chip-scope{color:var(--dsw-alias-brand-primary)}.tacit-select{max-width:11rem}'
1669
3301
  + '.tacit-directive{display:flex;align-items:center;gap:8px}.tacit-directive-text{flex:1;min-width:0}.tacit-directive-off .tacit-directive-text{opacity:.5;text-decoration:line-through}.tacit-directive-input{width:min(420px,100%);flex:1}.tacit-preview summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-preview pre{margin-top:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:8px 10px}'
1670
3302
  + '.tacit-rules-list{display:flex;flex-direction:column;gap:6px}.tacit-rule{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}'
3303
+ + '.tacit-card{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1)}'
3304
+ + '.tacit-card-head{width:100%;display:flex;align-items:center;gap:8px;text-align:left;padding:10px 12px;background:none;border:0;cursor:pointer;font:inherit;color:inherit}'
3305
+ + '.tacit-card-head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary)}'
3306
+ + '.tacit-card-title{font-size:13px;font-weight:600}.tacit-card-summary{font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
3307
+ + '.tacit-card-count{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px;font-variant-numeric:tabular-nums}'
3308
+ + '.tacit-card-chevron{margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:11px}'
3309
+ + '.tacit-card-body{padding:0 12px 12px}'
3310
+ + '.tacit-usage{display:flex;flex-direction:column;gap:10px}'
3311
+ + '.tacit-tiles{display:flex;flex-wrap:wrap;gap:8px}'
3312
+ + '.tacit-tile{flex:1 1 140px;display:flex;flex-direction:column;gap:2px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 10px;min-width:0}'
3313
+ + '.tacit-tile-label{font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-tile-value{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.tacit-tile-note{font-size:10px;color:var(--dsw-alias-state-warn-primary)}'
3314
+ + '.tacit-usage-chart{display:flex;flex-direction:column;gap:6px}.tacit-bars{width:100%;height:48px}.tacit-bar{fill:var(--dsw-alias-brand-primary)}'
3315
+ + '.tacit-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}'
3316
+ + '.tacit-series-toggle{display:flex;gap:6px}.tacit-series-toggle button[aria-pressed=true]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary)}'
3317
+ + '.tacit-usage-breakdown{display:flex;flex-direction:column;gap:4px}'
3318
+ + '.tacit-breakdown-row{display:grid;grid-template-columns:minmax(0,1.6fr) 4rem 5rem 5rem minmax(60px,1fr);align-items:center;gap:6px;font-size:12px;font-variant-numeric:tabular-nums}'
3319
+ + '.tacit-breakdown-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-breakdown-calls,.tacit-breakdown-tokens,.tacit-breakdown-usd{text-align:right;color:var(--dsw-alias-label-secondary)}'
3320
+ + '.tacit-share-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-share{background:var(--dsw-alias-brand-primary);border-radius:3px;height:6px}'
3321
+ + '.tacit-warn-bar{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;font-size:11px}'
3322
+ + '.tacit-warn-label{color:var(--dsw-alias-label-secondary)}.tacit-warn-track{background:var(--dsw-alias-bg-layer-2);border-radius:3px;height:6px;overflow:hidden}.tacit-warn-fill{background:var(--dsw-alias-label-secondary);border-radius:3px;height:6px}'
3323
+ + '.tacit-warn-warn{border-color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-label{color:var(--dsw-alias-state-warn-primary)}.tacit-warn-warn .tacit-warn-fill{background:var(--dsw-alias-state-warn-primary)}'
3324
+ + '.tacit-warn-exceeded{border-color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-label{color:var(--dsw-alias-state-error-primary)}.tacit-warn-exceeded .tacit-warn-fill{background:var(--dsw-alias-state-error-primary)}'
3325
+ + '.tacit-usage-filters{display:flex;flex-direction:column;gap:6px}.tacit-filter-row{display:flex;flex-wrap:wrap;gap:8px}.tacit-filter{display:flex;flex-direction:column;gap:2px;min-width:0}.tacit-filter-label{font-size:10px;color:var(--dsw-alias-label-secondary)}'
3326
+ + '.tacit-usage-runs{display:flex;flex-direction:column;gap:6px}'
3327
+ + '.tacit-usage-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
3328
+ + '.tacit-usage-row{display:grid;grid-template-columns:minmax(90px,1fr) minmax(90px,1.2fr) minmax(80px,1.2fr) minmax(90px,1.2fr) 5rem 4rem 5rem 5rem;gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
3329
+ + '.tacit-usage-table>.tacit-usage-row:first-child{border-top:0}.tacit-usage-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
3330
+ + '.tacit-usage-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
3331
+ + '.tacit-run-toggle{width:100%;text-align:left}'
3332
+ + '.tacit-usage-attempts{border-top:1px dashed var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);padding:6px 8px}.tacit-usage-attempts-cell{display:flex;flex-direction:column;gap:4px}'
3333
+ + '.tacit-attempt{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;color:var(--dsw-alias-label-primary)}.tacit-attempt-meta,.tacit-attempt-tokens{color:var(--dsw-alias-label-secondary)}.tacit-attempt-usd{margin-left:auto;font-variant-numeric:tabular-nums}'
3334
+ + '.tacit-status-success,.tacit-status-ok{color:var(--dsw-alias-state-success-primary)}.tacit-status-partial,.tacit-status-unmetered{color:var(--dsw-alias-state-warn-primary)}.tacit-status-failed{color:var(--dsw-alias-state-error-primary)}.tacit-status-running{color:var(--dsw-alias-label-secondary)}'
3335
+ + '.tacit-pager{display:flex;align-items:center;gap:8px}.tacit-pager-label{font-size:11px;color:var(--dsw-alias-label-secondary)}'
3336
+ + '.tacit-pricing{display:flex;flex-direction:column;gap:8px}'
3337
+ + '.tacit-pricing-table{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;overflow:hidden}'
3338
+ + '.tacit-pricing-row{display:grid;grid-template-columns:minmax(120px,1.6fr) 6rem repeat(3,5rem) minmax(90px,1fr);gap:6px;align-items:center;padding:5px 8px;border-top:1px solid var(--dsw-alias-border-l1);font-size:11px;font-variant-numeric:tabular-nums}'
3339
+ + '.tacit-pricing-table>.tacit-pricing-row:first-child{border-top:0}.tacit-pricing-head{color:var(--dsw-alias-label-secondary);font-weight:600;background:var(--dsw-alias-bg-layer-2)}'
3340
+ + '.tacit-pricing-cell{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tacit-pricing-model{font-weight:600}'
3341
+ + '.tacit-pricing-error{font-size:11px;color:var(--dsw-alias-state-warn-primary);line-height:1.5}'
3342
+ + '@media (max-width:640px){.tacit-settings-label{min-width:0;flex-basis:100%}.tacit-modal-cols{grid-template-columns:1fr}.tacit-usage-row{display:flex;flex-direction:column}.tacit-usage-cell{white-space:normal}.tacit-pricing-row{display:flex;flex-direction:column}.tacit-pricing-cell{white-space:normal}.tacit-tile{flex-basis:45%}.tacit-breakdown-row{grid-template-columns:minmax(0,1fr) 4rem 5rem}}'
1671
3343
 
1672
3344
  function injectCss() {
1673
3345
  const tagId = 'dsh-tacit/styles.css'
@@ -1685,6 +3357,9 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1685
3357
  function apply(ctx) {
1686
3358
  ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'dsh-tacit: dictionaries')
1687
3359
  const t = ctx.locale.bind(NS)
3360
+ // Store actions write result notices themselves — they hold the measured
3361
+ // figures — so the one bound translator is handed to them here.
3362
+ setTranslator(t)
1688
3363
  const kit = makeKit(t)
1689
3364
  injectCss()
1690
3365
 
@@ -1752,6 +3427,25 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1752
3427
  closeFeedback,
1753
3428
  fadeFeedback,
1754
3429
  rootStore,
3430
+ toggleSection,
3431
+ setRootNotice,
3432
+ fetchUsage,
3433
+ setUsageFilter,
3434
+ toggleUsageRun,
3435
+ startUsagePolling,
3436
+ stopUsagePolling,
3437
+ setUsageSeries,
3438
+ refreshPricing,
3439
+ openConfirm,
3440
+ closeConfirm,
3441
+ fetchBootstrapPreview,
3442
+ clearAllRoot,
3443
+ clearUsageHistory,
3444
+ runNotice,
3445
+ fmtRate,
3446
+ ConfirmDialog,
3447
+ UsageCard,
3448
+ PricingCard,
1755
3449
  },
1756
3450
  }
1757
3451
  },