dsh-tacit 0.3.0 → 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/README.md CHANGED
@@ -50,14 +50,14 @@ already configured in the harness (Tacit never reads it).
50
50
  | | What | Cost |
51
51
  | --- | --- | --- |
52
52
  | **Zero-click learning** | messy turns and your own corrections are analyzed in the background, with the previous turn as context; automatic analyses are capped per day (30 by default) | $0.001–0.003 each |
53
- | **Directives that earn their place** | learned directives are injected as a short system-prompt section you can read, edit, toggle or delete; a new one starts as a *candidate* and is retired if your messy-turn rate gets worse | free |
53
+ | **Directives that earn their place** | learned directives are injected as a short system-prompt section you can read, edit, toggle or delete; a new one goes on trial (one per scope at a time) and is retired if you start correcting the agent more often | free |
54
54
  | **✨ Improve** | a composer button that rewrites your current draft using what Tacit has learned, with a before/after preview and 👍/👎 | $0.001–0.002 per click |
55
- | **Measured, not guessed** | Settings shows your real trend — messy-turn rate and tokens per turn, first 20 turns vs. latest 20 — and Tacit's own spend: every call metered and priced at list price, shown in Settings | free |
55
+ | **Measured, not guessed** | Settings shows your real trend — how often you correct the agent, messy-turn rate and tokens per turn, first 20 turns vs. latest 20 — and Tacit's own spend: every call metered and priced at list price, shown in Settings | free |
56
56
 
57
57
  <p align="center">
58
- <img alt="Tacit settings with automatic learning controls and an editable active directive" src="https://raw.githubusercontent.com/hackernotfound/dsh-tacit/main/docs/assets/tacit-settings.png" width="960">
58
+ <img alt="Settings → Tacit: the Overview card with automatic learning status and the Usage card with spend tiles, a daily spend chart, spend by operation and the run list" src="https://raw.githubusercontent.com/hackernotfound/dsh-tacit/main/docs/assets/tacit-settings.png" width="960">
59
59
  </p>
60
- <p align="center"><sub>Settings → Tacit, captured from a clean local profile with a synthetic example directive. (Predates the eight-card layout and the usage/cost dashboard — pending a recapture.)</sub></p>
60
+ <p align="center"><sub>Settings → Tacit with Overview and Usage expanded, captured from a clean local profile seeded with synthetic runs and directives.</sub></p>
61
61
 
62
62
  ## How it works
63
63
 
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} 轮,真实数据)',
@@ -80,6 +81,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
80
81
  'steer.remove': '删除',
81
82
  'steer.preview': '实际注入的文本',
82
83
  'steer.distilled': '自动提炼',
84
+ 'steer.queued': '等待试用',
83
85
  'steer.trial': '试用 {n}/{total}',
84
86
  'steer.active': '已生效',
85
87
  'steer.retired': '已淘汰 · {reason}',
@@ -185,6 +187,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
185
187
  'usage.col.time': '时间',
186
188
  'usage.col.op': '操作',
187
189
  'usage.col.scope': '范围',
190
+ 'usage.scopeAll': '全部会话',
188
191
  'usage.col.model': '模型',
189
192
  'usage.col.status': '状态',
190
193
  'usage.col.calls': '计费调用',
@@ -236,6 +239,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
236
239
  'privacy.warnHint': '填 0 表示关闭提醒。达到阈值的 80% 时开始提醒,超过阈值后标记为已超出。',
237
240
  'privacy.clearUsage': '清除用量记录',
238
241
  'privacy.apply': '应用',
242
+ 'privacy.applyTo': '{action}:{field}',
239
243
  'confirm.reportsTitle': '清除所有分析报告?',
240
244
  'confirm.reportsBody': '这会删除 Tacit 为所有会话写下的全部分析报告。指令、风格规则和用量记录会保留。此操作无法撤销。',
241
245
  'confirm.usageTitle': '清除用量记录?',
@@ -280,6 +284,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
280
284
  'err.busy': '该轮次的分析已在运行中。',
281
285
  'err.no-llm': '无法访问模型服务。',
282
286
  'err.no-api-key': 'DeepSeek API Key 缺失或无效(请在 设置 → 模型 中配置)。',
287
+ 'err.no-credit': '模型服务商余额或配额已耗尽。',
283
288
  'err.rate-limited': '请求被限流,请稍后重试。',
284
289
  'err.timeout': '模型调用超时,请重试。',
285
290
  'err.empty-response': '模型没有返回内容,请重试。',
@@ -295,6 +300,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
295
300
  'status.autoOn': 'Auto-learning on · {today}/{budget} today',
296
301
  'status.autoOff': 'Auto-learning off — Tacit only learns when you click Analyze.',
297
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}',
298
304
  'status.trendMessy': 'Messy turns: {a} → {b}',
299
305
  'status.trendTokens': 'Tokens/turn: {a} → {b}',
300
306
  'status.trendHint': '(first {n} turns vs. latest {n}, measured)',
@@ -325,6 +331,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
325
331
  'steer.remove': 'Remove',
326
332
  'steer.preview': 'Exact text injected',
327
333
  'steer.distilled': 'distilled',
334
+ 'steer.queued': 'waiting for trial',
328
335
  'steer.trial': 'trial {n}/{total}',
329
336
  'steer.active': 'active',
330
337
  'steer.retired': 'retired · {reason}',
@@ -430,6 +437,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
430
437
  'usage.col.time': 'Time',
431
438
  'usage.col.op': 'Operation',
432
439
  'usage.col.scope': 'Scope',
440
+ 'usage.scopeAll': 'all sessions',
433
441
  'usage.col.model': 'Model',
434
442
  'usage.col.status': 'Status',
435
443
  'usage.col.calls': 'Billed calls',
@@ -481,6 +489,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
481
489
  'privacy.warnHint': '0 turns the warning off. Tacit warns at 80 % of the amount and marks it exceeded above it.',
482
490
  'privacy.clearUsage': 'Clear usage history',
483
491
  'privacy.apply': 'Apply',
492
+ 'privacy.applyTo': '{action}: {field}',
484
493
  'confirm.reportsTitle': 'Clear all analysis reports?',
485
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.',
486
495
  'confirm.usageTitle': 'Clear usage history?',
@@ -525,6 +534,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
525
534
  'err.busy': 'An analysis for this turn is already running.',
526
535
  'err.no-llm': 'The model service is unavailable.',
527
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.',
528
538
  'err.rate-limited': 'The request was rate-limited; try again shortly.',
529
539
  'err.timeout': 'The model call timed out; please retry.',
530
540
  'err.empty-response': 'The model returned no content; please retry.',
@@ -655,7 +665,6 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
655
665
  usageFilters: { range: '30d', type: '', status: '', model: '', workspace: '', sessionId: '', page: 1, pageSize: 20 },
656
666
  usageRuns: {}, // runId → the full run (attempts included), fetched on first expand
657
667
  usageExpanded: new Set(), // runIds whose attempt rows are open
658
- usageLoading: false,
659
668
  usageSeries: '30', // '7' | '30' — which sparkline the strip shows
660
669
  pricingRefreshing: false, // a /pricing-refresh call is in flight
661
670
  initStarted: false,
@@ -775,14 +784,12 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
775
784
 
776
785
  /** Read the whole cost panel in one call; a failure keeps the last envelope. */
777
786
  async function fetchUsage() {
778
- rootStore.usageLoading = true
779
787
  try {
780
788
  const result = await api('/usage', usageQuery())
781
789
  if (result !== null && typeof result === 'object' && result.ok === true) rootStore.usage = result
782
790
  } catch {
783
791
  // A stale panel beats a blank one; the next poll tries again.
784
792
  }
785
- rootStore.usageLoading = false
786
793
  notifyRoot()
787
794
  }
788
795
 
@@ -1752,6 +1759,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
1752
1759
  h('div', { className: 'tacit-progress-text' }, autoOn ? t('status.autoHint') : t('status.autoOff')),
1753
1760
  hasTrend
1754
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) })),
1755
1763
  h('span', { className: 'tacit-chip' }, t('status.trendMessy', { a: pct(trend.early.messyRate), b: pct(trend.recent.messyRate) })),
1756
1764
  h('span', { className: 'tacit-chip' }, t('status.trendTokens', { a: fmt(trend.early.tokensPerTurn), b: fmt(trend.recent.tokensPerTurn) })),
1757
1765
  h('span', { className: 'tacit-progress-text' }, t('status.trendHint', { n: String(trend.window) })))
@@ -2253,6 +2261,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2253
2261
  */
2254
2262
  let confirmOpener = null
2255
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
+
2256
2271
  function captureConfirmOpener() {
2257
2272
  confirmOpener = typeof document !== 'undefined' && document !== null ? document.activeElement : null
2258
2273
  }
@@ -2281,10 +2296,25 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2281
2296
  return h('div', {
2282
2297
  className: 'tacit-modal-backdrop',
2283
2298
  onClick: cancel,
2284
- // Escape cancels from anywhere inside: keydown from either button
2285
- // bubbles here.
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.
2286
2303
  onKeyDown: (event) => {
2287
- if (event.key === 'Escape') cancel()
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()
2288
2318
  },
2289
2319
  },
2290
2320
  h('div', {
@@ -2298,8 +2328,8 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2298
2328
  h('h3', { className: 'tacit-modal-title', id: 'tacit-confirm-title' }, title),
2299
2329
  h('p', { className: 'tacit-confirm-body' }, body),
2300
2330
  h('div', { className: 'tacit-confirm-actions' },
2301
- h('button', { type: 'button', className: 'tacit-btn', autoFocus: true, onClick: cancel }, t('confirm.cancel')),
2302
- h('button', { type: 'button', className: 'tacit-btn tacit-btn-danger', onClick: onConfirm }, confirmLabel))))
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))))
2303
2333
  }
2304
2334
 
2305
2335
  // ── Global panel (Settings → Tacit section) ────────────────────────────
@@ -2362,14 +2392,16 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2362
2392
  typeof entry.workspace === 'string' && entry.workspace.length > 0
2363
2393
  ? h('span', { className: 'tacit-chip tacit-chip-scope', title: entry.workspace }, t('steer.workspace', { name: labelOf(entry.workspace) }))
2364
2394
  : null,
2365
- entry.status === 'candidate'
2366
- ? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
2367
- n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
2368
- total: String(config !== null && typeof config.directiveTrialTurns === 'number' ? config.directiveTrialTurns : 10),
2369
- }))
2370
- : entry.status === 'retired'
2371
- ? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('steer.retired', { reason: String(entry.retiredReason || '') }))
2372
- : 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')),
2373
2405
  h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => editDirectives({ action: 'remove', id: entry.id }) }, t('steer.remove'))))),
2374
2406
  h('div', { className: 'tacit-settings-row' },
2375
2407
  h('input', {
@@ -2515,9 +2547,15 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2515
2547
  const confirm = rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'usage'
2516
2548
  ? 'usage'
2517
2549
  : (rootStore.confirm !== null && typeof rootStore.confirm === 'object' && rootStore.confirm.kind === 'reports' ? 'reports' : null)
2518
- const retention = config !== null && typeof config.costHistoryDays === 'number' && RETENTION_DAYS.includes(config.costHistoryDays)
2550
+ const retention = config !== null && typeof config.costHistoryDays === 'number' && Number.isFinite(config.costHistoryDays) && config.costHistoryDays > 0
2519
2551
  ? config.costHistoryDays
2520
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)
2521
2559
  /** Every card is titled by `card.<id>` and driven by `rootStore.sections`. */
2522
2560
  const card = (id, children, extra) => SectionCard(kit, {
2523
2561
  id,
@@ -2584,7 +2622,6 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2584
2622
  card('pricing', [
2585
2623
  PricingCard(kit, {
2586
2624
  pricing: usagePricing,
2587
- open: sections.pricing === true,
2588
2625
  refreshing: rootStore.pricingRefreshing === true,
2589
2626
  onRefresh: () => refreshPricing(t),
2590
2627
  }),
@@ -2661,7 +2698,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2661
2698
  value: String(retention),
2662
2699
  onChange: (event) => updateRootConfig({ costHistoryDays: Number(event.target.value) }),
2663
2700
  },
2664
- ...RETENTION_DAYS.map((days) => h('option', { key: days, value: String(days) }, String(days))))),
2701
+ ...retentionDays.map((days) => h('option', { key: days, value: String(days) }, String(days))))),
2665
2702
  h('div', { className: 'tacit-settings-row' },
2666
2703
  h('label', { className: 'tacit-settings-label', htmlFor: 'tacit-warn-daily' }, t('privacy.warnDaily')),
2667
2704
  h('input', {
@@ -2678,7 +2715,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2678
2715
  className: 'tacit-btn tacit-btn-sm',
2679
2716
  // Two identically labelled buttons in one card: the accessible
2680
2717
  // name has to say which threshold each one applies.
2681
- 'aria-label': t('privacy.apply') + ': ' + t('privacy.warnDaily'),
2718
+ 'aria-label': t('privacy.applyTo', { action: t('privacy.apply'), field: t('privacy.warnDaily') }),
2682
2719
  onClick: () => applyWarn('costWarnDailyUsd', dailyText, setDailyText),
2683
2720
  }, t('privacy.apply'))),
2684
2721
  h('div', { className: 'tacit-settings-row' },
@@ -2697,7 +2734,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2697
2734
  className: 'tacit-btn tacit-btn-sm',
2698
2735
  // Two identically labelled buttons in one card: the accessible
2699
2736
  // name has to say which threshold each one applies.
2700
- 'aria-label': t('privacy.apply') + ': ' + t('privacy.warnMonthly'),
2737
+ 'aria-label': t('privacy.applyTo', { action: t('privacy.apply'), field: t('privacy.warnMonthly') }),
2701
2738
  onClick: () => applyWarn('costWarnMonthlyUsd', monthlyText, setMonthlyText),
2702
2739
  }, t('privacy.apply'))),
2703
2740
  h('p', { className: 'tacit-panel-hint' }, t('privacy.warnHint')),
@@ -2744,7 +2781,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2744
2781
  /** Every run type and op the ledger can tag, in the order the filters list them. */
2745
2782
  const USAGE_RUN_TYPES = ['bootstrap', 'analysis', 'analysis-batch', 'improve', 'directive-distillation', 'style-distillation', 'prompt-enrichment']
2746
2783
  const USAGE_RANGES = ['today', '7d', '30d', 'month', 'all']
2747
- const USAGE_STATUSES = ['success', 'partial', 'failed']
2784
+ const USAGE_STATUSES = ['running', 'success', 'partial', 'failed']
2748
2785
  const USAGE_COLUMNS = ['time', 'op', 'scope', 'model', 'status', 'calls', 'tokens', 'cost']
2749
2786
 
2750
2787
  /** One label/value tile; `note` is the small "n unpriced" line under the value. */
@@ -2942,7 +2979,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2942
2979
  ? run.attempts.filter((attempt) => attempt !== null && typeof attempt === 'object')
2943
2980
  : null
2944
2981
  return h('div', { className: 'tacit-usage-attempts', id: 'tacit-run-' + runId, role: 'row' },
2945
- h('div', { className: 'tacit-usage-cell tacit-usage-attempts-cell', role: 'cell' },
2982
+ h('div', { className: 'tacit-usage-cell tacit-usage-attempts-cell', role: 'cell', 'aria-colspan': USAGE_COLUMNS.length },
2946
2983
  attempts === null
2947
2984
  ? h('p', { className: 'tacit-panel-hint' }, t('usage.loading'))
2948
2985
  : attempts.map((attempt) => AttemptRow(kit, attempt))))
@@ -2951,9 +2988,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
2951
2988
  /** One run row; the first cell is the disclosure button for its attempts. */
2952
2989
  function RunRow(kit, { item, open, onToggleRun }) {
2953
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') : '—'
2954
2995
  const scope = item.workspace.length > 0 && item.turn !== null
2955
2996
  ? item.workspace + ' · #' + String(item.turn)
2956
- : (item.workspace.length > 0 ? item.workspace : (item.turn !== null ? '#' + String(item.turn) : '—'))
2997
+ : (item.workspace.length > 0 ? item.workspace : (item.turn !== null ? '#' + String(item.turn) : unscoped))
2957
2998
  const cell = (key, child) => h('span', { key, className: 'tacit-usage-cell', role: 'cell' }, child)
2958
2999
  return h('div', { className: 'tacit-usage-row', role: 'row' },
2959
3000
  cell('time', h('button', {
@@ -3149,13 +3190,13 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
3149
3190
 
3150
3191
  /**
3151
3192
  * The Pricing card body. Like `UsageCard` a plain renderer, not a
3152
- * component: it owns no hooks and no state. It builds nothing at all while
3153
- * collapsed the header summary already carries the headline rate, and
3154
- * this page re-renders on every 10s usage poll.
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.
3155
3197
  */
3156
- function PricingCard(kit, { pricing, open, onRefresh, refreshing }) {
3198
+ function PricingCard(kit, { pricing, onRefresh, refreshing }) {
3157
3199
  const { t, fmtTime } = kit
3158
- if (open !== true) return null
3159
3200
  const priced = pricingOf(pricing)
3160
3201
  const models = Object.keys(priced.models).sort()
3161
3202
  const when = priced.refreshedAt > 0
@@ -3224,6 +3265,10 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
3224
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}'
3225
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}'
3226
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}'
3227
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}'
3228
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)}'
3229
3274
  + '.tacit-check{width:14px;height:14px;accent-color:var(--dsw-alias-brand-primary);flex:none;margin:2px 4px 0 0}'
@@ -3398,6 +3443,7 @@ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || ty
3398
3443
  clearUsageHistory,
3399
3444
  runNotice,
3400
3445
  fmtRate,
3446
+ ConfirmDialog,
3401
3447
  UsageCard,
3402
3448
  PricingCard,
3403
3449
  },
package/lib/analyze.js CHANGED
@@ -114,6 +114,7 @@ export const DIRECTIVE_TOOL = {
114
114
  items: {
115
115
  type: 'object',
116
116
  properties: {
117
+ id: { type: 'string', description: 'the [id] of the current directive this one keeps or rewords; omit for a genuinely new directive' },
117
118
  text: { type: 'string', maxLength: 220, description: 'one sentence, at most 25 words' },
118
119
  workspace: { type: 'string', description: 'only when the habit shows up in exactly one workspace: that workspace name as written in the evidence' },
119
120
  },
@@ -125,6 +126,67 @@ export const DIRECTIVE_TOOL = {
125
126
  },
126
127
  }
127
128
  export const MAX_WORKSPACE_DIRECTIVES = 4
129
+ /** Retired directives kept on the profile so the distiller is told not to re-propose them. */
130
+ export const MAX_RETIRED = 6
131
+
132
+ /** The workspace a directive is limited to; '' = every conversation. */
133
+ export const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
134
+ const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
135
+
136
+ /** At most MAX_DIRECTIVES global and MAX_WORKSPACE_DIRECTIVES per-workspace live directives, plus the last MAX_RETIRED retired ones; order kept. */
137
+ export function capDirectives(list) {
138
+ const retired = list.filter((entry) => entry.status === 'retired')
139
+ const forgotten = new Set(retired.slice(0, Math.max(0, retired.length - MAX_RETIRED)))
140
+ const counts = new Map()
141
+ return list.filter((entry) => {
142
+ if (entry.status === 'retired') return !forgotten.has(entry)
143
+ const scope = scopeOf(entry)
144
+ const n = counts.get(scope) ?? 0
145
+ if (n >= (scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES)) return false
146
+ counts.set(scope, n + 1)
147
+ return true
148
+ })
149
+ }
150
+
151
+ /**
152
+ * Merge the model's new complete set of directives ({ id?, text, workspace? })
153
+ * into the profile: user entries are untouched; the global distilled set and
154
+ * the distilled set of every workspace the model mentioned are replaced;
155
+ * distilled entries of other workspaces are kept (their evidence was not in
156
+ * this batch), and so are retired ones (the do-not-re-propose list). An item
157
+ * naming an existing id, or repeating an existing text, keeps that entry's
158
+ * identity, state, trial and enabled flag and only updates the text; a
159
+ * retired entry stays exactly as it is; a new one queues for its trial slot.
160
+ */
161
+ export function mergeDirectives(profile, items, { nextId }) {
162
+ const users = profile.directives.filter((entry) => entry.source === 'user')
163
+ const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
164
+ const prior = profile.directives.filter((entry) => entry.source !== 'user')
165
+ const byId = new Map(prior.map((entry) => [entry.id, entry]))
166
+ const byKey = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
167
+ const mentioned = new Set([''])
168
+ for (const item of items) mentioned.add(scopeOf(item))
169
+ const distilled = []
170
+ const seen = new Set()
171
+ const matched = new Set()
172
+ for (const item of items) {
173
+ const scope = scopeOf(item)
174
+ const key = directiveKey(scope, item.text)
175
+ if (seen.has(key) || userKeys.has(key)) continue
176
+ seen.add(key)
177
+ const kept = (typeof item.id === 'string' ? byId.get(item.id) : undefined) ?? byKey.get(key)
178
+ if (kept !== undefined) {
179
+ if (kept.status === 'retired' || matched.has(kept.id)) continue
180
+ matched.add(kept.id)
181
+ distilled.push({ ...kept, text: item.text })
182
+ continue
183
+ }
184
+ distilled.push({ id: nextId(), text: item.text, enabled: true, source: 'distilled', createdAt: Date.now(), status: 'queued', ...(scope === '' ? {} : { workspace: scope }) })
185
+ }
186
+ const untouched = prior.filter((entry) => !matched.has(entry.id) && (entry.status === 'retired' || !mentioned.has(scopeOf(entry))))
187
+ profile.directives = capDirectives([...users, ...distilled, ...untouched])
188
+ return profile
189
+ }
128
190
 
129
191
  /** The last path segment of a workspace directory — what a person calls the project. */
130
192
  export function workspaceLabel(cwd) {
@@ -201,23 +263,36 @@ export function normalizeEnrichNote(text) {
201
263
 
202
264
  // ── Measured trend ─────────────────────────────────────────────────────────
203
265
 
266
+ /**
267
+ * One conversation's turns, each with `corrected`: whether the user's NEXT
268
+ * message in that conversation reads as a correction of it. The last turn has
269
+ * no next message and counts as not corrected.
270
+ */
271
+ export function markCorrections(turns) {
272
+ const list = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object')
273
+ return list.map((turn, index) => ({ ...turn, corrected: index + 1 < list.length && looksLikeCorrection(list[index + 1].prompt) }))
274
+ }
275
+
204
276
  function windowStats(turns) {
205
277
  const n = turns.length
206
- if (n === 0) return { n: 0, messyRate: 0, tokensPerTurn: 0 }
278
+ if (n === 0) return { n: 0, correctionRate: 0, messyRate: 0, tokensPerTurn: 0 }
279
+ let corrected = 0
207
280
  let messy = 0
208
281
  let tokens = 0
209
282
  for (const turn of turns) {
283
+ if (turn.corrected === true) corrected += 1
210
284
  if (isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })) messy += 1
211
285
  const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
212
286
  const read = (key) => (typeof usage[key] === 'number' && Number.isFinite(usage[key]) ? usage[key] : 0)
213
287
  tokens += read('inputTokens') + read('outputTokens') + read('cacheReadTokens') + read('reasoningTokens')
214
288
  }
215
- return { n, messyRate: messy / n, tokensPerTurn: Math.round(tokens / n) }
289
+ return { n, correctionRate: corrected / n, messyRate: messy / n, tokensPerTurn: Math.round(tokens / n) }
216
290
  }
217
291
 
218
292
  /**
219
293
  * Real before/after numbers from the fold: the first `window` finished turns
220
- * vs. the latest `window`, on rework signals (retries/errors/compactions/
294
+ * vs. the latest `window`, on how often the user corrected the agent (turns
295
+ * pre-marked by `markCorrections`), rework signals (retries/errors/compactions/
221
296
  * rejections — step counts are deliberately NOT a signal) and tokens/turn.
222
297
  */
223
298
  export function computeTrend(turns, { window = 20 } = {}) {
@@ -477,7 +552,9 @@ export const DIRECTIVE_SYSTEM_PROMPT = [
477
552
  'mood, addressed to the agent. A directive that needs a second sentence is two',
478
553
  'directives or too specific. You are writing',
479
554
  'the COMPLETE new set: keep existing directives that still hold (reworded if',
480
- 'sharper), drop ones that were one-off, add what is missing. No preamble.',
555
+ 'sharper, returned with their id), drop ones that were one-off, add what is',
556
+ 'missing. Never re-propose a retired directive or a close rephrasing of one.',
557
+ 'No preamble.',
481
558
  ].join('\n')
482
559
 
483
560
  export function buildDirectiveUserText(profile, recentReports = [], { labelOf = workspaceLabel } = {}) {
@@ -523,10 +600,19 @@ export function buildDirectiveUserText(profile, recentReports = [], { labelOf =
523
600
  lines.push('', '=== STYLE RULES THE USER CONFIRMED ===')
524
601
  for (const rule of rules) lines.push('- ' + clipSafe(rule.rule, 300))
525
602
  }
526
- const existing = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
603
+ const directives = Array.isArray(profile?.directives) ? profile.directives.filter((entry) => typeof entry?.text === 'string' && entry.text.length > 0) : []
604
+ const directiveLine = (entry, withId) => '- ' + (withId && typeof entry.id === 'string' ? '[' + entry.id + '] ' : '') + clipDirective(entry.text)
605
+ + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : '')
606
+ const existing = directives.filter((entry) => entry.status !== 'retired')
527
607
  if (existing.length > 0) {
528
608
  lines.push('', '=== CURRENT DIRECTIVES (keep the ones that still hold) ===')
529
- for (const entry of existing) lines.push('- ' + clipDirective(entry.text) + (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? ' [workspace: ' + labelOf(entry.workspace) + ']' : ''))
609
+ for (const entry of existing) lines.push(directiveLine(entry, true))
610
+ lines.push('When you keep or reword one of these, return it with its [id]; leave id out only for a genuinely new directive.')
611
+ }
612
+ const retired = directives.filter((entry) => entry.status === 'retired').slice(-MAX_RETIRED)
613
+ if (retired.length > 0) {
614
+ lines.push('', '=== RETIRED (made things worse while active; do not re-propose these) ===')
615
+ for (const entry of retired) lines.push(directiveLine(entry, false))
530
616
  }
531
617
  lines.push('', 'Write 2-4 directives for the agent about this user. A habit the user has', 'shown they can fix themselves is still worth a directive: the agent should', 'compensate for it when it is missing, not ask.')
532
618
  return lines.join('\n')
@@ -536,7 +622,8 @@ export function buildDirectiveUserText(profile, recentReports = [], { labelOf =
536
622
  const ASKS_USER_RE = /\b(ask|confirm with|check with|clarify with|verify with|get (approval|confirmation|permission) from) (the )?user\b|\bstop and ask\b|\bbefore doing anything\b|\bask (what|which|whether|if|for)\b/i
537
623
 
538
624
  /**
539
- * Parse a directives payload into clipped, deduped one-liners (≤4 kept);
625
+ * Parse a directives payload into clipped, deduped one-liners (≤4 kept), each
626
+ * with the id of the current directive it restates when the model gave one;
540
627
  * directives that instruct the agent to ask the user are rejected.
541
628
  */
542
629
  export function classifyDirectives(text) {
@@ -554,6 +641,7 @@ export function classifyDirectives(text) {
554
641
  const workspace = typeof item === 'object' && item !== null && typeof item.workspace === 'string' && item.workspace.trim().length > 0
555
642
  ? clipSafe(item.workspace.trim(), 200)
556
643
  : undefined
644
+ const id = typeof item === 'object' && item !== null && typeof item.id === 'string' && item.id.trim().length > 0 ? clipSafe(item.id.trim(), 64) : undefined
557
645
  const key = (workspace ?? '') + '\n' + value.toLowerCase()
558
646
  if (value.length === 0 || seen.has(key)) continue
559
647
  seen.add(key)
@@ -561,7 +649,7 @@ export function classifyDirectives(text) {
561
649
  rejected.push(value)
562
650
  continue
563
651
  }
564
- kept.push(workspace === undefined ? { text: value } : { text: value, workspace })
652
+ kept.push({ text: value, ...(workspace === undefined ? {} : { workspace }), ...(id === undefined ? {} : { id }) })
565
653
  if (kept.length >= 4) break
566
654
  }
567
655
  return { kept, rejected }
@@ -574,14 +662,13 @@ export function classifyDirectives(text) {
574
662
  * nothing is enabled (an empty section contributes nothing).
575
663
  */
576
664
  export function buildSteeringSection(profile, { cwd } = {}) {
577
- const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : null
578
- const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : null)
665
+ const here = typeof cwd === 'string' && cwd.length > 0 ? cwd : ''
579
666
  const candidates = (Array.isArray(profile?.directives) ? profile.directives : [])
580
- .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired'
667
+ .filter((entry) => entry !== null && typeof entry === 'object' && entry.enabled !== false && entry.status !== 'retired' && entry.status !== 'queued'
581
668
  && typeof entry.text === 'string' && entry.text.trim().length > 0)
582
- .filter((entry) => scopeOf(entry) === null || scopeOf(entry) === here)
669
+ .filter((entry) => scopeOf(entry) === '' || scopeOf(entry) === here)
583
670
  // This workspace's own directives first: they are the more specific ones.
584
- const enabled = [...candidates.filter((entry) => scopeOf(entry) !== null), ...candidates.filter((entry) => scopeOf(entry) === null)]
671
+ const enabled = [...candidates.filter((entry) => scopeOf(entry) !== ''), ...candidates.filter((entry) => scopeOf(entry) === '')]
585
672
  if (enabled.length === 0) return { text: '', ids: [] }
586
673
  const header = [
587
674
  '## About this user (learned by Tacit from their past prompts)',
package/lib/index.js CHANGED
@@ -87,7 +87,7 @@ export function apply(ctx, config) {
87
87
  const directives = store.profile().directives
88
88
  const count = (status) => directives.filter((entry) => entry.status === status).length
89
89
  console.info('[tacit] loaded — directives: ' + count('active') + ' active, ' + count('candidate') + ' candidates, '
90
- + count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
90
+ + count('queued') + ' queued, ' + count('retired') + ' retired; steering ' + (cfg.steerAgent ? 'on' : 'off') + '; auto-analysis '
91
91
  + (cfg.autoAnalyze ? 'on (cap ' + cfg.autoDailyBudget + '/day)' : 'off'))
92
92
  } catch {
93
93
  // Logging must never keep the plugin from loading.
package/lib/schema.js CHANGED
@@ -29,7 +29,7 @@ export const COACH_PROVIDER = 'deepseek-official'
29
29
  * outside this list onto one of these. Every entry has an `err.*` key in both
30
30
  * dictionaries (`client/src/10-i18n.js`, test-enforced).
31
31
  */
32
- export const COACH_ERROR_CODES = ['no-llm', 'no-api-key', 'rate-limited', 'timeout', 'empty-response', 'call-failed']
32
+ export const COACH_ERROR_CODES = ['no-llm', 'no-api-key', 'no-credit', 'rate-limited', 'timeout', 'empty-response', 'call-failed']
33
33
 
34
34
  /**
35
35
  * The loader-facing plugin config. Wrapped in `z.preprocess` so a patch row
@@ -232,15 +232,23 @@ const feedbackEntrySchema = z.object({
232
232
  * system prompt). `distilled` entries come from analyses; `user` entries are
233
233
  * typed in Settings and survive every distillation.
234
234
  */
235
- const directiveTrialSchema = z.object({
236
- /** Finished turns observed while the candidate was injected. */
237
- turns: z.number().int().min(0),
238
- /** How many of those were messy. */
239
- messy: z.number().int().min(0),
240
- /** Messy-turn rate over the 20 turns before the trial started. */
241
- baselineRate: z.number(),
242
- startedAt: z.number(),
243
- })
235
+ const directiveTrialSchema = z.preprocess(
236
+ // Profiles written before corrections were graded named the messy baseline `baselineRate`.
237
+ (raw) => (raw !== null && typeof raw === 'object' && 'baselineRate' in raw && !('baselineMessyRate' in raw) ? { ...raw, baselineMessyRate: raw.baselineRate } : raw),
238
+ z.object({
239
+ /** Finished turns observed while the candidate was injected. */
240
+ turns: z.number().int().min(0),
241
+ /** How many of those were messy. */
242
+ messy: z.number().int().min(0),
243
+ /** How many of those the user corrected with their next message. */
244
+ corrected: z.number().int().min(0).default(0),
245
+ /** Messy-turn rate over the 20 turns before the trial started. */
246
+ baselineMessyRate: z.number(),
247
+ /** Correction rate over those 20 turns; -1 = unknown (older profile), recomputed when the trial next advances. */
248
+ baselineCorrectionRate: z.number().default(-1),
249
+ startedAt: z.number(),
250
+ }),
251
+ )
244
252
 
245
253
  const directiveSchema = z.object({
246
254
  id: z.string(),
@@ -248,8 +256,8 @@ const directiveSchema = z.object({
248
256
  enabled: z.boolean().default(true),
249
257
  source: z.enum(['distilled', 'user']).default('distilled'),
250
258
  createdAt: z.number(),
251
- /** candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
252
- status: z.enum(['candidate', 'active', 'retired']).default('active'),
259
+ /** queued = waiting for its scope's trial slot; candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
260
+ status: z.enum(['queued', 'candidate', 'active', 'retired']).default('active'),
253
261
  trial: directiveTrialSchema.optional(),
254
262
  retiredReason: z.string().optional(),
255
263
  /** Absolute workspace directory this directive is limited to; absent = every conversation. */
@@ -347,6 +355,12 @@ export const USAGE_OPS = [
347
355
  'enrichment',
348
356
  ]
349
357
 
358
+ /** Every state one recorded attempt can end in. */
359
+ export const USAGE_ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
360
+
361
+ /** Every state a run can be in. `running` is written too: the flush persists live runs. */
362
+ export const USAGE_RUN_STATUSES = ['running', 'success', 'partial', 'failed']
363
+
350
364
  /** Every kind of run the tracker groups attempts into. */
351
365
  export const USAGE_RUN_TYPES = [
352
366
  'bootstrap',
@@ -382,7 +396,7 @@ export const usageAttemptSchema = z.object({
382
396
  provider: z.string().default(''),
383
397
  reasoningEffort: z.string().nullable().default(null),
384
398
  finish: z.string().default(''),
385
- status: z.enum(['ok', 'failed', 'unmetered']),
399
+ status: z.enum(USAGE_ATTEMPT_STATUSES),
386
400
  code: z.string().default(''),
387
401
  sessionId: z.string().default(''),
388
402
  turn: z.number().nullable().default(null),
@@ -425,7 +439,7 @@ export const usageRunSchema = z.object({
425
439
  trigger: z.string().default(''),
426
440
  startedAt: z.number(),
427
441
  endedAt: z.number().default(0),
428
- status: z.enum(['running', 'success', 'partial', 'failed']).default('running'),
442
+ status: z.enum(USAGE_RUN_STATUSES).default('running'),
429
443
  sessionId: z.string().default(''),
430
444
  turn: z.number().nullable().default(null),
431
445
  workspace: z.string().default(''),
@@ -466,7 +480,7 @@ export const usageSummarySchema = z.object({
466
480
  export const usageArgSchema = z.object({
467
481
  range: z.enum(['today', '7d', '30d', 'month', 'all']).optional(),
468
482
  type: z.enum(USAGE_RUN_TYPES).optional(),
469
- status: z.enum(['success', 'partial', 'failed']).optional(),
483
+ status: z.enum(USAGE_RUN_STATUSES).optional(),
470
484
  model: z.string().max(64).optional(),
471
485
  workspace: z.string().max(200).optional(),
472
486
  sessionId: z.string().max(200).optional(),