@duckmind/dm-darwin-x64 0.43.4 → 0.43.8

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.
Files changed (251) hide show
  1. package/dm +0 -0
  2. package/extensions/.dm-extensions.json +241 -49
  3. package/extensions/dm-9router-ext/package.json +2 -19
  4. package/extensions/dm-9router-ext/src/index.js +5 -0
  5. package/extensions/dm-ask-user/index.js +10 -0
  6. package/extensions/dm-ask-user/package.json +1 -21
  7. package/extensions/dm-cli-anything/package.json +0 -6
  8. package/extensions/dm-context/package.json +2 -12
  9. package/extensions/dm-context/src/context.js +1 -0
  10. package/extensions/dm-context/src/index.js +7 -0
  11. package/extensions/dm-context/src/utils.js +1 -0
  12. package/extensions/dm-cua/package.json +0 -7
  13. package/extensions/dm-fff/package.json +1 -28
  14. package/extensions/dm-fff/src/index.js +15 -0
  15. package/extensions/dm-fff/src/query.js +1 -0
  16. package/extensions/dm-goal/package.json +2 -31
  17. package/extensions/dm-goal/src/goal.js +43 -0
  18. package/extensions/dm-grill-me/index.js +174 -0
  19. package/extensions/dm-grill-me/package.json +1 -26
  20. package/extensions/dm-image2/package.json +0 -7
  21. package/extensions/dm-subagents/package.json +1 -32
  22. package/extensions/dm-subagents/src/agents/agent-management.js +37 -0
  23. package/extensions/dm-subagents/src/agents/agent-memory.js +6 -0
  24. package/extensions/dm-subagents/src/agents/agent-scope.js +1 -0
  25. package/extensions/dm-subagents/src/agents/agent-selection.js +1 -0
  26. package/extensions/dm-subagents/src/agents/agent-serializer.js +7 -0
  27. package/extensions/dm-subagents/src/agents/agents.js +11 -0
  28. package/extensions/dm-subagents/src/agents/chain-serializer.js +12 -0
  29. package/extensions/dm-subagents/src/agents/frontmatter.js +6 -0
  30. package/extensions/dm-subagents/src/agents/identity.js +1 -0
  31. package/extensions/dm-subagents/src/agents/proactive-skills.js +1 -0
  32. package/extensions/dm-subagents/src/agents/skills.js +6 -0
  33. package/extensions/dm-subagents/src/extension/config.js +2 -0
  34. package/extensions/dm-subagents/src/extension/control-notices.js +4 -0
  35. package/extensions/dm-subagents/src/extension/doctor.js +16 -0
  36. package/extensions/dm-subagents/src/extension/fanout-child.js +231 -0
  37. package/extensions/dm-subagents/src/extension/index.js +367 -0
  38. package/extensions/dm-subagents/src/extension/rpc.js +8 -0
  39. package/extensions/dm-subagents/src/extension/schemas.js +1 -0
  40. package/extensions/dm-subagents/src/extension/{tool-description.ts → tool-description.js} +6 -126
  41. package/extensions/dm-subagents/src/intercom/intercom-bridge.js +13 -0
  42. package/extensions/dm-subagents/src/intercom/native-supervisor-channel.js +5 -0
  43. package/extensions/dm-subagents/src/intercom/result-intercom.js +3 -0
  44. package/extensions/dm-subagents/src/profiles/profiles.js +3 -0
  45. package/extensions/dm-subagents/src/runs/background/async-execution.js +46 -0
  46. package/extensions/dm-subagents/src/runs/background/async-job-tracker.js +14 -0
  47. package/extensions/dm-subagents/src/runs/background/async-resume.js +8 -0
  48. package/extensions/dm-subagents/src/runs/background/async-status.js +12 -0
  49. package/extensions/dm-subagents/src/runs/background/chain-append.js +2 -0
  50. package/extensions/dm-subagents/src/runs/background/chain-root-attachment.js +1 -0
  51. package/extensions/dm-subagents/src/runs/background/completion-batcher.js +1 -0
  52. package/extensions/dm-subagents/src/runs/background/completion-dedupe.js +1 -0
  53. package/extensions/dm-subagents/src/runs/background/control-channel.js +1 -0
  54. package/extensions/dm-subagents/src/runs/background/fleet-view.js +17 -0
  55. package/extensions/dm-subagents/src/runs/background/notify.js +3 -0
  56. package/extensions/dm-subagents/src/runs/background/parallel-groups.js +1 -0
  57. package/extensions/dm-subagents/src/runs/background/result-watcher.js +8 -0
  58. package/extensions/dm-subagents/src/runs/background/run-id-resolver.js +4 -0
  59. package/extensions/dm-subagents/src/runs/background/run-status.js +23 -0
  60. package/extensions/dm-subagents/src/runs/background/scheduled-runs.js +4 -0
  61. package/extensions/dm-subagents/src/runs/background/stale-run-reconciler.js +11 -0
  62. package/extensions/dm-subagents/src/runs/background/subagent-runner.js +78 -0
  63. package/extensions/dm-subagents/src/runs/background/top-level-async.js +1 -0
  64. package/extensions/dm-subagents/src/runs/background/wait.js +11 -0
  65. package/extensions/dm-subagents/src/runs/foreground/chain-clarify.js +12 -0
  66. package/extensions/dm-subagents/src/runs/foreground/chain-execution.js +102 -0
  67. package/extensions/dm-subagents/src/runs/foreground/execution.js +51 -0
  68. package/extensions/dm-subagents/src/runs/foreground/subagent-executor.js +228 -0
  69. package/extensions/dm-subagents/src/runs/shared/acceptance.js +3 -0
  70. package/extensions/dm-subagents/src/runs/shared/chain-outputs.js +1 -0
  71. package/extensions/dm-subagents/src/runs/shared/completion-guard.js +3 -0
  72. package/extensions/dm-subagents/src/runs/shared/dm-args.js +1 -0
  73. package/extensions/dm-subagents/src/runs/shared/dm-spawn.js +1 -0
  74. package/extensions/dm-subagents/src/runs/shared/dynamic-fanout.js +1 -0
  75. package/extensions/dm-subagents/src/runs/shared/long-running-guard.js +1 -0
  76. package/extensions/dm-subagents/src/runs/shared/mcp-direct-tool-allowlist.js +1 -0
  77. package/extensions/dm-subagents/src/runs/shared/model-fallback.js +1 -0
  78. package/extensions/dm-subagents/src/runs/shared/model-scope.js +1 -0
  79. package/extensions/dm-subagents/src/runs/shared/nested-events.js +8 -0
  80. package/extensions/dm-subagents/src/runs/shared/nested-path.js +1 -0
  81. package/extensions/dm-subagents/src/runs/shared/nested-render.js +1 -0
  82. package/extensions/dm-subagents/src/runs/shared/parallel-utils.js +5 -0
  83. package/extensions/dm-subagents/src/runs/shared/run-history.js +5 -0
  84. package/extensions/dm-subagents/src/runs/shared/single-output.js +14 -0
  85. package/extensions/dm-subagents/src/runs/shared/structured-output.js +1 -0
  86. package/extensions/dm-subagents/src/runs/shared/subagent-control.js +5 -0
  87. package/extensions/dm-subagents/src/runs/shared/subagent-prompt-runtime.js +22 -0
  88. package/extensions/dm-subagents/src/runs/shared/tool-budget.js +1 -0
  89. package/extensions/dm-subagents/src/runs/shared/turn-budget.js +7 -0
  90. package/extensions/dm-subagents/src/runs/shared/workflow-graph.js +1 -0
  91. package/extensions/dm-subagents/src/runs/shared/worktree.js +3 -0
  92. package/extensions/dm-subagents/src/shared/artifacts.js +2 -0
  93. package/extensions/dm-subagents/src/shared/atomic-json.js +1 -0
  94. package/extensions/dm-subagents/src/shared/child-transcript.js +5 -0
  95. package/extensions/dm-subagents/src/shared/file-coalescer.js +1 -0
  96. package/extensions/dm-subagents/src/shared/fork-context.js +6 -0
  97. package/extensions/dm-subagents/src/shared/formatters.js +9 -0
  98. package/extensions/dm-subagents/src/shared/jsonl-writer.js +2 -0
  99. package/extensions/dm-subagents/src/shared/model-info.js +1 -0
  100. package/extensions/dm-subagents/src/shared/post-exit-stdio-guard.js +1 -0
  101. package/extensions/dm-subagents/src/shared/session-identity.js +1 -0
  102. package/extensions/dm-subagents/src/shared/session-tokens.js +2 -0
  103. package/extensions/dm-subagents/src/shared/settings.js +23 -0
  104. package/extensions/dm-subagents/src/shared/status-format.js +1 -0
  105. package/extensions/dm-subagents/src/shared/types.js +8 -0
  106. package/extensions/dm-subagents/src/shared/utils.js +2 -0
  107. package/extensions/dm-subagents/src/slash/prompt-template-bridge.js +1 -0
  108. package/extensions/dm-subagents/src/slash/prompt-workflows.js +7 -0
  109. package/extensions/dm-subagents/src/slash/slash-bridge.js +1 -0
  110. package/extensions/dm-subagents/src/slash/slash-commands.js +38 -0
  111. package/extensions/dm-subagents/src/slash/slash-live-state.js +6 -0
  112. package/extensions/dm-subagents/src/tui/render-helpers.js +1 -0
  113. package/extensions/dm-subagents/src/tui/render.js +4 -0
  114. package/extensions/dm-tasks/package.json +1 -30
  115. package/extensions/dm-tasks/src/auto-clear.js +1 -0
  116. package/extensions/dm-tasks/src/index.js +212 -0
  117. package/extensions/dm-tasks/src/process-tracker.js +1 -0
  118. package/extensions/dm-tasks/src/task-store.js +1 -0
  119. package/extensions/dm-tasks/src/tasks-config.js +1 -0
  120. package/extensions/dm-tasks/src/types.js +0 -0
  121. package/extensions/dm-tasks/src/ui/settings-menu.js +1 -0
  122. package/extensions/dm-tasks/src/ui/task-widget.js +1 -0
  123. package/extensions/dm-usage/index.js +9 -0
  124. package/extensions/dm-usage/package.json +1 -18
  125. package/extensions/greedysearch-dm/index.js +20 -0
  126. package/extensions/greedysearch-dm/package.json +1 -22
  127. package/extensions/greedysearch-dm/src/formatters/results.js +8 -0
  128. package/extensions/greedysearch-dm/src/formatters/sources.js +1 -0
  129. package/extensions/greedysearch-dm/src/formatters/synthesis.js +1 -0
  130. package/extensions/greedysearch-dm/src/tools/greedy-search-handler.js +20 -0
  131. package/extensions/greedysearch-dm/src/tools/shared.js +9 -0
  132. package/extensions/greedysearch-dm/src/types.js +0 -0
  133. package/extensions/greedysearch-dm/src/utils/helpers.js +1 -0
  134. package/package.json +1 -1
  135. package/extensions/dm-9router-ext/src/index.ts +0 -541
  136. package/extensions/dm-ask-user/index.ts +0 -857
  137. package/extensions/dm-context/src/context.ts +0 -158
  138. package/extensions/dm-context/src/index.ts +0 -439
  139. package/extensions/dm-context/src/utils.ts +0 -6
  140. package/extensions/dm-fff/src/index.ts +0 -1023
  141. package/extensions/dm-fff/src/query.ts +0 -87
  142. package/extensions/dm-goal/src/goal.ts +0 -1073
  143. package/extensions/dm-grill-me/index.ts +0 -1085
  144. package/extensions/dm-subagents/src/agents/agent-management.ts +0 -1052
  145. package/extensions/dm-subagents/src/agents/agent-memory.ts +0 -254
  146. package/extensions/dm-subagents/src/agents/agent-scope.ts +0 -6
  147. package/extensions/dm-subagents/src/agents/agent-selection.ts +0 -25
  148. package/extensions/dm-subagents/src/agents/agent-serializer.ts +0 -121
  149. package/extensions/dm-subagents/src/agents/agents.ts +0 -1553
  150. package/extensions/dm-subagents/src/agents/chain-serializer.ts +0 -277
  151. package/extensions/dm-subagents/src/agents/frontmatter.ts +0 -93
  152. package/extensions/dm-subagents/src/agents/identity.ts +0 -30
  153. package/extensions/dm-subagents/src/agents/proactive-skills.ts +0 -191
  154. package/extensions/dm-subagents/src/agents/skills.ts +0 -729
  155. package/extensions/dm-subagents/src/extension/config.ts +0 -39
  156. package/extensions/dm-subagents/src/extension/control-notices.ts +0 -92
  157. package/extensions/dm-subagents/src/extension/doctor.ts +0 -214
  158. package/extensions/dm-subagents/src/extension/fanout-child.ts +0 -172
  159. package/extensions/dm-subagents/src/extension/index.ts +0 -656
  160. package/extensions/dm-subagents/src/extension/rpc.ts +0 -369
  161. package/extensions/dm-subagents/src/extension/schemas.ts +0 -309
  162. package/extensions/dm-subagents/src/intercom/intercom-bridge.ts +0 -180
  163. package/extensions/dm-subagents/src/intercom/native-supervisor-channel.ts +0 -519
  164. package/extensions/dm-subagents/src/intercom/result-intercom.ts +0 -377
  165. package/extensions/dm-subagents/src/profiles/profiles.ts +0 -637
  166. package/extensions/dm-subagents/src/runs/background/async-execution.ts +0 -1065
  167. package/extensions/dm-subagents/src/runs/background/async-job-tracker.ts +0 -441
  168. package/extensions/dm-subagents/src/runs/background/async-resume.ts +0 -391
  169. package/extensions/dm-subagents/src/runs/background/async-status.ts +0 -395
  170. package/extensions/dm-subagents/src/runs/background/chain-append.ts +0 -282
  171. package/extensions/dm-subagents/src/runs/background/chain-root-attachment.ts +0 -191
  172. package/extensions/dm-subagents/src/runs/background/completion-batcher.ts +0 -166
  173. package/extensions/dm-subagents/src/runs/background/completion-dedupe.ts +0 -63
  174. package/extensions/dm-subagents/src/runs/background/control-channel.ts +0 -332
  175. package/extensions/dm-subagents/src/runs/background/fleet-view.ts +0 -515
  176. package/extensions/dm-subagents/src/runs/background/notify.ts +0 -225
  177. package/extensions/dm-subagents/src/runs/background/parallel-groups.ts +0 -45
  178. package/extensions/dm-subagents/src/runs/background/result-watcher.ts +0 -315
  179. package/extensions/dm-subagents/src/runs/background/run-id-resolver.ts +0 -84
  180. package/extensions/dm-subagents/src/runs/background/run-status.ts +0 -434
  181. package/extensions/dm-subagents/src/runs/background/scheduled-runs.ts +0 -514
  182. package/extensions/dm-subagents/src/runs/background/stale-run-reconciler.ts +0 -368
  183. package/extensions/dm-subagents/src/runs/background/subagent-runner.ts +0 -3171
  184. package/extensions/dm-subagents/src/runs/background/top-level-async.ts +0 -13
  185. package/extensions/dm-subagents/src/runs/background/wait.ts +0 -353
  186. package/extensions/dm-subagents/src/runs/foreground/chain-clarify.ts +0 -1333
  187. package/extensions/dm-subagents/src/runs/foreground/chain-execution.ts +0 -1313
  188. package/extensions/dm-subagents/src/runs/foreground/execution.ts +0 -1239
  189. package/extensions/dm-subagents/src/runs/foreground/subagent-executor.ts +0 -3613
  190. package/extensions/dm-subagents/src/runs/shared/acceptance.ts +0 -879
  191. package/extensions/dm-subagents/src/runs/shared/chain-outputs.ts +0 -116
  192. package/extensions/dm-subagents/src/runs/shared/completion-guard.ts +0 -147
  193. package/extensions/dm-subagents/src/runs/shared/dm-args.ts +0 -271
  194. package/extensions/dm-subagents/src/runs/shared/dm-spawn.ts +0 -147
  195. package/extensions/dm-subagents/src/runs/shared/dynamic-fanout.ts +0 -295
  196. package/extensions/dm-subagents/src/runs/shared/long-running-guard.ts +0 -175
  197. package/extensions/dm-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +0 -365
  198. package/extensions/dm-subagents/src/runs/shared/model-fallback.ts +0 -292
  199. package/extensions/dm-subagents/src/runs/shared/model-scope.ts +0 -128
  200. package/extensions/dm-subagents/src/runs/shared/nested-events.ts +0 -908
  201. package/extensions/dm-subagents/src/runs/shared/nested-path.ts +0 -52
  202. package/extensions/dm-subagents/src/runs/shared/nested-render.ts +0 -115
  203. package/extensions/dm-subagents/src/runs/shared/parallel-utils.ts +0 -198
  204. package/extensions/dm-subagents/src/runs/shared/run-history.ts +0 -60
  205. package/extensions/dm-subagents/src/runs/shared/single-output.ts +0 -180
  206. package/extensions/dm-subagents/src/runs/shared/structured-output.ts +0 -77
  207. package/extensions/dm-subagents/src/runs/shared/subagent-control.ts +0 -223
  208. package/extensions/dm-subagents/src/runs/shared/subagent-prompt-runtime.ts +0 -342
  209. package/extensions/dm-subagents/src/runs/shared/tool-budget.ts +0 -74
  210. package/extensions/dm-subagents/src/runs/shared/turn-budget.ts +0 -52
  211. package/extensions/dm-subagents/src/runs/shared/workflow-graph.ts +0 -206
  212. package/extensions/dm-subagents/src/runs/shared/worktree.ts +0 -600
  213. package/extensions/dm-subagents/src/shared/artifacts.ts +0 -113
  214. package/extensions/dm-subagents/src/shared/atomic-json.ts +0 -86
  215. package/extensions/dm-subagents/src/shared/child-transcript.ts +0 -212
  216. package/extensions/dm-subagents/src/shared/file-coalescer.ts +0 -40
  217. package/extensions/dm-subagents/src/shared/fork-context.ts +0 -194
  218. package/extensions/dm-subagents/src/shared/formatters.ts +0 -133
  219. package/extensions/dm-subagents/src/shared/jsonl-writer.ts +0 -81
  220. package/extensions/dm-subagents/src/shared/model-info.ts +0 -78
  221. package/extensions/dm-subagents/src/shared/post-exit-stdio-guard.ts +0 -85
  222. package/extensions/dm-subagents/src/shared/session-identity.ts +0 -10
  223. package/extensions/dm-subagents/src/shared/session-tokens.ts +0 -44
  224. package/extensions/dm-subagents/src/shared/settings.ts +0 -450
  225. package/extensions/dm-subagents/src/shared/status-format.ts +0 -49
  226. package/extensions/dm-subagents/src/shared/types.ts +0 -1257
  227. package/extensions/dm-subagents/src/shared/utils.ts +0 -554
  228. package/extensions/dm-subagents/src/slash/prompt-template-bridge.ts +0 -420
  229. package/extensions/dm-subagents/src/slash/prompt-workflows.ts +0 -330
  230. package/extensions/dm-subagents/src/slash/slash-bridge.ts +0 -176
  231. package/extensions/dm-subagents/src/slash/slash-commands.ts +0 -1296
  232. package/extensions/dm-subagents/src/slash/slash-live-state.ts +0 -292
  233. package/extensions/dm-subagents/src/tui/render-helpers.ts +0 -80
  234. package/extensions/dm-subagents/src/tui/render.ts +0 -1748
  235. package/extensions/dm-tasks/src/auto-clear.ts +0 -91
  236. package/extensions/dm-tasks/src/index.ts +0 -1145
  237. package/extensions/dm-tasks/src/process-tracker.ts +0 -140
  238. package/extensions/dm-tasks/src/task-store.ts +0 -305
  239. package/extensions/dm-tasks/src/tasks-config.ts +0 -23
  240. package/extensions/dm-tasks/src/types.ts +0 -40
  241. package/extensions/dm-tasks/src/ui/settings-menu.ts +0 -100
  242. package/extensions/dm-tasks/src/ui/task-widget.ts +0 -265
  243. package/extensions/dm-usage/index.ts +0 -1718
  244. package/extensions/greedysearch-dm/index.ts +0 -177
  245. package/extensions/greedysearch-dm/src/formatters/results.ts +0 -163
  246. package/extensions/greedysearch-dm/src/formatters/sources.ts +0 -116
  247. package/extensions/greedysearch-dm/src/formatters/synthesis.ts +0 -87
  248. package/extensions/greedysearch-dm/src/tools/greedy-search-handler.ts +0 -370
  249. package/extensions/greedysearch-dm/src/tools/shared.ts +0 -187
  250. package/extensions/greedysearch-dm/src/types.ts +0 -110
  251. package/extensions/greedysearch-dm/src/utils/helpers.ts +0 -40
@@ -1,1718 +0,0 @@
1
- /**
2
- * /usage - Usage statistics dashboard
3
- *
4
- * Shows an inline view with usage stats grouped by provider.
5
- * - Tab cycles: Today → This Week → Last Week → All Time
6
- * - Arrow keys navigate providers
7
- * - Enter expands/collapses to show models
8
- */
9
-
10
- import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@duckmind/dm-coding-agent";
11
- import { DynamicBorder } from "@duckmind/dm-coding-agent";
12
- import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi, type AutocompleteItem } from "@duckmind/dm-tui";
13
- import { constants as fsConstants } from "node:fs";
14
- import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
15
- import { dirname, join } from "node:path";
16
- import { homedir } from "node:os";
17
-
18
- // =============================================================================
19
- // Types
20
- // =============================================================================
21
-
22
- interface TokenStats {
23
- total: number;
24
- input: number;
25
- output: number;
26
- cacheRead: number;
27
- cacheWrite: number;
28
- }
29
-
30
- interface BaseStats {
31
- messages: number;
32
- cost: number;
33
- tokens: TokenStats;
34
- }
35
-
36
- interface ModelStats extends BaseStats {
37
- sessions: Set<string>;
38
- }
39
-
40
- interface ProviderStats extends BaseStats {
41
- sessions: Set<string>;
42
- models: Map<string, ModelStats>;
43
- }
44
-
45
- interface TotalStats extends BaseStats {
46
- sessions: number;
47
- }
48
-
49
- interface Insight {
50
- percent: number; // 0-100
51
- headline: string;
52
- advice: string;
53
- }
54
-
55
- interface PeriodInsights {
56
- insights: Insight[];
57
- }
58
-
59
- interface RawMessage {
60
- sessionId: string;
61
- timestamp: number;
62
- cost: number;
63
- input: number;
64
- cacheRead: number;
65
- cacheWrite: number;
66
- }
67
-
68
- interface PeriodRawData {
69
- messages: RawMessage[];
70
- sessionCosts: Map<string, number>;
71
- }
72
-
73
- interface GlobalSessionSpan {
74
- startMs: number;
75
- endMs: number;
76
- }
77
-
78
- interface TimeFilteredStats {
79
- providers: Map<string, ProviderStats>;
80
- totals: TotalStats;
81
- dailySpend: Map<string, number>;
82
- insights: PeriodInsights;
83
- }
84
-
85
- interface UsageData {
86
- today: TimeFilteredStats;
87
- thisWeek: TimeFilteredStats;
88
- lastWeek: TimeFilteredStats;
89
- allTime: TimeFilteredStats;
90
- }
91
-
92
- type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
93
- type ViewMode = "table" | "insights";
94
- type UsageSubcommand = "use" | "show" | "rotation" | "verify" | "path" | "reset" | "help";
95
- type UsageResetTarget = "manual" | "quota" | "all";
96
-
97
- interface UsageManagedAccount {
98
- id: string;
99
- label: string;
100
- provider: string;
101
- source: "session" | "manual";
102
- updatedAt: string;
103
- quotaExhaustedUntil?: number;
104
- }
105
-
106
- interface UsageManagedState {
107
- version: 1;
108
- manualAccountId?: string;
109
- removedAccountIds: string[];
110
- accounts: UsageManagedAccount[];
111
- updatedAt: string;
112
- }
113
-
114
- interface UsageManagedSummary {
115
- data: UsageData;
116
- state: UsageManagedState;
117
- accounts: UsageManagedAccount[];
118
- }
119
-
120
- // =============================================================================
121
- // Column Configuration
122
- // =============================================================================
123
-
124
- interface DataColumn {
125
- label: string;
126
- width: number;
127
- dimmed?: boolean;
128
- getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
129
- }
130
-
131
- interface TableLayoutCandidate {
132
- columns: DataColumn[];
133
- minNameWidth: number;
134
- compact?: boolean;
135
- }
136
-
137
- interface TableLayout {
138
- columns: DataColumn[];
139
- nameWidth: number;
140
- tableWidth: number;
141
- compact: boolean;
142
- }
143
-
144
- const MAX_NAME_COL_WIDTH = 26;
145
- const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
146
- "openai-codex": "duckmind-ultra",
147
- openrouter: "duckmind-standard",
148
- };
149
-
150
- function formatProviderName(provider: string): string {
151
- return PROVIDER_DISPLAY_NAMES[provider] ?? provider;
152
- }
153
-
154
- const SESSIONS_COLUMN: DataColumn = {
155
- label: "Sessions",
156
- width: 9,
157
- getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
158
- };
159
-
160
- const MSGS_COLUMN: DataColumn = {
161
- label: "Msgs",
162
- width: 9,
163
- getValue: (s) => formatNumber(s.messages),
164
- };
165
-
166
- const COST_COLUMN: DataColumn = {
167
- label: "Cost",
168
- width: 9,
169
- getValue: (s) => formatCost(s.cost),
170
- };
171
-
172
- const TOKENS_COLUMN: DataColumn = {
173
- label: "Tokens",
174
- width: 9,
175
- getValue: (s) => formatTokens(s.tokens.total),
176
- };
177
-
178
- const INPUT_COLUMN: DataColumn = {
179
- label: "↑In",
180
- width: 8,
181
- dimmed: true,
182
- // Include cacheWrite so this reflects fresh input tokens sent this turn,
183
- // even for providers like Anthropic that split cached prompt creation out
184
- // from the regular input token count.
185
- getValue: (s) => formatTokens(s.tokens.input + s.tokens.cacheWrite),
186
- };
187
-
188
- const OUTPUT_COLUMN: DataColumn = {
189
- label: "↓Out",
190
- width: 8,
191
- dimmed: true,
192
- getValue: (s) => formatTokens(s.tokens.output),
193
- };
194
-
195
- const CACHE_COLUMN: DataColumn = {
196
- label: "Cache",
197
- width: 8,
198
- dimmed: true,
199
- getValue: (s) => formatTokens(s.tokens.cacheRead + s.tokens.cacheWrite),
200
- };
201
-
202
- const FULL_DATA_COLUMNS: DataColumn[] = [
203
- SESSIONS_COLUMN,
204
- MSGS_COLUMN,
205
- COST_COLUMN,
206
- TOKENS_COLUMN,
207
- INPUT_COLUMN,
208
- OUTPUT_COLUMN,
209
- CACHE_COLUMN,
210
- ];
211
-
212
- const TABLE_LAYOUTS: TableLayoutCandidate[] = [
213
- { columns: FULL_DATA_COLUMNS, minNameWidth: MAX_NAME_COL_WIDTH },
214
- { columns: [SESSIONS_COLUMN, MSGS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 14, compact: true },
215
- { columns: [SESSIONS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 12, compact: true },
216
- { columns: [COST_COLUMN, TOKENS_COLUMN], minNameWidth: 10, compact: true },
217
- { columns: [COST_COLUMN], minNameWidth: 8, compact: true },
218
- ];
219
-
220
- // =============================================================================
221
- // Data Collection
222
- // =============================================================================
223
-
224
- function getAgentDir(): string {
225
- // Respect DM_CODING_AGENT_DIR, keeping PI_CODING_AGENT_DIR only as a compatibility fallback
226
- return process.env.DM_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || join(homedir(), ".dm", "agent");
227
- }
228
-
229
- function getSessionsDir(): string {
230
- return join(getAgentDir(), "sessions");
231
- }
232
-
233
- function getUsageStateDir(): string {
234
- return join(getAgentDir(), "usage");
235
- }
236
-
237
- function getUsageStatePath(): string {
238
- return join(getUsageStateDir(), "state.json");
239
- }
240
-
241
- function getAgentSettingsPath(): string {
242
- return join(getAgentDir(), "settings.json");
243
- }
244
-
245
- async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
246
- try {
247
- const entries = await readdir(dir, { withFileTypes: true });
248
- for (const entry of entries) {
249
- if (signal?.aborted) return;
250
- const entryPath = join(dir, entry.name);
251
- if (entry.isDirectory()) {
252
- await collectSessionFilesRecursively(entryPath, files, signal);
253
- } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
254
- files.push(entryPath);
255
- }
256
- }
257
- } catch {
258
- // Skip directories we can't read
259
- }
260
- }
261
-
262
- async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
263
- const files: string[] = [];
264
- await collectSessionFilesRecursively(getSessionsDir(), files, signal);
265
- files.sort();
266
- return files;
267
- }
268
-
269
- interface SessionMessage {
270
- provider: string;
271
- model: string;
272
- cost: number;
273
- input: number;
274
- output: number;
275
- cacheRead: number;
276
- cacheWrite: number;
277
- timestamp: number;
278
- }
279
-
280
- interface ParsedSessionFile {
281
- sessionId: string;
282
- messages: SessionMessage[];
283
- }
284
-
285
- async function parseSessionFile(
286
- filePath: string,
287
- seenHashes: Set<string>,
288
- signal?: AbortSignal
289
- ): Promise<ParsedSessionFile | null> {
290
- try {
291
- const content = await readFile(filePath, "utf8");
292
- if (signal?.aborted) return null;
293
- const lines = content.trim().split("\n");
294
- const messages: SessionMessage[] = [];
295
- let sessionId = "";
296
-
297
- for (let i = 0; i < lines.length; i++) {
298
- if (signal?.aborted) return null;
299
- if (i % 500 === 0) {
300
- await new Promise<void>((resolve) => setImmediate(resolve));
301
- }
302
- const line = lines[i]!;
303
- if (!line.trim()) continue;
304
- try {
305
- const entry = JSON.parse(line);
306
-
307
- if (entry.type === "session") {
308
- sessionId = entry.id;
309
- } else if (entry.type === "message" && entry.message?.role === "assistant") {
310
- const msg = entry.message;
311
- if (msg.usage && msg.provider && msg.model) {
312
- const input = msg.usage.input || 0;
313
- const output = msg.usage.output || 0;
314
- const cacheRead = msg.usage.cacheRead || 0;
315
- const cacheWrite = msg.usage.cacheWrite || 0;
316
- const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
317
- const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
318
-
319
- // Deduplicate copied history across branched session files.
320
- // Keep the existing ccusage-style hash so current totals remain comparable.
321
- const totalTokens = input + output + cacheRead + cacheWrite;
322
- const hash = `${timestamp}:${totalTokens}`;
323
- if (seenHashes.has(hash)) continue;
324
- seenHashes.add(hash);
325
-
326
- messages.push({
327
- provider: msg.provider,
328
- model: msg.model,
329
- cost: msg.usage.cost?.total || 0,
330
- input,
331
- output,
332
- cacheRead,
333
- cacheWrite,
334
- timestamp,
335
- });
336
- }
337
- }
338
- } catch {
339
- // Skip malformed lines
340
- }
341
- }
342
-
343
- return sessionId ? { sessionId, messages } : null;
344
- } catch {
345
- return null;
346
- }
347
- }
348
-
349
- // Helper to accumulate stats into a target
350
- function accumulateStats(
351
- target: BaseStats,
352
- cost: number,
353
- tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
354
- ): void {
355
- target.messages++;
356
- target.cost += cost;
357
- target.tokens.total += tokens.total;
358
- target.tokens.input += tokens.input;
359
- target.tokens.output += tokens.output;
360
- target.tokens.cacheRead += tokens.cacheRead;
361
- target.tokens.cacheWrite += tokens.cacheWrite;
362
- }
363
-
364
- function emptyTokens(): TokenStats {
365
- return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
366
- }
367
-
368
- function emptyModelStats(): ModelStats {
369
- return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens() };
370
- }
371
-
372
- function emptyProviderStats(): ProviderStats {
373
- return { sessions: new Set(), messages: 0, cost: 0, tokens: emptyTokens(), models: new Map() };
374
- }
375
-
376
- function emptyTimeFilteredStats(): TimeFilteredStats {
377
- return {
378
- providers: new Map(),
379
- totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
380
- dailySpend: new Map(),
381
- insights: { insights: [] },
382
- };
383
- }
384
-
385
- function emptyPeriodRawData(): PeriodRawData {
386
- return { messages: [], sessionCosts: new Map() };
387
- }
388
-
389
- function emptyUsageData(): UsageData {
390
- return {
391
- today: emptyTimeFilteredStats(),
392
- thisWeek: emptyTimeFilteredStats(),
393
- lastWeek: emptyTimeFilteredStats(),
394
- allTime: emptyTimeFilteredStats(),
395
- };
396
- }
397
-
398
- function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
399
- const periods: TabName[] = ["allTime"];
400
- if (timestamp >= todayMs) periods.push("today");
401
- if (timestamp >= weekStartMs) {
402
- periods.push("thisWeek");
403
- } else if (timestamp >= lastWeekStartMs) {
404
- periods.push("lastWeek");
405
- }
406
- return periods;
407
- }
408
-
409
- function addMessagesToUsageData(
410
- data: UsageData,
411
- sessionId: string,
412
- messages: SessionMessage[],
413
- todayMs: number,
414
- weekStartMs: number,
415
- lastWeekStartMs: number,
416
- rawByPeriod: Record<TabName, PeriodRawData>,
417
- globalSessionSpans: Map<string, GlobalSessionSpan>
418
- ): void {
419
- const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
420
-
421
- for (const msg of messages) {
422
- // Track real per-session lifetime across every message we see, regardless of
423
- // which period the message falls into. Used later for the "8h+ session" insight.
424
- if (msg.timestamp > 0) {
425
- const span = globalSessionSpans.get(sessionId);
426
- if (!span) {
427
- globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
428
- } else {
429
- if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
430
- if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
431
- }
432
- }
433
-
434
- const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
435
- const tokens = {
436
- // Count fresh tokens processed this turn.
437
- // Include cacheWrite because those prompt tokens were newly written and billed.
438
- // Exclude cacheRead because repeated cache hits would otherwise dominate totals.
439
- total: msg.input + msg.output + msg.cacheWrite,
440
- input: msg.input,
441
- output: msg.output,
442
- cacheRead: msg.cacheRead,
443
- cacheWrite: msg.cacheWrite,
444
- };
445
-
446
- for (const period of periods) {
447
- const stats = data[period];
448
-
449
- let providerStats = stats.providers.get(msg.provider);
450
- if (!providerStats) {
451
- providerStats = emptyProviderStats();
452
- stats.providers.set(msg.provider, providerStats);
453
- }
454
-
455
- let modelStats = providerStats.models.get(msg.model);
456
- if (!modelStats) {
457
- modelStats = emptyModelStats();
458
- providerStats.models.set(msg.model, modelStats);
459
- }
460
-
461
- modelStats.sessions.add(sessionId);
462
- accumulateStats(modelStats, msg.cost, tokens);
463
-
464
- providerStats.sessions.add(sessionId);
465
- accumulateStats(providerStats, msg.cost, tokens);
466
-
467
- accumulateStats(stats.totals, msg.cost, tokens);
468
- if (msg.cost > 0 && msg.timestamp > 0) {
469
- const dayKey = new Date(msg.timestamp).toISOString().slice(0, 10);
470
- stats.dailySpend.set(dayKey, (stats.dailySpend.get(dayKey) ?? 0) + msg.cost);
471
- }
472
- sessionContributed[period] = true;
473
-
474
- const raw = rawByPeriod[period];
475
- raw.messages.push({
476
- sessionId,
477
- timestamp: msg.timestamp,
478
- cost: msg.cost,
479
- input: msg.input,
480
- cacheRead: msg.cacheRead,
481
- cacheWrite: msg.cacheWrite,
482
- });
483
- raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
484
- }
485
- }
486
-
487
- if (sessionContributed.today) data.today.totals.sessions++;
488
- if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
489
- if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
490
- if (sessionContributed.allTime) data.allTime.totals.sessions++;
491
- }
492
-
493
- async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
494
- const startOfToday = new Date();
495
- startOfToday.setHours(0, 0, 0, 0);
496
- const todayMs = startOfToday.getTime();
497
-
498
- // Start of current week (Monday 00:00)
499
- const startOfWeek = new Date();
500
- const dayOfWeek = startOfWeek.getDay(); // 0 = Sunday, 1 = Monday, ...
501
- const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
502
- startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
503
- startOfWeek.setHours(0, 0, 0, 0);
504
- const weekStartMs = startOfWeek.getTime();
505
-
506
- // Start of last week (previous Monday 00:00)
507
- const startOfLastWeek = new Date(startOfWeek);
508
- startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
509
- const lastWeekStartMs = startOfLastWeek.getTime();
510
-
511
- const data = emptyUsageData();
512
- const rawByPeriod: Record<TabName, PeriodRawData> = {
513
- today: emptyPeriodRawData(),
514
- thisWeek: emptyPeriodRawData(),
515
- lastWeek: emptyPeriodRawData(),
516
- allTime: emptyPeriodRawData(),
517
- };
518
- const globalSessionSpans = new Map<string, GlobalSessionSpan>();
519
-
520
- const sessionFiles = await getAllSessionFiles(signal);
521
- if (signal?.aborted) return null;
522
- const seenHashes = new Set<string>();
523
-
524
- for (const filePath of sessionFiles) {
525
- if (signal?.aborted) return null;
526
- const parsed = await parseSessionFile(filePath, seenHashes, signal);
527
- if (signal?.aborted) return null;
528
- if (!parsed) continue;
529
-
530
- addMessagesToUsageData(
531
- data,
532
- parsed.sessionId,
533
- parsed.messages,
534
- todayMs,
535
- weekStartMs,
536
- lastWeekStartMs,
537
- rawByPeriod,
538
- globalSessionSpans
539
- );
540
-
541
- await new Promise<void>((resolve) => setImmediate(resolve));
542
- }
543
-
544
- // Classify sessions that are globally long-running once, then reuse across periods.
545
- const longSessionIds = new Set<string>();
546
- for (const [id, span] of globalSessionSpans) {
547
- if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
548
- }
549
-
550
- for (const period of TAB_ORDER) {
551
- data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
552
- }
553
-
554
- return data;
555
- }
556
-
557
- // =============================================================================
558
- // Managed Account Command Helpers
559
- // =============================================================================
560
-
561
- const USAGE_SUBCOMMANDS = ["use", "show", "rotation", "verify", "path", "reset", "help"] as const;
562
- const USAGE_RESET_TARGETS = ["manual", "quota", "all"] as const;
563
- const USAGE_HELP_TEXT = [
564
- "Usage: /usage [use|show|rotation|verify|path|reset|help]",
565
- "use: select, activate, or remove managed account",
566
- "show: managed account and usage summary",
567
- "rotation: current rotation behavior",
568
- "verify: runtime health checks",
569
- "path: storage and settings locations",
570
- "reset: clear manual or quota state",
571
- ].join("\n");
572
-
573
- function parseUsageCommandArgs(args: string): { subcommand?: string; rest: string } {
574
- const trimmed = args.trim();
575
- if (!trimmed) return { rest: "" };
576
- const firstSpaceIndex = trimmed.indexOf(" ");
577
- if (firstSpaceIndex < 0) return { subcommand: trimmed.toLowerCase(), rest: "" };
578
- return {
579
- subcommand: trimmed.slice(0, firstSpaceIndex).toLowerCase(),
580
- rest: trimmed.slice(firstSpaceIndex + 1).trim(),
581
- };
582
- }
583
-
584
- function isUsageSubcommand(value: string): value is UsageSubcommand {
585
- return USAGE_SUBCOMMANDS.some((subcommand) => subcommand === value);
586
- }
587
-
588
- function parseUsageResetTarget(value: string): UsageResetTarget | undefined {
589
- if (value === "manual" || value === "quota" || value === "all") return value;
590
- return undefined;
591
- }
592
-
593
- function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
594
- return values.map((value) => ({ value, label: value }));
595
- }
596
-
597
- function getUsageCommandCompletions(argumentPrefix: string): AutocompleteItem[] | null {
598
- const trimmedStart = argumentPrefix.trimStart();
599
- if (!trimmedStart) return toAutocompleteItems(USAGE_SUBCOMMANDS);
600
-
601
- const firstSpaceIndex = trimmedStart.indexOf(" ");
602
- if (firstSpaceIndex < 0) {
603
- const matches = USAGE_SUBCOMMANDS.filter((value) => value.startsWith(trimmedStart.toLowerCase()));
604
- return matches.length > 0 ? toAutocompleteItems(matches) : null;
605
- }
606
-
607
- const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
608
- const rest = trimmedStart.slice(firstSpaceIndex + 1).toLowerCase();
609
- if (subcommand === "reset") {
610
- const matches = USAGE_RESET_TARGETS.filter((value) => value.startsWith(rest));
611
- return matches.length > 0 ? matches.map((value) => ({ value: `reset ${value}`, label: value })) : null;
612
- }
613
- if (subcommand === "use") {
614
- const matches = ["clear", "remove"].filter((value) => value.startsWith(rest));
615
- return matches.length > 0 ? matches.map((value) => ({ value: `use ${value}`, label: value })) : null;
616
- }
617
- return null;
618
- }
619
-
620
- function emptyUsageManagedState(): UsageManagedState {
621
- return { version: 1, removedAccountIds: [], accounts: [], updatedAt: new Date(0).toISOString() };
622
- }
623
-
624
- function asString(value: unknown): string | undefined {
625
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
626
- }
627
-
628
- function normalizeUsageManagedAccount(value: unknown): UsageManagedAccount | undefined {
629
- if (!value || typeof value !== "object") return undefined;
630
- const record = value as Record<string, unknown>;
631
- const id = asString(record.id);
632
- const provider = asString(record.provider) ?? id;
633
- if (!id || !provider) return undefined;
634
- const label = asString(record.label) ?? formatProviderName(provider);
635
- const source = record.source === "manual" ? "manual" : "session";
636
- const updatedAt = asString(record.updatedAt) ?? new Date(0).toISOString();
637
- const quota =
638
- typeof record.quotaExhaustedUntil === "number" && Number.isFinite(record.quotaExhaustedUntil)
639
- ? record.quotaExhaustedUntil
640
- : undefined;
641
- return { id, label, provider, source, updatedAt, quotaExhaustedUntil: quota };
642
- }
643
-
644
- function normalizeUsageManagedState(value: unknown): UsageManagedState {
645
- const empty = emptyUsageManagedState();
646
- if (!value || typeof value !== "object") return empty;
647
- const record = value as Record<string, unknown>;
648
- const accounts = Array.isArray(record.accounts)
649
- ? record.accounts.map(normalizeUsageManagedAccount).filter((account): account is UsageManagedAccount => Boolean(account))
650
- : [];
651
- const removedAccountIds = Array.isArray(record.removedAccountIds)
652
- ? record.removedAccountIds.map(asString).filter((id): id is string => Boolean(id))
653
- : [];
654
- return {
655
- version: 1,
656
- manualAccountId: asString(record.manualAccountId),
657
- removedAccountIds,
658
- accounts,
659
- updatedAt: asString(record.updatedAt) ?? empty.updatedAt,
660
- };
661
- }
662
-
663
- function getErrorCode(error: unknown): string | undefined {
664
- return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined;
665
- }
666
-
667
- async function readUsageManagedState(): Promise<{ state: UsageManagedState; error?: string }> {
668
- try {
669
- const raw = await readFile(getUsageStatePath(), "utf8");
670
- return { state: normalizeUsageManagedState(JSON.parse(raw)) };
671
- } catch (error) {
672
- if (getErrorCode(error) === "ENOENT") return { state: emptyUsageManagedState() };
673
- return { state: emptyUsageManagedState(), error: error instanceof Error ? error.message : String(error) };
674
- }
675
- }
676
-
677
- async function saveUsageManagedState(state: UsageManagedState): Promise<void> {
678
- state.version = 1;
679
- state.updatedAt = new Date().toISOString();
680
- state.removedAccountIds = [...new Set(state.removedAccountIds)].sort();
681
- state.accounts = state.accounts
682
- .filter((account) => !state.removedAccountIds.includes(account.id))
683
- .sort((a, b) => a.label.localeCompare(b.label));
684
- await mkdir(dirname(getUsageStatePath()), { recursive: true });
685
- await writeFile(getUsageStatePath(), `${JSON.stringify(state, null, 2)}\n`, "utf8");
686
- }
687
-
688
- function isQuotaActive(account: UsageManagedAccount): boolean {
689
- return typeof account.quotaExhaustedUntil === "number" && account.quotaExhaustedUntil > Date.now();
690
- }
691
-
692
- function getAccountStats(data: UsageData, account: UsageManagedAccount): ProviderStats | undefined {
693
- return data.allTime.providers.get(account.provider);
694
- }
695
-
696
- function getManagedAccounts(data: UsageData, state: UsageManagedState): UsageManagedAccount[] {
697
- const now = new Date().toISOString();
698
- const accounts = new Map<string, UsageManagedAccount>();
699
- for (const account of state.accounts) {
700
- if (!state.removedAccountIds.includes(account.id)) accounts.set(account.id, account);
701
- }
702
- for (const provider of data.allTime.providers.keys()) {
703
- if (state.removedAccountIds.includes(provider) || accounts.has(provider)) continue;
704
- accounts.set(provider, {
705
- id: provider,
706
- label: formatProviderName(provider),
707
- provider,
708
- source: "session",
709
- updatedAt: now,
710
- });
711
- }
712
-
713
- return Array.from(accounts.values()).sort((a, b) => {
714
- const manualId = state.manualAccountId;
715
- if (a.id === manualId && b.id !== manualId) return -1;
716
- if (b.id === manualId && a.id !== manualId) return 1;
717
- if (isQuotaActive(a) !== isQuotaActive(b)) return isQuotaActive(a) ? 1 : -1;
718
- const costDelta = (getAccountStats(data, b)?.cost ?? 0) - (getAccountStats(data, a)?.cost ?? 0);
719
- if (costDelta !== 0) return costDelta;
720
- return a.label.localeCompare(b.label);
721
- });
722
- }
723
-
724
- function getActiveManagedAccount(accounts: UsageManagedAccount[], state: UsageManagedState): UsageManagedAccount | undefined {
725
- const manual = accounts.find((account) => account.id === state.manualAccountId && !isQuotaActive(account));
726
- return manual ?? accounts.find((account) => !isQuotaActive(account));
727
- }
728
-
729
- function findManagedAccount(accounts: UsageManagedAccount[], query: string): UsageManagedAccount | undefined {
730
- const normalized = query.trim().toLowerCase();
731
- return accounts.find(
732
- (account) =>
733
- account.id.toLowerCase() === normalized ||
734
- account.provider.toLowerCase() === normalized ||
735
- account.label.toLowerCase() === normalized
736
- );
737
- }
738
-
739
- function formatManagedAccountLine(account: UsageManagedAccount, data: UsageData, state: UsageManagedState): string {
740
- const stats = getAccountStats(data, account);
741
- const tags = [
742
- state.manualAccountId === account.id ? "manual" : undefined,
743
- isQuotaActive(account) ? "quota" : undefined,
744
- account.source === "manual" ? "manual-entry" : undefined,
745
- ]
746
- .filter(Boolean)
747
- .join(", ");
748
- const suffix = tags ? ` (${tags})` : "";
749
- const cost = stats ? formatCost(stats.cost) : formatCost(0);
750
- const messages = stats ? formatNumber(stats.messages) : "0";
751
- const sessions = stats ? formatNumber(stats.sessions.size) : "0";
752
- return `${account.label}${suffix} · id=${account.id} · spend=${cost} · messages=${messages} · sessions=${sessions}`;
753
- }
754
-
755
- async function loadUsageManagedSummary(ctx: ExtensionCommandContext): Promise<UsageManagedSummary | null> {
756
- const data = await collectUsageData(ctx.signal);
757
- if (!data) {
758
- ctx.ui.notify("Usage data unavailable or cancelled.", "warning");
759
- return null;
760
- }
761
- const { state, error } = await readUsageManagedState();
762
- if (error) {
763
- ctx.ui.notify(`dm-usage state unreadable; using empty state: ${error}`, "warning");
764
- }
765
- return { data, state, accounts: getManagedAccounts(data, state) };
766
- }
767
-
768
- function managedAccountNoDataMessage(): string {
769
- return "No managed accounts found. Run /usage after at least one provider response, or use /usage use <provider-id> to create a local manual entry.";
770
- }
771
-
772
- async function activateUsageAccount(ctx: ExtensionCommandContext, summary: UsageManagedSummary, account: UsageManagedAccount): Promise<void> {
773
- summary.state.manualAccountId = account.id;
774
- summary.state.removedAccountIds = summary.state.removedAccountIds.filter((id) => id !== account.id);
775
- summary.state.accounts = summary.accounts.map((item) => (item.id === account.id ? { ...item, updatedAt: new Date().toISOString() } : item));
776
- await saveUsageManagedState(summary.state);
777
- ctx.ui.notify(`dm-usage: now using ${account.label} (${account.id})`, "info");
778
- }
779
-
780
- async function clearUsageManualAccount(ctx: ExtensionCommandContext, state: UsageManagedState): Promise<void> {
781
- const hadManual = Boolean(state.manualAccountId);
782
- state.manualAccountId = undefined;
783
- await saveUsageManagedState(state);
784
- ctx.ui.notify(`dm-usage: manual account ${hadManual ? "cleared" : "was not set"}`, "info");
785
- }
786
-
787
- async function removeUsageAccountState(ctx: ExtensionCommandContext, summary: UsageManagedSummary, account: UsageManagedAccount): Promise<void> {
788
- summary.state.removedAccountIds = [...summary.state.removedAccountIds, account.id];
789
- if (summary.state.manualAccountId === account.id) summary.state.manualAccountId = undefined;
790
- summary.state.accounts = summary.accounts.filter((item) => item.id !== account.id);
791
- await saveUsageManagedState(summary.state);
792
- ctx.ui.notify(`dm-usage: removed local managed-account state for ${account.label}; session data was not changed.`, "info");
793
- }
794
-
795
- async function runUsageUseSubcommand(ctx: ExtensionCommandContext, rest: string): Promise<void> {
796
- const summary = await loadUsageManagedSummary(ctx);
797
- if (!summary) return;
798
-
799
- if (rest === "clear") {
800
- await clearUsageManualAccount(ctx, summary.state);
801
- return;
802
- }
803
-
804
- if (rest.startsWith("remove ")) {
805
- const account = findManagedAccount(summary.accounts, rest.slice("remove ".length));
806
- if (!account) {
807
- ctx.ui.notify(`Unknown managed account: ${rest.slice("remove ".length)}`, "warning");
808
- return;
809
- }
810
- await removeUsageAccountState(ctx, summary, account);
811
- return;
812
- }
813
-
814
- if (rest) {
815
- const account =
816
- findManagedAccount(summary.accounts, rest) ??
817
- ({ id: rest, label: formatProviderName(rest), provider: rest, source: "manual", updatedAt: new Date().toISOString() } satisfies UsageManagedAccount);
818
- if (!summary.accounts.some((item) => item.id === account.id)) summary.accounts.push(account);
819
- await activateUsageAccount(ctx, summary, account);
820
- return;
821
- }
822
-
823
- if (!ctx.hasUI) {
824
- ctx.ui.notify(summary.accounts.length ? summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state)).join("\n") : managedAccountNoDataMessage(), summary.accounts.length ? "info" : "warning");
825
- return;
826
- }
827
-
828
- const options = [
829
- ...summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state)),
830
- "add manual account",
831
- "clear manual account",
832
- ];
833
- const selected = await ctx.ui.select("DuckMind Usage Accounts", options);
834
- if (!selected) return;
835
- if (selected === "add manual account") {
836
- const id = (await ctx.ui.input("Managed account id (provider key, no secret)"))?.trim();
837
- if (!id) return;
838
- await runUsageUseSubcommand(ctx, id);
839
- return;
840
- }
841
- if (selected === "clear manual account") {
842
- await clearUsageManualAccount(ctx, summary.state);
843
- return;
844
- }
845
-
846
- const account = summary.accounts[options.indexOf(selected)];
847
- if (!account) return;
848
- const action = await ctx.ui.select("Account Action", ["activate", "remove local state", "cancel"]);
849
- if (action === "activate") await activateUsageAccount(ctx, summary, account);
850
- if (action === "remove local state") await removeUsageAccountState(ctx, summary, account);
851
- }
852
-
853
- async function runUsageShowSubcommand(ctx: ExtensionCommandContext): Promise<void> {
854
- const summary = await loadUsageManagedSummary(ctx);
855
- if (!summary) return;
856
- const active = getActiveManagedAccount(summary.accounts, summary.state);
857
- const hiddenCount = summary.state.removedAccountIds.length;
858
- const lines = [
859
- "DuckMind Usage managed account summary",
860
- `active: ${active ? `${active.label} (${active.id})` : "none"}`,
861
- `manual: ${summary.state.manualAccountId ?? "none"}`,
862
- `accounts: ${summary.accounts.length}${hiddenCount ? ` · hidden=${hiddenCount}` : ""}`,
863
- `all-time: spend=${formatCost(summary.data.allTime.totals.cost)} · messages=${formatNumber(summary.data.allTime.totals.messages)} · sessions=${formatNumber(summary.data.allTime.totals.sessions)} · tokens=${formatTokens(summary.data.allTime.totals.tokens.total)}`,
864
- "",
865
- ...(summary.accounts.length
866
- ? summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state))
867
- : [managedAccountNoDataMessage()]),
868
- ];
869
- if (ctx.hasUI) {
870
- await ctx.ui.select("DuckMind Usage Summary", lines);
871
- } else {
872
- ctx.ui.notify(lines.join("\n"), "info");
873
- }
874
- }
875
-
876
- async function runUsageRotationSubcommand(ctx: ExtensionCommandContext): Promise<void> {
877
- const lines = [
878
- "Current policy: manual account first, then accounts without quota cooldown, then highest all-time local spend, then alphabetical fallback.",
879
- "dm-usage is observational: this state changes /usage account summaries only and does not route provider requests or store API secrets.",
880
- "Use /usage reset manual to clear manual selection, or /usage reset quota to clear quota cooldown markers.",
881
- ];
882
- if (ctx.hasUI) {
883
- await ctx.ui.select("DuckMind Usage Rotation", lines);
884
- } else {
885
- ctx.ui.notify(lines.join(" "), "info");
886
- }
887
- }
888
-
889
- async function isDirectoryReadable(path: string): Promise<boolean> {
890
- try {
891
- await access(path, fsConstants.R_OK);
892
- return true;
893
- } catch {
894
- return false;
895
- }
896
- }
897
-
898
- async function isWritableDirectoryFor(filePath: string): Promise<boolean> {
899
- try {
900
- const directory = dirname(filePath);
901
- await mkdir(directory, { recursive: true });
902
- await access(directory, fsConstants.R_OK | fsConstants.W_OK);
903
- return true;
904
- } catch {
905
- return false;
906
- }
907
- }
908
-
909
- async function runUsageVerifySubcommand(ctx: ExtensionCommandContext): Promise<void> {
910
- const sessionsReadable = await isDirectoryReadable(getSessionsDir());
911
- const stateWritable = await isWritableDirectoryFor(getUsageStatePath());
912
- const settingsWritable = await isWritableDirectoryFor(getAgentSettingsPath());
913
- const stateRead = await readUsageManagedState();
914
- const summary = await loadUsageManagedSummary(ctx);
915
- const accounts = summary?.accounts.length ?? 0;
916
- const active = summary ? getActiveManagedAccount(summary.accounts, summary.state)?.id ?? "none" : "none";
917
- const ok = sessionsReadable && stateWritable && settingsWritable && !stateRead.error;
918
- const lines = [
919
- `verify: ${ok ? "PASS" : "WARN"}`,
920
- `sessions readable: ${sessionsReadable ? "yes" : "no"}`,
921
- `state directory writable: ${stateWritable ? "yes" : "no"}`,
922
- `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
923
- `state json: ${stateRead.error ? `invalid (${stateRead.error})` : "ok"}`,
924
- `managed accounts: ${accounts}`,
925
- `active account: ${active}`,
926
- ];
927
- if (ctx.hasUI) {
928
- await ctx.ui.select(`DuckMind Usage Verify (${ok ? "PASS" : "WARN"})`, lines);
929
- } else {
930
- ctx.ui.notify(lines.join("\n"), ok ? "info" : "warning");
931
- }
932
- }
933
-
934
- async function runUsagePathSubcommand(ctx: ExtensionCommandContext): Promise<void> {
935
- const lines = [
936
- `Session storage: ${getSessionsDir()}`,
937
- `Managed account state: ${getUsageStatePath()}`,
938
- `Agent settings: ${getAgentSettingsPath()}`,
939
- ];
940
- if (ctx.hasUI) {
941
- await ctx.ui.select("DuckMind Usage Paths", lines);
942
- } else {
943
- ctx.ui.notify(lines.join("\n"), "info");
944
- }
945
- }
946
-
947
- async function chooseUsageResetTarget(ctx: ExtensionCommandContext, argument: string): Promise<UsageResetTarget | undefined> {
948
- const explicitTarget = parseUsageResetTarget(argument.toLowerCase());
949
- if (explicitTarget) return explicitTarget;
950
- if (argument) {
951
- ctx.ui.notify("Unknown reset target. Use: /usage reset [manual|quota|all]", "warning");
952
- return undefined;
953
- }
954
- if (!ctx.hasUI) return "all";
955
- const selected = await ctx.ui.select("Reset DuckMind Usage State", [
956
- "manual - clear manual account override",
957
- "quota - clear quota cooldown markers",
958
- "all - clear manual override, quota cooldown markers, and hidden account state",
959
- ]);
960
- if (!selected) return undefined;
961
- if (selected.startsWith("manual")) return "manual";
962
- if (selected.startsWith("quota")) return "quota";
963
- return "all";
964
- }
965
-
966
- async function runUsageResetSubcommand(ctx: ExtensionCommandContext, rest: string): Promise<void> {
967
- const target = await chooseUsageResetTarget(ctx, rest);
968
- if (!target) return;
969
- const { state } = await readUsageManagedState();
970
- const hadManual = Boolean(state.manualAccountId);
971
- if (target === "manual" || target === "all") state.manualAccountId = undefined;
972
- let quotaCleared = 0;
973
- if (target === "quota" || target === "all") {
974
- state.accounts = state.accounts.map((account) => {
975
- if (account.quotaExhaustedUntil === undefined) return account;
976
- quotaCleared++;
977
- const { quotaExhaustedUntil: _quotaExhaustedUntil, ...restAccount } = account;
978
- return restAccount;
979
- });
980
- }
981
- const hiddenCleared = target === "all" ? state.removedAccountIds.length : 0;
982
- if (target === "all") state.removedAccountIds = [];
983
- await saveUsageManagedState(state);
984
- ctx.ui.notify(
985
- `reset: target=${target} manualCleared=${hadManual && !state.manualAccountId ? "yes" : "no"} quotaCleared=${quotaCleared} hiddenCleared=${hiddenCleared}`,
986
- "info"
987
- );
988
- }
989
-
990
- async function runUsageSubcommand(subcommand: UsageSubcommand, rest: string, ctx: ExtensionCommandContext): Promise<void> {
991
- if (subcommand === "use") return runUsageUseSubcommand(ctx, rest);
992
- if (subcommand === "show") return runUsageShowSubcommand(ctx);
993
- if (subcommand === "rotation") return runUsageRotationSubcommand(ctx);
994
- if (subcommand === "verify") return runUsageVerifySubcommand(ctx);
995
- if (subcommand === "path") return runUsagePathSubcommand(ctx);
996
- if (subcommand === "reset") return runUsageResetSubcommand(ctx, rest);
997
- ctx.ui.notify(USAGE_HELP_TEXT, "info");
998
- }
999
-
1000
- // =============================================================================
1001
- // Insights
1002
- // =============================================================================
1003
-
1004
- const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
1005
- const PARALLEL_SESSION_THRESHOLD = 4;
1006
- const LARGE_CONTEXT_THRESHOLD = 150_000;
1007
- const LARGE_CACHE_MISS_THRESHOLD = 100_000;
1008
- const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
1009
- const TOP_SESSION_COUNT = 5;
1010
- const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
1011
- const MIN_PERCENT_TO_SHOW = 1;
1012
-
1013
- /**
1014
- * Insights are weighted by recorded API cost. Periods with zero total cost produce
1015
- * an empty `insights` list — the UI renders a distinct empty-state for that case.
1016
- * Long-running-session classification is passed in from a global pass so that a
1017
- * session's real lifetime is used rather than the slice visible inside this period.
1018
- */
1019
- function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
1020
- if (raw.messages.length === 0) {
1021
- return { insights: [] };
1022
- }
1023
-
1024
- const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
1025
- if (total <= 0) {
1026
- return { insights: [] };
1027
- }
1028
-
1029
- const candidates: Insight[] = [];
1030
-
1031
- // 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
1032
- const parallelWeight = computeParallelCostWeight(raw.messages);
1033
- if (parallelWeight !== null) {
1034
- candidates.push({
1035
- percent: (parallelWeight / total) * 100,
1036
- headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
1037
- advice:
1038
- "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
1039
- });
1040
- }
1041
-
1042
- // 2. Large context — input + cacheRead + cacheWrite > threshold.
1043
- const largeContextWeight = raw.messages
1044
- .filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
1045
- .reduce((sum, m) => sum + m.cost, 0);
1046
- candidates.push({
1047
- percent: (largeContextWeight / total) * 100,
1048
- headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
1049
- advice:
1050
- "Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
1051
- });
1052
-
1053
- // 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
1054
- const uncachedWeight = raw.messages
1055
- .filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
1056
- .reduce((sum, m) => sum + m.cost, 0);
1057
- candidates.push({
1058
- percent: (uncachedWeight / total) * 100,
1059
- headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
1060
- advice:
1061
- "Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
1062
- });
1063
-
1064
- // 4. Long-running sessions — classification comes from the global pass so we use
1065
- // true session lifetime, not just the span visible inside this period slice.
1066
- const longWeight = raw.messages
1067
- .filter((m) => longSessionIds.has(m.sessionId))
1068
- .reduce((sum, m) => sum + m.cost, 0);
1069
- if (longWeight > 0) {
1070
- candidates.push({
1071
- percent: (longWeight / total) * 100,
1072
- headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
1073
- advice:
1074
- "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
1075
- });
1076
- }
1077
-
1078
- // 5. Top-N session concentration.
1079
- if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
1080
- const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
1081
- const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
1082
- const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
1083
- candidates.push({
1084
- percent: (topWeight / total) * 100,
1085
- headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
1086
- advice:
1087
- "A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
1088
- });
1089
- }
1090
-
1091
- const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
1092
- return { insights };
1093
- }
1094
-
1095
- /**
1096
- * Two-pointer sweep of messages sorted by timestamp. For each message, count the
1097
- * number of distinct session IDs whose messages fall within an exact ± window.
1098
- * Returns the total cost attributable to moments when ≥ threshold sessions were
1099
- * active, or null if the period has too few sessions/messages to call it.
1100
- *
1101
- * Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
1102
- * otherwise they would collapse into a single synthetic instant and inflate the
1103
- * parallel count on older or incomplete logs.
1104
- */
1105
- function computeParallelCostWeight(messages: RawMessage[]): number | null {
1106
- const timed = messages.filter((m) => m.timestamp > 0);
1107
- if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
1108
- const distinctSessions = new Set(timed.map((m) => m.sessionId));
1109
- if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
1110
-
1111
- const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
1112
- const sidCount = new Map<string, number>();
1113
- let uniqueCount = 0;
1114
- let left = 0;
1115
- let right = 0;
1116
- let parallelCost = 0;
1117
-
1118
- for (let i = 0; i < sorted.length; i++) {
1119
- const current = sorted[i]!;
1120
- const high = current.timestamp + PARALLEL_WINDOW_MS;
1121
- const low = current.timestamp - PARALLEL_WINDOW_MS;
1122
-
1123
- while (right < sorted.length && sorted[right]!.timestamp <= high) {
1124
- const sid = sorted[right]!.sessionId;
1125
- const next = (sidCount.get(sid) ?? 0) + 1;
1126
- sidCount.set(sid, next);
1127
- if (next === 1) uniqueCount++;
1128
- right++;
1129
- }
1130
- while (left < right && sorted[left]!.timestamp < low) {
1131
- const sid = sorted[left]!.sessionId;
1132
- const next = (sidCount.get(sid) ?? 0) - 1;
1133
- if (next === 0) {
1134
- sidCount.delete(sid);
1135
- uniqueCount--;
1136
- } else {
1137
- sidCount.set(sid, next);
1138
- }
1139
- left++;
1140
- }
1141
-
1142
- if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
1143
- }
1144
-
1145
- return parallelCost;
1146
- }
1147
-
1148
- function formatThresholdTokens(n: number): string {
1149
- if (n >= 1_000_000) return `${n / 1_000_000}M`;
1150
- if (n >= 1_000) return `${n / 1_000}k`;
1151
- return String(n);
1152
- }
1153
-
1154
- function formatInsightPercent(p: number): string {
1155
- if (p >= 10) return `${Math.round(p)}%`;
1156
- return `${Math.round(p * 10) / 10}%`;
1157
- }
1158
-
1159
- // =============================================================================
1160
- // Formatting Helpers
1161
- // =============================================================================
1162
-
1163
- function formatCost(cost: number): string {
1164
- if (cost === 0) return "-";
1165
- if (cost < 0.01) return `$${cost.toFixed(4)}`;
1166
- if (cost < 1) return `$${cost.toFixed(2)}`;
1167
- if (cost < 10) return `$${cost.toFixed(2)}`;
1168
- if (cost < 100) return `$${cost.toFixed(1)}`;
1169
- return `$${Math.round(cost)}`;
1170
- }
1171
-
1172
- function formatTokens(count: number): string {
1173
- if (count === 0) return "-";
1174
- if (count < 1000) return count.toString();
1175
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
1176
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
1177
- if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
1178
- return `${Math.round(count / 1000000)}M`;
1179
- }
1180
-
1181
- function formatNumber(n: number): string {
1182
- if (n === 0) return "-";
1183
- return n.toLocaleString();
1184
- }
1185
-
1186
- function padLeft(s: string, len: number): string {
1187
- const vis = visibleWidth(s);
1188
- if (vis >= len) return s;
1189
- return " ".repeat(len - vis) + s;
1190
- }
1191
-
1192
- function padRight(s: string, len: number): string {
1193
- const vis = visibleWidth(s);
1194
- if (vis >= len) return s;
1195
- return s + " ".repeat(len - vis);
1196
- }
1197
-
1198
- function sumColumnWidths(columns: DataColumn[]): number {
1199
- return columns.reduce((sum, col) => sum + col.width, 0);
1200
- }
1201
-
1202
- function fitCell(s: string, len: number, align: "left" | "right" = "left"): string {
1203
- if (len <= 0) return "";
1204
- const truncated = truncateToWidth(s, len);
1205
- return align === "right" ? padLeft(truncated, len) : padRight(truncated, len);
1206
- }
1207
-
1208
- function clampLines(lines: string[], width: number): string[] {
1209
- return lines.map((line) => truncateToWidth(line, Math.max(width, 0)));
1210
- }
1211
-
1212
- function pickFittingText(width: number, variants: string[]): string {
1213
- for (const variant of variants) {
1214
- if (visibleWidth(variant) <= width) return variant;
1215
- }
1216
- return variants[variants.length - 1] || "";
1217
- }
1218
-
1219
- function getTableLayout(width: number): TableLayout {
1220
- const safeWidth = Math.max(width, 0);
1221
-
1222
- for (const candidate of TABLE_LAYOUTS) {
1223
- const columnsWidth = sumColumnWidths(candidate.columns);
1224
- const nameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - columnsWidth, 0));
1225
- if (nameWidth >= candidate.minNameWidth) {
1226
- return {
1227
- columns: candidate.columns,
1228
- nameWidth,
1229
- tableWidth: nameWidth + columnsWidth,
1230
- compact: candidate.compact ?? false,
1231
- };
1232
- }
1233
- }
1234
-
1235
- const fallback = TABLE_LAYOUTS[TABLE_LAYOUTS.length - 1]!;
1236
- const fallbackColumnsWidth = sumColumnWidths(fallback.columns);
1237
- const fallbackNameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - fallbackColumnsWidth, 0));
1238
- return {
1239
- columns: fallback.columns,
1240
- nameWidth: fallbackNameWidth,
1241
- tableWidth: fallbackNameWidth + fallbackColumnsWidth,
1242
- compact: fallback.compact ?? false,
1243
- };
1244
- }
1245
-
1246
- // Visual shell inspired by robhowley/pi-userland packages/pi-openrouter
1247
- // overlay/chart helpers, kept local-session-only for dm-usage.
1248
- function renderPanel(title: string, rows: string[], width: number, theme: Theme): string[] {
1249
- const safeWidth = Math.max(Math.floor(width), 0);
1250
- if (safeWidth < 12) {
1251
- return clampLines([theme.fg("accent", theme.bold(title)), ...rows, ""], safeWidth);
1252
- }
1253
-
1254
- const innerWidth = Math.max(safeWidth - 2, 1);
1255
- const titleText = ` ${title} `;
1256
- const visibleTitle = truncateToWidth(titleText, Math.max(innerWidth - 1, 1), "");
1257
- const titleWidth = visibleWidth(visibleTitle);
1258
- const topRight = Math.max(innerWidth - titleWidth, 0);
1259
- const top = `╭${visibleTitle}${"─".repeat(topRight)}╮`;
1260
- const bottom = `╰${"─".repeat(innerWidth)}╯`;
1261
- const body = rows.length > 0 ? rows : [theme.fg("dim", "No data")];
1262
-
1263
- return [
1264
- theme.fg("border", top),
1265
- ...body.map((line) => panelRow(line, innerWidth, theme)),
1266
- theme.fg("border", bottom),
1267
- "",
1268
- ];
1269
- }
1270
-
1271
- function panelRow(content: string, innerWidth: number, theme: Theme): string {
1272
- const fitted = truncateToWidth(content, innerWidth);
1273
- const padding = Math.max(innerWidth - visibleWidth(fitted), 0);
1274
- return `${theme.fg("border", "│")}${fitted}${" ".repeat(padding)}${theme.fg("border", "│")}`;
1275
- }
1276
-
1277
- function renderSpendSparkline(dailySpend: Map<string, number>, maxWidth: number): string {
1278
- if (dailySpend.size === 0) return "No spend data";
1279
-
1280
- const sortedDays = Array.from(dailySpend.keys()).sort();
1281
- const visibleDays = sortedDays.slice(-Math.max(1, Math.min(30, maxWidth)));
1282
- const values = visibleDays.map((day) => dailySpend.get(day) ?? 0);
1283
- const max = Math.max(...values);
1284
- if (max <= 0) return "No spend";
1285
-
1286
- const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
1287
- return values
1288
- .map((value) => {
1289
- const idx = Math.max(0, Math.min(blocks.length - 1, Math.ceil((value / max) * blocks.length) - 1));
1290
- return blocks[idx];
1291
- })
1292
- .join("");
1293
- }
1294
-
1295
- function formatDailyAverage(stats: TimeFilteredStats): string {
1296
- const days = stats.dailySpend.size;
1297
- if (days === 0) return "avg/day -";
1298
- return `avg/day ${formatCost(stats.totals.cost / days)}`;
1299
- }
1300
-
1301
- // =============================================================================
1302
- // Component
1303
- // =============================================================================
1304
-
1305
- const TAB_LABELS: Record<TabName, string> = {
1306
- today: "Today",
1307
- thisWeek: "This Week",
1308
- lastWeek: "Last Week",
1309
- allTime: "All Time",
1310
- };
1311
-
1312
- const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
1313
-
1314
- class UsageComponent {
1315
- private activeTab: TabName = "allTime";
1316
- private viewMode: ViewMode = "table";
1317
- private data: UsageData;
1318
- private selectedIndex = 0;
1319
- private expanded = new Set<string>();
1320
- private providerOrder: string[] = [];
1321
- private theme: Theme;
1322
- private requestRender: () => void;
1323
- private done: () => void;
1324
-
1325
- constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
1326
- this.theme = theme;
1327
- this.requestRender = requestRender;
1328
- this.done = done;
1329
- this.data = data;
1330
- this.updateProviderOrder();
1331
- }
1332
-
1333
- private updateProviderOrder(): void {
1334
- const stats = this.data[this.activeTab];
1335
- this.providerOrder = Array.from(stats.providers.entries())
1336
- .sort((a, b) => b[1].cost - a[1].cost)
1337
- .map(([name]) => name);
1338
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
1339
- }
1340
-
1341
- handleInput(data: string): void {
1342
- if (matchesKey(data, "escape") || matchesKey(data, "q")) {
1343
- this.done();
1344
- return;
1345
- }
1346
-
1347
- if (matchesKey(data, "v")) {
1348
- this.viewMode = this.viewMode === "table" ? "insights" : "table";
1349
- this.requestRender();
1350
- return;
1351
- }
1352
-
1353
- if (matchesKey(data, "tab") || matchesKey(data, "right")) {
1354
- const idx = TAB_ORDER.indexOf(this.activeTab);
1355
- this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
1356
- this.updateProviderOrder();
1357
- this.requestRender();
1358
- } else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
1359
- const idx = TAB_ORDER.indexOf(this.activeTab);
1360
- this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
1361
- this.updateProviderOrder();
1362
- this.requestRender();
1363
- } else if (this.viewMode === "table" && matchesKey(data, "up")) {
1364
- if (this.selectedIndex > 0) {
1365
- this.selectedIndex--;
1366
- this.requestRender();
1367
- }
1368
- } else if (this.viewMode === "table" && matchesKey(data, "down")) {
1369
- if (this.selectedIndex < this.providerOrder.length - 1) {
1370
- this.selectedIndex++;
1371
- this.requestRender();
1372
- }
1373
- } else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
1374
- const provider = this.providerOrder[this.selectedIndex];
1375
- if (provider) {
1376
- if (this.expanded.has(provider)) {
1377
- this.expanded.delete(provider);
1378
- } else {
1379
- this.expanded.add(provider);
1380
- }
1381
- this.requestRender();
1382
- }
1383
- }
1384
- }
1385
-
1386
- // -------------------------------------------------------------------------
1387
- // Render Methods
1388
- // -------------------------------------------------------------------------
1389
-
1390
- render(width: number): string[] {
1391
- if (this.viewMode === "insights") {
1392
- return clampLines(
1393
- [
1394
- ...this.renderTitle(width),
1395
- ...this.renderTabs(width, getTableLayout(width)),
1396
- ...this.renderSpendChart(width),
1397
- ...this.renderInsights(width),
1398
- ...this.renderHelp(width),
1399
- ],
1400
- width
1401
- );
1402
- }
1403
-
1404
- const layout = getTableLayout(width);
1405
- return clampLines(
1406
- [
1407
- ...this.renderTitle(width),
1408
- ...this.renderTabs(width, layout),
1409
- ...this.renderHeader(layout),
1410
- ...this.renderRows(layout),
1411
- ...this.renderTotals(layout),
1412
- ...this.renderFormulaNote(width),
1413
- ...this.renderHelp(width),
1414
- ],
1415
- width
1416
- );
1417
- }
1418
-
1419
- private renderTitle(width: number): string[] {
1420
- const th = this.theme;
1421
- const stats = this.data[this.activeTab];
1422
- const label = this.viewMode === "insights" ? "Usage Insights" : "Usage Statistics";
1423
- return renderPanel(
1424
- "DuckMind Usage",
1425
- [
1426
- `${th.fg("accent", th.bold(label))} · ${TAB_LABELS[this.activeTab]}`,
1427
- `Spend ${formatCost(stats.totals.cost)} · ${formatNumber(stats.totals.messages)} messages · ${formatTokens(stats.totals.tokens.total)} tokens`,
1428
- `Providers ${formatNumber(stats.providers.size)} · Sessions ${formatNumber(stats.totals.sessions)} · ${formatDailyAverage(stats)}`,
1429
- ],
1430
- width,
1431
- th
1432
- );
1433
- }
1434
-
1435
- private renderSpendChart(width: number): string[] {
1436
- const th = this.theme;
1437
- const stats = this.data[this.activeTab];
1438
- return renderPanel(
1439
- "Daily Spend",
1440
- [
1441
- th.fg("accent", renderSpendSparkline(stats.dailySpend, Math.max(width - 4, 10))),
1442
- th.fg("dim", "Local session spend, last 30 active UTC days"),
1443
- ],
1444
- width,
1445
- th
1446
- );
1447
- }
1448
-
1449
- private renderInsights(width: number): string[] {
1450
- const th = this.theme;
1451
- const stats = this.data[this.activeTab];
1452
- const { insights } = stats.insights;
1453
- const hasMessages = stats.totals.messages > 0;
1454
- const hasCost = stats.totals.cost > 0;
1455
- const lines: string[] = [];
1456
-
1457
- lines.push("What's contributing to your cost?");
1458
- lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
1459
- lines.push("");
1460
- const note = `${TAB_LABELS[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;
1461
- lines.push(th.fg("dim", note));
1462
- lines.push("");
1463
-
1464
- if (!hasMessages) {
1465
- lines.push(th.fg("dim", " No usage recorded for this period."));
1466
- lines.push("");
1467
- return lines;
1468
- }
1469
- if (!hasCost) {
1470
- lines.push(th.fg("dim", " No cost data recorded for this period."));
1471
- lines.push("");
1472
- return lines;
1473
- }
1474
- if (insights.length === 0) {
1475
- lines.push(th.fg("dim", " No insights above 1% for this period."));
1476
- lines.push("");
1477
- return lines;
1478
- }
1479
-
1480
- const indent = " ";
1481
- const adviceWidth = Math.max(width - indent.length, 30);
1482
-
1483
- for (const insight of insights) {
1484
- const pct = th.fg("accent", th.bold(formatInsightPercent(insight.percent)));
1485
- lines.push(`${pct} ${insight.headline}`);
1486
- for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
1487
- lines.push(`${indent}${th.fg("dim", wrapped)}`);
1488
- }
1489
- lines.push("");
1490
- }
1491
-
1492
- return lines;
1493
- }
1494
-
1495
- private renderTabs(width: number, layout: TableLayout): string[] {
1496
- const th = this.theme;
1497
- const fullTabs = TAB_ORDER.map((tab) => {
1498
- const label = TAB_LABELS[tab];
1499
- return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
1500
- }).join(" ");
1501
-
1502
- const activeTabOnly = th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`);
1503
- const tabLine = pickFittingText(width, [
1504
- fullTabs,
1505
- `${activeTabOnly} ${th.fg("dim", "[Tab/←→]")}`,
1506
- activeTabOnly,
1507
- ]);
1508
-
1509
- // Compact-note only applies to the table view — it's meaningless for insights.
1510
- const infoLines =
1511
- this.viewMode === "table" && layout.compact
1512
- ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
1513
- : [];
1514
-
1515
- return [tabLine, ...infoLines, ""];
1516
- }
1517
-
1518
- private renderHeader(layout: TableLayout): string[] {
1519
- const th = this.theme;
1520
-
1521
- let headerLine = fitCell("Provider / Model", layout.nameWidth);
1522
- for (const col of layout.columns) {
1523
- const label = fitCell(col.label, col.width, "right");
1524
- headerLine += col.dimmed ? th.fg("dim", label) : label;
1525
- }
1526
-
1527
- return [th.fg("muted", headerLine), th.fg("border", "─".repeat(layout.tableWidth))];
1528
- }
1529
-
1530
- private renderDataRow(
1531
- name: string,
1532
- stats: BaseStats & { sessions: Set<string> | number },
1533
- layout: TableLayout,
1534
- options: { indent?: number; selected?: boolean; dimAll?: boolean; prefix?: string } = {}
1535
- ): string {
1536
- const th = this.theme;
1537
- const { indent = 0, selected = false, dimAll = false, prefix } = options;
1538
-
1539
- const rawPrefix = prefix ?? " ".repeat(indent);
1540
- const safePrefix = layout.nameWidth > 0 ? truncateToWidth(rawPrefix, layout.nameWidth, "") : "";
1541
- const prefixWidth = visibleWidth(safePrefix);
1542
- const innerNameWidth = Math.max(layout.nameWidth - prefixWidth, 0);
1543
- const truncName = innerNameWidth > 0 ? truncateToWidth(name, innerNameWidth) : "";
1544
- const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
1545
-
1546
- let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
1547
-
1548
- for (const col of layout.columns) {
1549
- const value = fitCell(col.getValue(stats), col.width, "right");
1550
- const shouldDim = col.dimmed || dimAll;
1551
- row += shouldDim ? th.fg("dim", value) : value;
1552
- }
1553
-
1554
- return row;
1555
- }
1556
-
1557
- private renderRows(layout: TableLayout): string[] {
1558
- const th = this.theme;
1559
- const stats = this.data[this.activeTab];
1560
- const lines: string[] = [];
1561
-
1562
- if (this.providerOrder.length === 0) {
1563
- lines.push(th.fg("dim", " No usage data for this period"));
1564
- return lines;
1565
- }
1566
-
1567
- for (let i = 0; i < this.providerOrder.length; i++) {
1568
- const providerName = this.providerOrder[i]!;
1569
- const providerStats = stats.providers.get(providerName)!;
1570
- const isSelected = i === this.selectedIndex;
1571
- const isExpanded = this.expanded.has(providerName);
1572
- const arrow = isExpanded ? "▾" : "▸";
1573
- const prefix = isSelected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
1574
-
1575
- lines.push(
1576
- this.renderDataRow(formatProviderName(providerName), providerStats, layout, {
1577
- selected: isSelected,
1578
- prefix,
1579
- })
1580
- );
1581
-
1582
- if (isExpanded) {
1583
- const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
1584
-
1585
- for (const [modelName, modelStats] of models) {
1586
- lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
1587
- }
1588
- }
1589
- }
1590
-
1591
- return lines;
1592
- }
1593
-
1594
- private renderTotals(layout: TableLayout): string[] {
1595
- const th = this.theme;
1596
- const stats = this.data[this.activeTab];
1597
-
1598
- let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
1599
- for (const col of layout.columns) {
1600
- const value = fitCell(col.getValue(stats.totals), col.width, "right");
1601
- totalRow += col.dimmed ? th.fg("dim", value) : value;
1602
- }
1603
-
1604
- return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
1605
- }
1606
-
1607
- private renderFormulaNote(width: number): string[] {
1608
- const line = pickFittingText(width, [
1609
- "Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite (as of 0.2.0)",
1610
- "Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)",
1611
- "Tokens & ↑In include CacheWrite (v0.2.0+)",
1612
- "Incl. CacheWrite (v0.2.0+)",
1613
- ]);
1614
- return [this.theme.fg("dim", line), ""];
1615
- }
1616
-
1617
- private renderHelp(width: number): string[] {
1618
- const variants =
1619
- this.viewMode === "insights"
1620
- ? [
1621
- "[Tab/←→] period [v] table view [q] close",
1622
- "[Tab] period [v] table [q] close",
1623
- "[v] table [q] close",
1624
- "[q] close",
1625
- ]
1626
- : [
1627
- "[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close",
1628
- "[Tab] period [↑↓] select [Enter] expand [v] insights [q] close",
1629
- "[↑↓] select [Enter] expand [v] insights [q] close",
1630
- "[↑↓] select [v] insights [q] close",
1631
- "[↑↓] select [q] close",
1632
- "[q] close",
1633
- ];
1634
- const line = pickFittingText(width, variants);
1635
- return [this.theme.fg("dim", line)];
1636
- }
1637
-
1638
- invalidate(): void {}
1639
- dispose(): void {}
1640
- }
1641
-
1642
- // =============================================================================
1643
- // Extension Entry Point
1644
- // =============================================================================
1645
-
1646
- export default function (pi: ExtensionAPI) {
1647
- pi.registerCommand("usage", {
1648
- description: `Show usage statistics dashboard and account tools: ${USAGE_SUBCOMMANDS.join(", ")}`,
1649
- getArgumentCompletions: getUsageCommandCompletions,
1650
- handler: async (args: string, ctx: ExtensionCommandContext) => {
1651
- const parsed = parseUsageCommandArgs(args);
1652
- if (parsed.subcommand) {
1653
- if (!isUsageSubcommand(parsed.subcommand)) {
1654
- ctx.ui.notify(`Unknown /usage subcommand: ${parsed.subcommand}\n${USAGE_HELP_TEXT}`, "warning");
1655
- return;
1656
- }
1657
- await runUsageSubcommand(parsed.subcommand, parsed.rest, ctx);
1658
- return;
1659
- }
1660
-
1661
- if (!ctx.hasUI) {
1662
- await runUsageShowSubcommand(ctx);
1663
- return;
1664
- }
1665
-
1666
- const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
1667
- const loader = new CancellableLoader(
1668
- tui,
1669
- (s: string) => theme.fg("accent", s),
1670
- (s: string) => theme.fg("muted", s),
1671
- "Loading Usage..."
1672
- );
1673
- let finished = false;
1674
- const finish = (value: UsageData | null) => {
1675
- if (finished) return;
1676
- finished = true;
1677
- loader.dispose();
1678
- done(value);
1679
- };
1680
-
1681
- loader.onAbort = () => finish(null);
1682
-
1683
- collectUsageData(loader.signal)
1684
- .then(finish)
1685
- .catch(() => finish(null));
1686
-
1687
- return loader;
1688
- });
1689
-
1690
- if (!data) {
1691
- return;
1692
- }
1693
-
1694
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1695
- const container = new Container();
1696
-
1697
- // Top border
1698
- container.addChild(new Spacer(1));
1699
- container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
1700
- container.addChild(new Spacer(1));
1701
-
1702
- const usage = new UsageComponent(theme, data, () => tui.requestRender(), () => done());
1703
-
1704
- return {
1705
- render: (w: number) => {
1706
- const borderLines = clampLines(container.render(w), w);
1707
- const usageLines = usage.render(w);
1708
- const bottomBorder = theme.fg("border", "─".repeat(w));
1709
- return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
1710
- },
1711
- invalidate: () => container.invalidate(),
1712
- handleInput: (input: string) => usage.handleInput(input),
1713
- dispose: () => {},
1714
- };
1715
- });
1716
- },
1717
- });
1718
- }