@duckmind/dm-windows-x64 0.60.4 → 0.60.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.
- package/dm.exe +0 -0
- package/extensions/.dm-extensions.json +70 -123
- package/extensions/dm-9router-ext/src/index.js +410 -5
- package/extensions/dm-caveman/extensions/caveman.js +283 -12
- package/extensions/dm-cliproxy/index.js +182 -2
- package/extensions/dm-cliproxy/scripts/check-config-migration.js +66 -8
- package/extensions/dm-cliproxy/src/apply.js +228 -1
- package/extensions/dm-cliproxy/src/cache.js +42 -1
- package/extensions/dm-cliproxy/src/commands.js +50 -2
- package/extensions/dm-cliproxy/src/compat.js +81 -1
- package/extensions/dm-cliproxy/src/config.js +192 -2
- package/extensions/dm-cliproxy/src/conflicts.js +46 -1
- package/extensions/dm-cliproxy/src/fetch-models.js +190 -1
- package/extensions/dm-cliproxy/src/fetch-usage.js +41 -1
- package/extensions/dm-cliproxy/src/log.js +23 -1
- package/extensions/dm-cliproxy/src/status-quota.js +77 -1
- package/extensions/dm-cliproxy/src/ui-frame.js +50 -1
- package/extensions/dm-cliproxy/src/ui-hub/hub.js +199 -2
- package/extensions/dm-cliproxy/src/ui-hub/index.js +26 -2
- package/extensions/dm-cliproxy/src/ui-hub/shell.js +46 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-diagnostics.js +69 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-models.js +379 -1
- package/extensions/dm-cliproxy/src/ui-hub/view-usage.js +106 -1
- package/extensions/dm-cliproxy/src/ui-picker/catalog.js +41 -1
- package/extensions/dm-cliproxy/src/ui-picker/mutate.js +115 -1
- package/extensions/dm-cliproxy/src/ui-picker/prompt-confirm.js +35 -1
- package/extensions/dm-cliproxy/src/ui-picker/prompt-name.js +62 -1
- package/extensions/dm-cliproxy/src/ui-picker/providers.js +45 -1
- package/extensions/dm-cliproxy/src/ui-picker/render-text.js +34 -1
- package/extensions/dm-cliproxy/src/ui-picker/rows.js +57 -1
- package/extensions/dm-cliproxy/src/ui-setup.js +260 -2
- package/extensions/dm-cliproxy/src/ui-usage.js +151 -1
- package/extensions/dm-cliproxy/src/usage-shared-cache.js +97 -1
- package/extensions/dm-context/src/context.js +144 -1
- package/extensions/dm-context/src/index.js +339 -7
- package/extensions/dm-context/src/utils.js +9 -1
- package/extensions/dm-cua/bin/browser-cua.mjs +73 -8
- package/extensions/dm-cua/index.js +75 -6
- package/extensions/dm-cua/src/browser-cua-lib.mjs +490 -6
- package/extensions/dm-cua/src/browser-install.mjs +331 -2
- package/extensions/dm-fff/src/index.js +688 -12
- package/extensions/dm-fff/src/query.js +60 -1
- package/extensions/dm-goal/src/goal.js +894 -23
- package/extensions/dm-image2/index.js +103 -9
- package/extensions/dm-image2/src/image-lib.mjs +1275 -8
- package/extensions/dm-subagents/install.mjs +62 -8
- package/extensions/dm-subagents/src/agents/agent-management.js +1190 -36
- package/extensions/dm-subagents/src/agents/agent-memory.js +216 -6
- package/extensions/dm-subagents/src/agents/agent-scope.js +5 -1
- package/extensions/dm-subagents/src/agents/agent-selection.js +20 -1
- package/extensions/dm-subagents/src/agents/agent-serializer.js +120 -5
- package/extensions/dm-subagents/src/agents/agents.js +1139 -11
- package/extensions/dm-subagents/src/agents/chain-serializer.js +299 -11
- package/extensions/dm-subagents/src/agents/frontmatter.js +65 -6
- package/extensions/dm-subagents/src/agents/identity.js +29 -1
- package/extensions/dm-subagents/src/agents/proactive-skills.js +141 -1
- package/extensions/dm-subagents/src/agents/skills.js +614 -6
- package/extensions/dm-subagents/src/extension/config.js +35 -2
- package/extensions/dm-subagents/src/extension/control-notices.js +69 -4
- package/extensions/dm-subagents/src/extension/doctor.js +172 -15
- package/extensions/dm-subagents/src/extension/fanout-child.js +158 -231
- package/extensions/dm-subagents/src/extension/index.js +521 -359
- package/extensions/dm-subagents/src/extension/rpc.js +266 -7
- package/extensions/dm-subagents/src/extension/schemas.js +275 -1
- package/extensions/dm-subagents/src/extension/tool-description.js +111 -6
- package/extensions/dm-subagents/src/intercom/intercom-bridge.js +126 -4
- package/extensions/dm-subagents/src/intercom/native-supervisor-channel.js +452 -5
- package/extensions/dm-subagents/src/intercom/result-intercom.js +319 -3
- package/extensions/dm-subagents/src/profiles/profiles.js +458 -3
- package/extensions/dm-subagents/src/runs/background/async-execution.js +834 -40
- package/extensions/dm-subagents/src/runs/background/async-job-tracker.js +435 -14
- package/extensions/dm-subagents/src/runs/background/async-resume.js +334 -8
- package/extensions/dm-subagents/src/runs/background/async-status.js +313 -12
- package/extensions/dm-subagents/src/runs/background/chain-append.js +245 -2
- package/extensions/dm-subagents/src/runs/background/chain-root-attachment.js +136 -1
- package/extensions/dm-subagents/src/runs/background/completion-batcher.js +94 -1
- package/extensions/dm-subagents/src/runs/background/completion-dedupe.js +54 -1
- package/extensions/dm-subagents/src/runs/background/control-channel.js +190 -1
- package/extensions/dm-subagents/src/runs/background/fleet-view.js +483 -17
- package/extensions/dm-subagents/src/runs/background/notify.js +129 -3
- package/extensions/dm-subagents/src/runs/background/parallel-groups.js +34 -1
- package/extensions/dm-subagents/src/runs/background/result-watcher.js +236 -6
- package/extensions/dm-subagents/src/runs/background/run-id-resolver.js +76 -4
- package/extensions/dm-subagents/src/runs/background/run-status.js +427 -23
- package/extensions/dm-subagents/src/runs/background/scheduled-runs.js +487 -4
- package/extensions/dm-subagents/src/runs/background/stale-run-reconciler.js +306 -9
- package/extensions/dm-subagents/src/runs/background/subagent-runner.js +2849 -73
- package/extensions/dm-subagents/src/runs/background/top-level-async.js +5 -1
- package/extensions/dm-subagents/src/runs/background/wait.js +206 -11
- package/extensions/dm-subagents/src/runs/foreground/chain-clarify.js +1013 -12
- package/extensions/dm-subagents/src/runs/foreground/chain-execution.js +980 -101
- package/extensions/dm-subagents/src/runs/foreground/execution.js +1165 -45
- package/extensions/dm-subagents/src/runs/foreground/subagent-executor.js +3157 -222
- package/extensions/dm-subagents/src/runs/shared/acceptance.js +835 -3
- package/extensions/dm-subagents/src/runs/shared/chain-outputs.js +104 -1
- package/extensions/dm-subagents/src/runs/shared/completion-guard.js +116 -3
- package/extensions/dm-subagents/src/runs/shared/dm-args.js +208 -1
- package/extensions/dm-subagents/src/runs/shared/dm-spawn.js +90 -1
- package/extensions/dm-subagents/src/runs/shared/dynamic-fanout.js +282 -1
- package/extensions/dm-subagents/src/runs/shared/long-running-guard.js +148 -1
- package/extensions/dm-subagents/src/runs/shared/mcp-direct-tool-allowlist.js +305 -1
- package/extensions/dm-subagents/src/runs/shared/model-fallback.js +194 -1
- package/extensions/dm-subagents/src/runs/shared/model-scope.js +65 -1
- package/extensions/dm-subagents/src/runs/shared/nested-events.js +851 -8
- package/extensions/dm-subagents/src/runs/shared/nested-path.js +41 -1
- package/extensions/dm-subagents/src/runs/shared/nested-render.js +105 -1
- package/extensions/dm-subagents/src/runs/shared/parallel-utils.js +81 -4
- package/extensions/dm-subagents/src/runs/shared/run-history.js +51 -4
- package/extensions/dm-subagents/src/runs/shared/single-output.js +149 -8
- package/extensions/dm-subagents/src/runs/shared/structured-output.js +58 -1
- package/extensions/dm-subagents/src/runs/shared/subagent-control.js +166 -5
- package/extensions/dm-subagents/src/runs/shared/subagent-prompt-runtime.js +329 -13
- package/extensions/dm-subagents/src/runs/shared/tool-budget.js +73 -1
- package/extensions/dm-subagents/src/runs/shared/turn-budget.js +47 -4
- package/extensions/dm-subagents/src/runs/shared/workflow-graph.js +196 -1
- package/extensions/dm-subagents/src/runs/shared/worktree.js +435 -3
- package/extensions/dm-subagents/src/shared/artifacts.js +92 -2
- package/extensions/dm-subagents/src/shared/atomic-json.js +55 -1
- package/extensions/dm-subagents/src/shared/child-transcript.js +167 -5
- package/extensions/dm-subagents/src/shared/file-coalescer.js +25 -1
- package/extensions/dm-subagents/src/shared/fork-context.js +147 -4
- package/extensions/dm-subagents/src/shared/formatters.js +98 -7
- package/extensions/dm-subagents/src/shared/jsonl-writer.js +56 -2
- package/extensions/dm-subagents/src/shared/model-info.js +62 -1
- package/extensions/dm-subagents/src/shared/post-exit-stdio-guard.js +68 -1
- package/extensions/dm-subagents/src/shared/session-identity.js +6 -1
- package/extensions/dm-subagents/src/shared/session-tokens.js +39 -2
- package/extensions/dm-subagents/src/shared/settings.js +198 -11
- package/extensions/dm-subagents/src/shared/status-format.js +53 -1
- package/extensions/dm-subagents/src/shared/types.js +184 -6
- package/extensions/dm-subagents/src/shared/utils.js +462 -2
- package/extensions/dm-subagents/src/slash/prompt-template-bridge.js +288 -1
- package/extensions/dm-subagents/src/slash/prompt-workflows.js +297 -7
- package/extensions/dm-subagents/src/slash/slash-bridge.js +118 -1
- package/extensions/dm-subagents/src/slash/slash-commands.js +1287 -31
- package/extensions/dm-subagents/src/slash/slash-live-state.js +240 -4
- package/extensions/dm-subagents/src/tui/render-helpers.js +64 -1
- package/extensions/dm-subagents/src/tui/render.js +1542 -4
- package/extensions/dm-usage/index.js +1294 -9
- package/extensions/greedysearch-dm/bin/cdp-greedy.mjs +40 -9
- package/extensions/greedysearch-dm/bin/cdp-headless.mjs +5 -2
- package/extensions/greedysearch-dm/bin/cdp-visible.mjs +5 -2
- package/extensions/greedysearch-dm/bin/cdp.mjs +896 -30
- package/extensions/greedysearch-dm/bin/gschrome.mjs +30 -2
- package/extensions/greedysearch-dm/bin/kill-visible.mjs +7 -2
- package/extensions/greedysearch-dm/bin/launch-visible.mjs +13 -2
- package/extensions/greedysearch-dm/bin/launch.mjs +282 -10
- package/extensions/greedysearch-dm/bin/mcp.mjs +386 -361
- package/extensions/greedysearch-dm/bin/search.mjs +620 -540
- package/extensions/greedysearch-dm/bin/visible.mjs +22 -2
- package/extensions/greedysearch-dm/extractors/bing-copilot.mjs +329 -579
- package/extensions/greedysearch-dm/extractors/chatgpt.mjs +301 -583
- package/extensions/greedysearch-dm/extractors/common.mjs +408 -32
- package/extensions/greedysearch-dm/extractors/consensus.mjs +376 -365
- package/extensions/greedysearch-dm/extractors/consent.mjs +303 -14
- package/extensions/greedysearch-dm/extractors/gemini.mjs +228 -592
- package/extensions/greedysearch-dm/extractors/google-ai.mjs +78 -499
- package/extensions/greedysearch-dm/extractors/logically.mjs +270 -347
- package/extensions/greedysearch-dm/extractors/perplexity.mjs +243 -581
- package/extensions/greedysearch-dm/extractors/selectors.mjs +32 -1
- package/extensions/greedysearch-dm/extractors/semantic-scholar.mjs +130 -317
- package/extensions/greedysearch-dm/index.js +123 -23
- package/extensions/greedysearch-dm/src/fetcher.mjs +576 -2
- package/extensions/greedysearch-dm/src/formatters/results.js +95 -10
- package/extensions/greedysearch-dm/src/formatters/sources.js +57 -1
- package/extensions/greedysearch-dm/src/formatters/synthesis.js +49 -1
- package/extensions/greedysearch-dm/src/github.mjs +222 -7
- package/extensions/greedysearch-dm/src/reddit.mjs +145 -14
- package/extensions/greedysearch-dm/src/search/browser-lifecycle.mjs +340 -9
- package/extensions/greedysearch-dm/src/search/challenge-detect.mjs +112 -4
- package/extensions/greedysearch-dm/src/search/chrome.mjs +486 -285
- package/extensions/greedysearch-dm/src/search/constants.mjs +109 -8
- package/extensions/greedysearch-dm/src/search/defaults.mjs +10 -1
- package/extensions/greedysearch-dm/src/search/engines.mjs +79 -9
- package/extensions/greedysearch-dm/src/search/fetch-source.mjs +441 -349
- package/extensions/greedysearch-dm/src/search/file-sources.mjs +29 -7
- package/extensions/greedysearch-dm/src/search/minimize.mjs +86 -1
- package/extensions/greedysearch-dm/src/search/output.mjs +51 -5
- package/extensions/greedysearch-dm/src/search/paths.mjs +48 -1
- package/extensions/greedysearch-dm/src/search/pdf.mjs +63 -2
- package/extensions/greedysearch-dm/src/search/port-pid.mjs +69 -1
- package/extensions/greedysearch-dm/src/search/progress.mjs +109 -2
- package/extensions/greedysearch-dm/src/search/query.mjs +21 -1
- package/extensions/greedysearch-dm/src/search/recovery.mjs +49 -1
- package/extensions/greedysearch-dm/src/search/research.mjs +2227 -458
- package/extensions/greedysearch-dm/src/search/scale-aware.mjs +61 -11
- package/extensions/greedysearch-dm/src/search/simple-research.mjs +396 -805
- package/extensions/greedysearch-dm/src/search/sources.mjs +412 -1
- package/extensions/greedysearch-dm/src/search/synthesis-runner.mjs +127 -12
- package/extensions/greedysearch-dm/src/search/synthesis.mjs +202 -12
- package/extensions/greedysearch-dm/src/tools/greedy-search-handler.js +209 -23
- package/extensions/greedysearch-dm/src/tools/shared.js +226 -10
- package/extensions/greedysearch-dm/src/utils/content.mjs +35 -4
- package/extensions/greedysearch-dm/src/utils/helpers.js +22 -1
- package/extensions/greedysearch-dm/src/utils/node-runtime.mjs +10 -1
- package/extensions/greedysearch-dm/src/utils/system-cmds.mjs +61 -1
- package/package.json +1 -1
|
@@ -1,9 +1,1294 @@
|
|
|
1
|
-
import{DynamicBorder
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
`),"info")}async function GQ(J,Q){let Z=QQ(Q.toLowerCase());if(Z)return Z;if(Q){J.ui.notify("Unknown reset target. Use: /usage reset [manual|quota|all]","warning");return}if(!J.hasUI)return"all";let $=await J.ui.select("Reset DuckMind Usage State",["manual - clear manual account override","quota - clear quota cooldown markers","all - clear manual override, quota cooldown markers, and hidden account state"]);if(!$)return;if($.startsWith("manual"))return"manual";if($.startsWith("quota"))return"quota";return"all"}async function KQ(J,Q){let Z=await GQ(J,Q);if(!Z)return;let{state:$}=await t(),Y=Boolean($.manualAccountId);if(Z==="manual"||Z==="all")$.manualAccountId=void 0;let X=0;if(Z==="quota"||Z==="all")$.accounts=$.accounts.map((j)=>{if(j.quotaExhaustedUntil===void 0)return j;X++;let{quotaExhaustedUntil:F,...V}=j;return V});let z=Z==="all"?$.removedAccountIds.length:0;if(Z==="all")$.removedAccountIds=[];await g($),J.ui.notify(`reset: target=${Z} manualCleared=${Y&&!$.manualAccountId?"yes":"no"} quotaCleared=${X} hiddenCleared=${z}`,"info")}async function HQ(J,Q,Z){if(J==="use")return TJ(Z,Q);if(J==="show")return WJ(Z);if(J==="rotation")return XQ(Z);if(J==="verify")return qQ(Z);if(J==="path")return FQ(Z);if(J==="reset")return KQ(Z,Q);Z.ui.notify(CJ,"info")}var qJ=120000,n=4,FJ=150000,GJ=1e5,AJ=28800000,KJ=5,BQ=10,PQ=1;function DQ(J,Q){if(J.messages.length===0)return{insights:[]};let Z=J.messages.reduce((V,q)=>V+q.cost,0);if(Z<=0)return{insights:[]};let $=[],Y=RQ(J.messages);if(Y!==null)$.push({percent:Y/Z*100,headline:`of your cost was while ${n}+ sessions ran in parallel`,advice:"All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly."});let X=J.messages.filter((V)=>V.input+V.cacheRead+V.cacheWrite>FJ).reduce((V,q)=>V+q.cost,0);$.push({percent:X/Z*100,headline:`of your cost was at >${HJ(FJ)} context`,advice:"Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks."});let z=J.messages.filter((V)=>V.input+V.cacheWrite>GJ).reduce((V,q)=>V+q.cost,0);$.push({percent:z/Z*100,headline:`of your cost came from >${HJ(GJ)}-token uncached prompts`,advice:"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."});let j=J.messages.filter((V)=>Q.has(V.sessionId)).reduce((V,q)=>V+q.cost,0);if(j>0)$.push({percent:j/Z*100,headline:`of your cost came from sessions active for ${AJ/3600000}+ hours`,advice:"These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional."});if(J.sessionCosts.size>KJ){let V=Array.from(J.sessionCosts.values()).sort((G,H)=>H-G),q=Math.min(KJ,V.length),K=V.slice(0,q).reduce((G,H)=>G+H,0);$.push({percent:K/Z*100,headline:`of your cost came from your top ${q} session${q===1?"":"s"}`,advice:"A small number of sessions drives most of your spend. The table view can help pinpoint which ones."})}return{insights:$.filter((V)=>V.percent>=PQ).sort((V,q)=>q.percent-V.percent)}}function RQ(J){let Q=J.filter((V)=>V.timestamp>0);if(Q.length<BQ)return null;if(new Set(Q.map((V)=>V.sessionId)).size<n)return null;let $=Q.slice().sort((V,q)=>V.timestamp-q.timestamp),Y=new Map,X=0,z=0,j=0,F=0;for(let V=0;V<$.length;V++){let q=$[V],K=q.timestamp+qJ,G=q.timestamp-qJ;while(j<$.length&&$[j].timestamp<=K){let H=$[j].sessionId,B=(Y.get(H)??0)+1;if(Y.set(H,B),B===1)X++;j++}while(z<j&&$[z].timestamp<G){let H=$[z].sessionId,B=(Y.get(H)??0)-1;if(B===0)Y.delete(H),X--;else Y.set(H,B);z++}if(X>=n)F+=q.cost}return F}function HJ(J){if(J>=1e6)return`${J/1e6}M`;if(J>=1000)return`${J/1000}k`;return String(J)}function IQ(J){if(J>=10)return`${Math.round(J)}%`;return`${Math.round(J*10)/10}%`}function N(J){if(J===0)return"-";if(J<0.01)return`$${J.toFixed(4)}`;if(J<1)return`$${J.toFixed(2)}`;if(J<10)return`$${J.toFixed(2)}`;if(J<100)return`$${J.toFixed(1)}`;return`$${Math.round(J)}`}function O(J){if(J===0)return"-";if(J<1000)return J.toString();if(J<1e4)return`${(J/1000).toFixed(1)}k`;if(J<1e6)return`${Math.round(J/1000)}k`;if(J<1e7)return`${(J/1e6).toFixed(1)}M`;return`${Math.round(J/1e6)}M`}function E(J){if(J===0)return"-";return J.toLocaleString()}function kQ(J,Q){let Z=C(J);if(Z>=Q)return J;return" ".repeat(Q-Z)+J}function fJ(J,Q){let Z=C(J);if(Z>=Q)return J;return J+" ".repeat(Q-Z)}function BJ(J){return J.reduce((Q,Z)=>Q+Z.width,0)}function T(J,Q,Z="left"){if(Q<=0)return"";let $=_(J,Q);return Z==="right"?kQ($,Q):fJ($,Q)}function f(J,Q){return J.map((Z)=>_(Z,Math.max(Q,0)))}function u(J,Q){for(let Z of Q)if(C(Z)<=J)return Z;return Q[Q.length-1]||""}function PJ(J){let Q=Math.max(J,0);for(let X of h){let z=BJ(X.columns),j=Math.min(m,Math.max(Q-z,0));if(j>=X.minNameWidth)return{columns:X.columns,nameWidth:j,tableWidth:j+z,compact:X.compact??!1}}let Z=h[h.length-1],$=BJ(Z.columns),Y=Math.min(m,Math.max(Q-$,0));return{columns:Z.columns,nameWidth:Y,tableWidth:Y+$,compact:Z.compact??!1}}function DJ(J,Q,Z,$){let Y=Math.max(Math.floor(Z),0);if(Y<12)return f([$.fg("accent",$.bold(J)),...Q,""],Y);let X=Math.max(Y-2,1),z=` ${J} `,j=_(z,Math.max(X-1,1),""),F=C(j),V=Math.max(X-F,0),q=`╭${j}${"─".repeat(V)}╮`,K=`╰${"─".repeat(X)}╯`,G=Q.length>0?Q:[$.fg("dim","No data")];return[$.fg("border",q),...G.map((H)=>EQ(H,X,$)),$.fg("border",K),""]}function EQ(J,Q,Z){let $=_(J,Q),Y=Math.max(Q-C($),0);return`${Z.fg("border","│")}${$}${" ".repeat(Y)}${Z.fg("border","│")}`}function LQ(J,Q){if(J.size===0)return"No spend data";let Y=Array.from(J.keys()).sort().slice(-Math.max(1,Math.min(30,Q))).map((j)=>J.get(j)??0),X=Math.max(...Y);if(X<=0)return"No spend";let z=["▁","▂","▃","▄","▅","▆","▇","█"];return Y.map((j)=>{let F=Math.max(0,Math.min(z.length-1,Math.ceil(j/X*z.length)-1));return z[F]}).join("")}function UQ(J){let Q=J.dailySpend.size;if(Q===0)return"avg/day -";return`avg/day ${N(J.totals.cost/Q)}`}var x={today:"Today",thisWeek:"This Week",lastWeek:"Last Week",allTime:"All Time"},k=["today","thisWeek","lastWeek","allTime"];class wJ{activeTab="allTime";viewMode="table";data;selectedIndex=0;expanded=new Set;providerOrder=[];theme;requestRender;done;constructor(J,Q,Z,$){this.theme=J,this.requestRender=Z,this.done=$,this.data=Q,this.updateProviderOrder()}updateProviderOrder(){let J=this.data[this.activeTab];this.providerOrder=Array.from(J.providers.entries()).sort((Q,Z)=>Z[1].cost-Q[1].cost).map(([Q])=>Q),this.selectedIndex=Math.min(this.selectedIndex,Math.max(0,this.providerOrder.length-1))}handleInput(J){if(R(J,"escape")||R(J,"q")){this.done();return}if(R(J,"v")){this.viewMode=this.viewMode==="table"?"insights":"table",this.requestRender();return}if(R(J,"tab")||R(J,"right")){let Q=k.indexOf(this.activeTab);this.activeTab=k[(Q+1)%k.length],this.updateProviderOrder(),this.requestRender()}else if(R(J,"shift+tab")||R(J,"left")){let Q=k.indexOf(this.activeTab);this.activeTab=k[(Q-1+k.length)%k.length],this.updateProviderOrder(),this.requestRender()}else if(this.viewMode==="table"&&R(J,"up")){if(this.selectedIndex>0)this.selectedIndex--,this.requestRender()}else if(this.viewMode==="table"&&R(J,"down")){if(this.selectedIndex<this.providerOrder.length-1)this.selectedIndex++,this.requestRender()}else if(this.viewMode==="table"&&(R(J,"enter")||R(J,"space"))){let Q=this.providerOrder[this.selectedIndex];if(Q){if(this.expanded.has(Q))this.expanded.delete(Q);else this.expanded.add(Q);this.requestRender()}}}render(J){if(this.viewMode==="insights")return f([...this.renderTitle(J),...this.renderTabs(J,PJ(J)),...this.renderSpendChart(J),...this.renderInsights(J),...this.renderHelp(J)],J);let Q=PJ(J);return f([...this.renderTitle(J),...this.renderTabs(J,Q),...this.renderHeader(Q),...this.renderRows(Q),...this.renderTotals(Q),...this.renderFormulaNote(J),...this.renderHelp(J)],J)}renderTitle(J){let Q=this.theme,Z=this.data[this.activeTab],$=this.viewMode==="insights"?"Usage Insights":"Usage Statistics";return DJ("DuckMind Usage",[`${Q.fg("accent",Q.bold($))} · ${x[this.activeTab]}`,`Spend ${N(Z.totals.cost)} · ${E(Z.totals.messages)} messages · ${O(Z.totals.tokens.total)} tokens`,`Providers ${E(Z.providers.size)} · Sessions ${E(Z.totals.sessions)} · ${UQ(Z)}`],J,Q)}renderSpendChart(J){let Q=this.theme,Z=this.data[this.activeTab];return DJ("Daily Spend",[Q.fg("accent",LQ(Z.dailySpend,Math.max(J-4,10))),Q.fg("dim","Local session spend, last 30 active UTC days")],J,Q)}renderInsights(J){let Q=this.theme,Z=this.data[this.activeTab],{insights:$}=Z.insights,Y=Z.totals.messages>0,X=Z.totals.cost>0,z=[];z.push("What's contributing to your cost?"),z.push(Q.fg("dim","Approximate, based on local sessions on this machine.")),z.push("");let j=`${x[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;if(z.push(Q.fg("dim",j)),z.push(""),!Y)return z.push(Q.fg("dim"," No usage recorded for this period.")),z.push(""),z;if(!X)return z.push(Q.fg("dim"," No cost data recorded for this period.")),z.push(""),z;if($.length===0)return z.push(Q.fg("dim"," No insights above 1% for this period.")),z.push(""),z;let F=" ",V=Math.max(J-F.length,30);for(let q of $){let K=Q.fg("accent",Q.bold(IQ(q.percent)));z.push(`${K} ${q.headline}`);for(let G of ZJ(q.advice,V))z.push(`${F}${Q.fg("dim",G)}`);z.push("")}return z}renderTabs(J,Q){let Z=this.theme,$=k.map((j)=>{let F=x[j];return j===this.activeTab?Z.fg("accent",`[${F}]`):Z.fg("dim",` ${F} `)}).join(" "),Y=Z.fg("accent",`[${x[this.activeTab]}]`),X=u(J,[$,`${Y} ${Z.fg("dim","[Tab/←→]")}`,Y]),z=this.viewMode==="table"&&Q.compact?ZJ(Z.fg("dim","Compact view. Widen the terminal for more columns."),Math.max(J,1)):[];return[X,...z,""]}renderHeader(J){let Q=this.theme,Z=T("Provider / Model",J.nameWidth);for(let $ of J.columns){let Y=T($.label,$.width,"right");Z+=$.dimmed?Q.fg("dim",Y):Y}return[Q.fg("muted",Z),Q.fg("border","─".repeat(J.tableWidth))]}renderDataRow(J,Q,Z,$={}){let Y=this.theme,{indent:X=0,selected:z=!1,dimAll:j=!1,prefix:F}=$,V=F??" ".repeat(X),q=Z.nameWidth>0?_(V,Z.nameWidth,""):"",K=C(q),G=Math.max(Z.nameWidth-K,0),H=G>0?_(J,G):"",B=z?Y.fg("accent",H):j?Y.fg("dim",H):H,P=q+(G>0?fJ(B,G):"");for(let D of Z.columns){let I=T(D.getValue(Q),D.width,"right"),w=D.dimmed||j;P+=w?Y.fg("dim",I):I}return P}renderRows(J){let Q=this.theme,Z=this.data[this.activeTab],$=[];if(this.providerOrder.length===0)return $.push(Q.fg("dim"," No usage data for this period")),$;for(let Y=0;Y<this.providerOrder.length;Y++){let X=this.providerOrder[Y],z=Z.providers.get(X),j=Y===this.selectedIndex,F=this.expanded.has(X),V=F?"▾":"▸",q=j?Q.fg("accent",`${V} `):Q.fg("dim",`${V} `);if($.push(this.renderDataRow(S(X),z,J,{selected:j,prefix:q})),F){let K=Array.from(z.models.entries()).sort((G,H)=>H[1].cost-G[1].cost);for(let[G,H]of K)$.push(this.renderDataRow(G,H,J,{indent:4,dimAll:!0}))}}return $}renderTotals(J){let Q=this.theme,Z=this.data[this.activeTab],$=T(Q.bold("Total"),J.nameWidth);for(let Y of J.columns){let X=T(Y.getValue(Z.totals),Y.width,"right");$+=Y.dimmed?Q.fg("dim",X):X}return[Q.fg("border","─".repeat(J.tableWidth)),$,""]}renderFormulaNote(J){let Q=u(J,["Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite (as of 0.2.0)","Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)","Tokens & ↑In include CacheWrite (v0.2.0+)","Incl. CacheWrite (v0.2.0+)"]);return[this.theme.fg("dim",Q),""]}renderHelp(J){let Q=this.viewMode==="insights"?["[Tab/←→] period [v] table view [q] close","[Tab] period [v] table [q] close","[v] table [q] close","[q] close"]:["[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close","[Tab] period [↑↓] select [Enter] expand [v] insights [q] close","[↑↓] select [Enter] expand [v] insights [q] close","[↑↓] select [v] insights [q] close","[↑↓] select [q] close","[q] close"],Z=u(J,Q);return[this.theme.fg("dim",Z)]}invalidate(){}dispose(){}}function _Q(J){J.registerCommand("usage",{description:`Show usage statistics dashboard and account tools: ${y.join(", ")}`,getArgumentCompletions:ZQ,handler:async(Q,Z)=>{let $=tJ(Q);if($.subcommand){if(!JQ($.subcommand)){Z.ui.notify(`Unknown /usage subcommand: ${$.subcommand}
|
|
9
|
-
|
|
1
|
+
import { DynamicBorder } from "@duckmind/dm-coding-agent";
|
|
2
|
+
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@duckmind/dm-tui";
|
|
3
|
+
import { constants as fsConstants } from "node:fs";
|
|
4
|
+
import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
const MAX_NAME_COL_WIDTH = 26;
|
|
8
|
+
const PROVIDER_DISPLAY_NAMES = {
|
|
9
|
+
"openai-codex": "duckmind-ultra",
|
|
10
|
+
openrouter: "duckmind-standard"
|
|
11
|
+
};
|
|
12
|
+
function formatProviderName(provider) {
|
|
13
|
+
return PROVIDER_DISPLAY_NAMES[provider] ?? provider;
|
|
14
|
+
}
|
|
15
|
+
const SESSIONS_COLUMN = {
|
|
16
|
+
label: "Sessions",
|
|
17
|
+
width: 9,
|
|
18
|
+
getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size)
|
|
19
|
+
};
|
|
20
|
+
const MSGS_COLUMN = {
|
|
21
|
+
label: "Msgs",
|
|
22
|
+
width: 9,
|
|
23
|
+
getValue: (s) => formatNumber(s.messages)
|
|
24
|
+
};
|
|
25
|
+
const COST_COLUMN = {
|
|
26
|
+
label: "Cost",
|
|
27
|
+
width: 9,
|
|
28
|
+
getValue: (s) => formatCost(s.cost)
|
|
29
|
+
};
|
|
30
|
+
const TOKENS_COLUMN = {
|
|
31
|
+
label: "Tokens",
|
|
32
|
+
width: 9,
|
|
33
|
+
getValue: (s) => formatTokens(s.tokens.total)
|
|
34
|
+
};
|
|
35
|
+
const INPUT_COLUMN = {
|
|
36
|
+
label: "↑In",
|
|
37
|
+
width: 8,
|
|
38
|
+
dimmed: true,
|
|
39
|
+
getValue: (s) => formatTokens(s.tokens.input + s.tokens.cacheWrite)
|
|
40
|
+
};
|
|
41
|
+
const OUTPUT_COLUMN = {
|
|
42
|
+
label: "↓Out",
|
|
43
|
+
width: 8,
|
|
44
|
+
dimmed: true,
|
|
45
|
+
getValue: (s) => formatTokens(s.tokens.output)
|
|
46
|
+
};
|
|
47
|
+
const CACHE_COLUMN = {
|
|
48
|
+
label: "Cache",
|
|
49
|
+
width: 8,
|
|
50
|
+
dimmed: true,
|
|
51
|
+
getValue: (s) => formatTokens(s.tokens.cacheRead + s.tokens.cacheWrite)
|
|
52
|
+
};
|
|
53
|
+
const FULL_DATA_COLUMNS = [
|
|
54
|
+
SESSIONS_COLUMN,
|
|
55
|
+
MSGS_COLUMN,
|
|
56
|
+
COST_COLUMN,
|
|
57
|
+
TOKENS_COLUMN,
|
|
58
|
+
INPUT_COLUMN,
|
|
59
|
+
OUTPUT_COLUMN,
|
|
60
|
+
CACHE_COLUMN
|
|
61
|
+
];
|
|
62
|
+
const TABLE_LAYOUTS = [
|
|
63
|
+
{ columns: FULL_DATA_COLUMNS, minNameWidth: MAX_NAME_COL_WIDTH },
|
|
64
|
+
{ columns: [SESSIONS_COLUMN, MSGS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 14, compact: true },
|
|
65
|
+
{ columns: [SESSIONS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 12, compact: true },
|
|
66
|
+
{ columns: [COST_COLUMN, TOKENS_COLUMN], minNameWidth: 10, compact: true },
|
|
67
|
+
{ columns: [COST_COLUMN], minNameWidth: 8, compact: true }
|
|
68
|
+
];
|
|
69
|
+
function getAgentDir() {
|
|
70
|
+
return process.env.DM_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || join(homedir(), ".dm", "agent");
|
|
71
|
+
}
|
|
72
|
+
function getSessionsDir() {
|
|
73
|
+
return join(getAgentDir(), "sessions");
|
|
74
|
+
}
|
|
75
|
+
function getUsageStateDir() {
|
|
76
|
+
return join(getAgentDir(), "usage");
|
|
77
|
+
}
|
|
78
|
+
function getUsageStatePath() {
|
|
79
|
+
return join(getUsageStateDir(), "state.json");
|
|
80
|
+
}
|
|
81
|
+
function getAgentSettingsPath() {
|
|
82
|
+
return join(getAgentDir(), "settings.json");
|
|
83
|
+
}
|
|
84
|
+
async function collectSessionFilesRecursively(dir, files, signal) {
|
|
85
|
+
try {
|
|
86
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (signal?.aborted)
|
|
89
|
+
return;
|
|
90
|
+
const entryPath = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
await collectSessionFilesRecursively(entryPath, files, signal);
|
|
93
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
94
|
+
files.push(entryPath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
async function getAllSessionFiles(signal) {
|
|
100
|
+
const files = [];
|
|
101
|
+
await collectSessionFilesRecursively(getSessionsDir(), files, signal);
|
|
102
|
+
files.sort();
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
async function parseSessionFile(filePath, seenHashes, signal) {
|
|
106
|
+
try {
|
|
107
|
+
const content = await readFile(filePath, "utf8");
|
|
108
|
+
if (signal?.aborted)
|
|
109
|
+
return null;
|
|
110
|
+
const lines = content.trim().split(`
|
|
111
|
+
`);
|
|
112
|
+
const messages = [];
|
|
113
|
+
let sessionId = "";
|
|
114
|
+
for (let i = 0;i < lines.length; i++) {
|
|
115
|
+
if (signal?.aborted)
|
|
116
|
+
return null;
|
|
117
|
+
if (i % 500 === 0) {
|
|
118
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
119
|
+
}
|
|
120
|
+
const line = lines[i];
|
|
121
|
+
if (!line.trim())
|
|
122
|
+
continue;
|
|
123
|
+
try {
|
|
124
|
+
const entry = JSON.parse(line);
|
|
125
|
+
if (entry.type === "session") {
|
|
126
|
+
sessionId = entry.id;
|
|
127
|
+
} else if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
128
|
+
const msg = entry.message;
|
|
129
|
+
if (msg.usage && msg.provider && msg.model) {
|
|
130
|
+
const input = msg.usage.input || 0;
|
|
131
|
+
const output = msg.usage.output || 0;
|
|
132
|
+
const cacheRead = msg.usage.cacheRead || 0;
|
|
133
|
+
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
134
|
+
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
135
|
+
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
136
|
+
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
137
|
+
const hash = `${timestamp}:${totalTokens}`;
|
|
138
|
+
if (seenHashes.has(hash))
|
|
139
|
+
continue;
|
|
140
|
+
seenHashes.add(hash);
|
|
141
|
+
messages.push({
|
|
142
|
+
provider: msg.provider,
|
|
143
|
+
model: msg.model,
|
|
144
|
+
cost: msg.usage.cost?.total || 0,
|
|
145
|
+
input,
|
|
146
|
+
output,
|
|
147
|
+
cacheRead,
|
|
148
|
+
cacheWrite,
|
|
149
|
+
timestamp
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
return sessionId ? { sessionId, messages } : null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function accumulateStats(target, cost, tokens) {
|
|
161
|
+
target.messages++;
|
|
162
|
+
target.cost += cost;
|
|
163
|
+
target.tokens.total += tokens.total;
|
|
164
|
+
target.tokens.input += tokens.input;
|
|
165
|
+
target.tokens.output += tokens.output;
|
|
166
|
+
target.tokens.cacheRead += tokens.cacheRead;
|
|
167
|
+
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
168
|
+
}
|
|
169
|
+
function emptyTokens() {
|
|
170
|
+
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
171
|
+
}
|
|
172
|
+
function emptyModelStats() {
|
|
173
|
+
return { sessions: new Set, messages: 0, cost: 0, tokens: emptyTokens() };
|
|
174
|
+
}
|
|
175
|
+
function emptyProviderStats() {
|
|
176
|
+
return { sessions: new Set, messages: 0, cost: 0, tokens: emptyTokens(), models: new Map };
|
|
177
|
+
}
|
|
178
|
+
function emptyTimeFilteredStats() {
|
|
179
|
+
return {
|
|
180
|
+
providers: new Map,
|
|
181
|
+
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
182
|
+
dailySpend: new Map,
|
|
183
|
+
insights: { insights: [] }
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function emptyPeriodRawData() {
|
|
187
|
+
return { messages: [], sessionCosts: new Map };
|
|
188
|
+
}
|
|
189
|
+
function emptyUsageData() {
|
|
190
|
+
return {
|
|
191
|
+
today: emptyTimeFilteredStats(),
|
|
192
|
+
thisWeek: emptyTimeFilteredStats(),
|
|
193
|
+
lastWeek: emptyTimeFilteredStats(),
|
|
194
|
+
allTime: emptyTimeFilteredStats()
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function getPeriodsForTimestamp(timestamp, todayMs, weekStartMs, lastWeekStartMs) {
|
|
198
|
+
const periods = ["allTime"];
|
|
199
|
+
if (timestamp >= todayMs)
|
|
200
|
+
periods.push("today");
|
|
201
|
+
if (timestamp >= weekStartMs) {
|
|
202
|
+
periods.push("thisWeek");
|
|
203
|
+
} else if (timestamp >= lastWeekStartMs) {
|
|
204
|
+
periods.push("lastWeek");
|
|
205
|
+
}
|
|
206
|
+
return periods;
|
|
207
|
+
}
|
|
208
|
+
function addMessagesToUsageData(data, sessionId, messages, todayMs, weekStartMs, lastWeekStartMs, rawByPeriod, globalSessionSpans) {
|
|
209
|
+
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
210
|
+
for (const msg of messages) {
|
|
211
|
+
if (msg.timestamp > 0) {
|
|
212
|
+
const span = globalSessionSpans.get(sessionId);
|
|
213
|
+
if (!span) {
|
|
214
|
+
globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
|
|
215
|
+
} else {
|
|
216
|
+
if (msg.timestamp < span.startMs)
|
|
217
|
+
span.startMs = msg.timestamp;
|
|
218
|
+
if (msg.timestamp > span.endMs)
|
|
219
|
+
span.endMs = msg.timestamp;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
223
|
+
const tokens = {
|
|
224
|
+
total: msg.input + msg.output + msg.cacheWrite,
|
|
225
|
+
input: msg.input,
|
|
226
|
+
output: msg.output,
|
|
227
|
+
cacheRead: msg.cacheRead,
|
|
228
|
+
cacheWrite: msg.cacheWrite
|
|
229
|
+
};
|
|
230
|
+
for (const period of periods) {
|
|
231
|
+
const stats = data[period];
|
|
232
|
+
let providerStats = stats.providers.get(msg.provider);
|
|
233
|
+
if (!providerStats) {
|
|
234
|
+
providerStats = emptyProviderStats();
|
|
235
|
+
stats.providers.set(msg.provider, providerStats);
|
|
236
|
+
}
|
|
237
|
+
let modelStats = providerStats.models.get(msg.model);
|
|
238
|
+
if (!modelStats) {
|
|
239
|
+
modelStats = emptyModelStats();
|
|
240
|
+
providerStats.models.set(msg.model, modelStats);
|
|
241
|
+
}
|
|
242
|
+
modelStats.sessions.add(sessionId);
|
|
243
|
+
accumulateStats(modelStats, msg.cost, tokens);
|
|
244
|
+
providerStats.sessions.add(sessionId);
|
|
245
|
+
accumulateStats(providerStats, msg.cost, tokens);
|
|
246
|
+
accumulateStats(stats.totals, msg.cost, tokens);
|
|
247
|
+
if (msg.cost > 0 && msg.timestamp > 0) {
|
|
248
|
+
const dayKey = new Date(msg.timestamp).toISOString().slice(0, 10);
|
|
249
|
+
stats.dailySpend.set(dayKey, (stats.dailySpend.get(dayKey) ?? 0) + msg.cost);
|
|
250
|
+
}
|
|
251
|
+
sessionContributed[period] = true;
|
|
252
|
+
const raw = rawByPeriod[period];
|
|
253
|
+
raw.messages.push({
|
|
254
|
+
sessionId,
|
|
255
|
+
timestamp: msg.timestamp,
|
|
256
|
+
cost: msg.cost,
|
|
257
|
+
input: msg.input,
|
|
258
|
+
cacheRead: msg.cacheRead,
|
|
259
|
+
cacheWrite: msg.cacheWrite
|
|
260
|
+
});
|
|
261
|
+
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (sessionContributed.today)
|
|
265
|
+
data.today.totals.sessions++;
|
|
266
|
+
if (sessionContributed.thisWeek)
|
|
267
|
+
data.thisWeek.totals.sessions++;
|
|
268
|
+
if (sessionContributed.lastWeek)
|
|
269
|
+
data.lastWeek.totals.sessions++;
|
|
270
|
+
if (sessionContributed.allTime)
|
|
271
|
+
data.allTime.totals.sessions++;
|
|
272
|
+
}
|
|
273
|
+
async function collectUsageData(signal) {
|
|
274
|
+
const startOfToday = new Date;
|
|
275
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
276
|
+
const todayMs = startOfToday.getTime();
|
|
277
|
+
const startOfWeek = new Date;
|
|
278
|
+
const dayOfWeek = startOfWeek.getDay();
|
|
279
|
+
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
280
|
+
startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday);
|
|
281
|
+
startOfWeek.setHours(0, 0, 0, 0);
|
|
282
|
+
const weekStartMs = startOfWeek.getTime();
|
|
283
|
+
const startOfLastWeek = new Date(startOfWeek);
|
|
284
|
+
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
285
|
+
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
286
|
+
const data = emptyUsageData();
|
|
287
|
+
const rawByPeriod = {
|
|
288
|
+
today: emptyPeriodRawData(),
|
|
289
|
+
thisWeek: emptyPeriodRawData(),
|
|
290
|
+
lastWeek: emptyPeriodRawData(),
|
|
291
|
+
allTime: emptyPeriodRawData()
|
|
292
|
+
};
|
|
293
|
+
const globalSessionSpans = new Map;
|
|
294
|
+
const sessionFiles = await getAllSessionFiles(signal);
|
|
295
|
+
if (signal?.aborted)
|
|
296
|
+
return null;
|
|
297
|
+
const seenHashes = new Set;
|
|
298
|
+
for (const filePath of sessionFiles) {
|
|
299
|
+
if (signal?.aborted)
|
|
300
|
+
return null;
|
|
301
|
+
const parsed = await parseSessionFile(filePath, seenHashes, signal);
|
|
302
|
+
if (signal?.aborted)
|
|
303
|
+
return null;
|
|
304
|
+
if (!parsed)
|
|
305
|
+
continue;
|
|
306
|
+
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs, lastWeekStartMs, rawByPeriod, globalSessionSpans);
|
|
307
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
308
|
+
}
|
|
309
|
+
const longSessionIds = new Set;
|
|
310
|
+
for (const [id, span] of globalSessionSpans) {
|
|
311
|
+
if (span.endMs - span.startMs >= LONG_SESSION_MS)
|
|
312
|
+
longSessionIds.add(id);
|
|
313
|
+
}
|
|
314
|
+
for (const period of TAB_ORDER) {
|
|
315
|
+
data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
|
|
316
|
+
}
|
|
317
|
+
return data;
|
|
318
|
+
}
|
|
319
|
+
const USAGE_SUBCOMMANDS = ["use", "show", "rotation", "verify", "path", "reset", "help"];
|
|
320
|
+
const USAGE_RESET_TARGETS = ["manual", "quota", "all"];
|
|
321
|
+
const USAGE_HELP_TEXT = [
|
|
322
|
+
"Usage: /usage [use|show|rotation|verify|path|reset|help]",
|
|
323
|
+
"use: select, activate, or remove managed account",
|
|
324
|
+
"show: managed account and usage summary",
|
|
325
|
+
"rotation: current rotation behavior",
|
|
326
|
+
"verify: runtime health checks",
|
|
327
|
+
"path: storage and settings locations",
|
|
328
|
+
"reset: clear manual or quota state"
|
|
329
|
+
].join(`
|
|
330
|
+
`);
|
|
331
|
+
function parseUsageCommandArgs(args) {
|
|
332
|
+
const trimmed = args.trim();
|
|
333
|
+
if (!trimmed)
|
|
334
|
+
return { rest: "" };
|
|
335
|
+
const firstSpaceIndex = trimmed.indexOf(" ");
|
|
336
|
+
if (firstSpaceIndex < 0)
|
|
337
|
+
return { subcommand: trimmed.toLowerCase(), rest: "" };
|
|
338
|
+
return {
|
|
339
|
+
subcommand: trimmed.slice(0, firstSpaceIndex).toLowerCase(),
|
|
340
|
+
rest: trimmed.slice(firstSpaceIndex + 1).trim()
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function isUsageSubcommand(value) {
|
|
344
|
+
return USAGE_SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
345
|
+
}
|
|
346
|
+
function parseUsageResetTarget(value) {
|
|
347
|
+
if (value === "manual" || value === "quota" || value === "all")
|
|
348
|
+
return value;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
function toAutocompleteItems(values) {
|
|
352
|
+
return values.map((value) => ({ value, label: value }));
|
|
353
|
+
}
|
|
354
|
+
function getUsageCommandCompletions(argumentPrefix) {
|
|
355
|
+
const trimmedStart = argumentPrefix.trimStart();
|
|
356
|
+
if (!trimmedStart)
|
|
357
|
+
return toAutocompleteItems(USAGE_SUBCOMMANDS);
|
|
358
|
+
const firstSpaceIndex = trimmedStart.indexOf(" ");
|
|
359
|
+
if (firstSpaceIndex < 0) {
|
|
360
|
+
const matches = USAGE_SUBCOMMANDS.filter((value) => value.startsWith(trimmedStart.toLowerCase()));
|
|
361
|
+
return matches.length > 0 ? toAutocompleteItems(matches) : null;
|
|
362
|
+
}
|
|
363
|
+
const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
|
|
364
|
+
const rest = trimmedStart.slice(firstSpaceIndex + 1).toLowerCase();
|
|
365
|
+
if (subcommand === "reset") {
|
|
366
|
+
const matches = USAGE_RESET_TARGETS.filter((value) => value.startsWith(rest));
|
|
367
|
+
return matches.length > 0 ? matches.map((value) => ({ value: `reset ${value}`, label: value })) : null;
|
|
368
|
+
}
|
|
369
|
+
if (subcommand === "use") {
|
|
370
|
+
const matches = ["clear", "remove"].filter((value) => value.startsWith(rest));
|
|
371
|
+
return matches.length > 0 ? matches.map((value) => ({ value: `use ${value}`, label: value })) : null;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
function emptyUsageManagedState() {
|
|
376
|
+
return { version: 1, removedAccountIds: [], accounts: [], updatedAt: new Date(0).toISOString() };
|
|
377
|
+
}
|
|
378
|
+
function asString(value) {
|
|
379
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
380
|
+
}
|
|
381
|
+
function normalizeUsageManagedAccount(value) {
|
|
382
|
+
if (!value || typeof value !== "object")
|
|
383
|
+
return;
|
|
384
|
+
const record = value;
|
|
385
|
+
const id = asString(record.id);
|
|
386
|
+
const provider = asString(record.provider) ?? id;
|
|
387
|
+
if (!id || !provider)
|
|
388
|
+
return;
|
|
389
|
+
const label = asString(record.label) ?? formatProviderName(provider);
|
|
390
|
+
const source = record.source === "manual" ? "manual" : "session";
|
|
391
|
+
const updatedAt = asString(record.updatedAt) ?? new Date(0).toISOString();
|
|
392
|
+
const quota = typeof record.quotaExhaustedUntil === "number" && Number.isFinite(record.quotaExhaustedUntil) ? record.quotaExhaustedUntil : undefined;
|
|
393
|
+
return { id, label, provider, source, updatedAt, quotaExhaustedUntil: quota };
|
|
394
|
+
}
|
|
395
|
+
function normalizeUsageManagedState(value) {
|
|
396
|
+
const empty = emptyUsageManagedState();
|
|
397
|
+
if (!value || typeof value !== "object")
|
|
398
|
+
return empty;
|
|
399
|
+
const record = value;
|
|
400
|
+
const accounts = Array.isArray(record.accounts) ? record.accounts.map(normalizeUsageManagedAccount).filter((account) => Boolean(account)) : [];
|
|
401
|
+
const removedAccountIds = Array.isArray(record.removedAccountIds) ? record.removedAccountIds.map(asString).filter((id) => Boolean(id)) : [];
|
|
402
|
+
return {
|
|
403
|
+
version: 1,
|
|
404
|
+
manualAccountId: asString(record.manualAccountId),
|
|
405
|
+
removedAccountIds,
|
|
406
|
+
accounts,
|
|
407
|
+
updatedAt: asString(record.updatedAt) ?? empty.updatedAt
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function getErrorCode(error) {
|
|
411
|
+
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
|
412
|
+
}
|
|
413
|
+
async function readUsageManagedState() {
|
|
414
|
+
try {
|
|
415
|
+
const raw = await readFile(getUsageStatePath(), "utf8");
|
|
416
|
+
return { state: normalizeUsageManagedState(JSON.parse(raw)) };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (getErrorCode(error) === "ENOENT")
|
|
419
|
+
return { state: emptyUsageManagedState() };
|
|
420
|
+
return { state: emptyUsageManagedState(), error: error instanceof Error ? error.message : String(error) };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function saveUsageManagedState(state) {
|
|
424
|
+
state.version = 1;
|
|
425
|
+
state.updatedAt = new Date().toISOString();
|
|
426
|
+
state.removedAccountIds = [...new Set(state.removedAccountIds)].sort();
|
|
427
|
+
state.accounts = state.accounts.filter((account) => !state.removedAccountIds.includes(account.id)).sort((a, b) => a.label.localeCompare(b.label));
|
|
428
|
+
await mkdir(dirname(getUsageStatePath()), { recursive: true });
|
|
429
|
+
await writeFile(getUsageStatePath(), `${JSON.stringify(state, null, 2)}
|
|
430
|
+
`, "utf8");
|
|
431
|
+
}
|
|
432
|
+
function isQuotaActive(account) {
|
|
433
|
+
return typeof account.quotaExhaustedUntil === "number" && account.quotaExhaustedUntil > Date.now();
|
|
434
|
+
}
|
|
435
|
+
function getAccountStats(data, account) {
|
|
436
|
+
return data.allTime.providers.get(account.provider);
|
|
437
|
+
}
|
|
438
|
+
function getManagedAccounts(data, state) {
|
|
439
|
+
const now = new Date().toISOString();
|
|
440
|
+
const accounts = new Map;
|
|
441
|
+
for (const account of state.accounts) {
|
|
442
|
+
if (!state.removedAccountIds.includes(account.id))
|
|
443
|
+
accounts.set(account.id, account);
|
|
444
|
+
}
|
|
445
|
+
for (const provider of data.allTime.providers.keys()) {
|
|
446
|
+
if (state.removedAccountIds.includes(provider) || accounts.has(provider))
|
|
447
|
+
continue;
|
|
448
|
+
accounts.set(provider, {
|
|
449
|
+
id: provider,
|
|
450
|
+
label: formatProviderName(provider),
|
|
451
|
+
provider,
|
|
452
|
+
source: "session",
|
|
453
|
+
updatedAt: now
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return Array.from(accounts.values()).sort((a, b) => {
|
|
457
|
+
const manualId = state.manualAccountId;
|
|
458
|
+
if (a.id === manualId && b.id !== manualId)
|
|
459
|
+
return -1;
|
|
460
|
+
if (b.id === manualId && a.id !== manualId)
|
|
461
|
+
return 1;
|
|
462
|
+
if (isQuotaActive(a) !== isQuotaActive(b))
|
|
463
|
+
return isQuotaActive(a) ? 1 : -1;
|
|
464
|
+
const costDelta = (getAccountStats(data, b)?.cost ?? 0) - (getAccountStats(data, a)?.cost ?? 0);
|
|
465
|
+
if (costDelta !== 0)
|
|
466
|
+
return costDelta;
|
|
467
|
+
return a.label.localeCompare(b.label);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function getActiveManagedAccount(accounts, state) {
|
|
471
|
+
const manual = accounts.find((account) => account.id === state.manualAccountId && !isQuotaActive(account));
|
|
472
|
+
return manual ?? accounts.find((account) => !isQuotaActive(account));
|
|
473
|
+
}
|
|
474
|
+
function findManagedAccount(accounts, query) {
|
|
475
|
+
const normalized = query.trim().toLowerCase();
|
|
476
|
+
return accounts.find((account) => account.id.toLowerCase() === normalized || account.provider.toLowerCase() === normalized || account.label.toLowerCase() === normalized);
|
|
477
|
+
}
|
|
478
|
+
function formatManagedAccountLine(account, data, state) {
|
|
479
|
+
const stats = getAccountStats(data, account);
|
|
480
|
+
const tags = [
|
|
481
|
+
state.manualAccountId === account.id ? "manual" : undefined,
|
|
482
|
+
isQuotaActive(account) ? "quota" : undefined,
|
|
483
|
+
account.source === "manual" ? "manual-entry" : undefined
|
|
484
|
+
].filter(Boolean).join(", ");
|
|
485
|
+
const suffix = tags ? ` (${tags})` : "";
|
|
486
|
+
const cost = stats ? formatCost(stats.cost) : formatCost(0);
|
|
487
|
+
const messages = stats ? formatNumber(stats.messages) : "0";
|
|
488
|
+
const sessions = stats ? formatNumber(stats.sessions.size) : "0";
|
|
489
|
+
return `${account.label}${suffix} · id=${account.id} · spend=${cost} · messages=${messages} · sessions=${sessions}`;
|
|
490
|
+
}
|
|
491
|
+
async function loadUsageManagedSummary(ctx) {
|
|
492
|
+
const data = await collectUsageData(ctx.signal);
|
|
493
|
+
if (!data) {
|
|
494
|
+
ctx.ui.notify("Usage data unavailable or cancelled.", "warning");
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const { state, error } = await readUsageManagedState();
|
|
498
|
+
if (error) {
|
|
499
|
+
ctx.ui.notify(`dm-usage state unreadable; using empty state: ${error}`, "warning");
|
|
500
|
+
}
|
|
501
|
+
return { data, state, accounts: getManagedAccounts(data, state) };
|
|
502
|
+
}
|
|
503
|
+
function managedAccountNoDataMessage() {
|
|
504
|
+
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.";
|
|
505
|
+
}
|
|
506
|
+
async function activateUsageAccount(ctx, summary, account) {
|
|
507
|
+
summary.state.manualAccountId = account.id;
|
|
508
|
+
summary.state.removedAccountIds = summary.state.removedAccountIds.filter((id) => id !== account.id);
|
|
509
|
+
summary.state.accounts = summary.accounts.map((item) => item.id === account.id ? { ...item, updatedAt: new Date().toISOString() } : item);
|
|
510
|
+
await saveUsageManagedState(summary.state);
|
|
511
|
+
ctx.ui.notify(`dm-usage: now using ${account.label} (${account.id})`, "info");
|
|
512
|
+
}
|
|
513
|
+
async function clearUsageManualAccount(ctx, state) {
|
|
514
|
+
const hadManual = Boolean(state.manualAccountId);
|
|
515
|
+
state.manualAccountId = undefined;
|
|
516
|
+
await saveUsageManagedState(state);
|
|
517
|
+
ctx.ui.notify(`dm-usage: manual account ${hadManual ? "cleared" : "was not set"}`, "info");
|
|
518
|
+
}
|
|
519
|
+
async function removeUsageAccountState(ctx, summary, account) {
|
|
520
|
+
summary.state.removedAccountIds = [...summary.state.removedAccountIds, account.id];
|
|
521
|
+
if (summary.state.manualAccountId === account.id)
|
|
522
|
+
summary.state.manualAccountId = undefined;
|
|
523
|
+
summary.state.accounts = summary.accounts.filter((item) => item.id !== account.id);
|
|
524
|
+
await saveUsageManagedState(summary.state);
|
|
525
|
+
ctx.ui.notify(`dm-usage: removed local managed-account state for ${account.label}; session data was not changed.`, "info");
|
|
526
|
+
}
|
|
527
|
+
async function runUsageUseSubcommand(ctx, rest) {
|
|
528
|
+
const summary = await loadUsageManagedSummary(ctx);
|
|
529
|
+
if (!summary)
|
|
530
|
+
return;
|
|
531
|
+
if (rest === "clear") {
|
|
532
|
+
await clearUsageManualAccount(ctx, summary.state);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (rest.startsWith("remove ")) {
|
|
536
|
+
const account = findManagedAccount(summary.accounts, rest.slice("remove ".length));
|
|
537
|
+
if (!account) {
|
|
538
|
+
ctx.ui.notify(`Unknown managed account: ${rest.slice("remove ".length)}`, "warning");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
await removeUsageAccountState(ctx, summary, account);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (rest) {
|
|
545
|
+
const account = findManagedAccount(summary.accounts, rest) ?? { id: rest, label: formatProviderName(rest), provider: rest, source: "manual", updatedAt: new Date().toISOString() };
|
|
546
|
+
if (!summary.accounts.some((item) => item.id === account.id))
|
|
547
|
+
summary.accounts.push(account);
|
|
548
|
+
await activateUsageAccount(ctx, summary, account);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (!ctx.hasUI) {
|
|
552
|
+
ctx.ui.notify(summary.accounts.length ? summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state)).join(`
|
|
553
|
+
`) : managedAccountNoDataMessage(), summary.accounts.length ? "info" : "warning");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const options = [
|
|
557
|
+
...summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state)),
|
|
558
|
+
"add manual account",
|
|
559
|
+
"clear manual account"
|
|
560
|
+
];
|
|
561
|
+
const selected = await ctx.ui.select("DuckMind Usage Accounts", options);
|
|
562
|
+
if (!selected)
|
|
563
|
+
return;
|
|
564
|
+
if (selected === "add manual account") {
|
|
565
|
+
const id = (await ctx.ui.input("Managed account id (provider key, no secret)"))?.trim();
|
|
566
|
+
if (!id)
|
|
567
|
+
return;
|
|
568
|
+
await runUsageUseSubcommand(ctx, id);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (selected === "clear manual account") {
|
|
572
|
+
await clearUsageManualAccount(ctx, summary.state);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const account = summary.accounts[options.indexOf(selected)];
|
|
576
|
+
if (!account)
|
|
577
|
+
return;
|
|
578
|
+
const action = await ctx.ui.select("Account Action", ["activate", "remove local state", "cancel"]);
|
|
579
|
+
if (action === "activate")
|
|
580
|
+
await activateUsageAccount(ctx, summary, account);
|
|
581
|
+
if (action === "remove local state")
|
|
582
|
+
await removeUsageAccountState(ctx, summary, account);
|
|
583
|
+
}
|
|
584
|
+
async function runUsageShowSubcommand(ctx) {
|
|
585
|
+
const summary = await loadUsageManagedSummary(ctx);
|
|
586
|
+
if (!summary)
|
|
587
|
+
return;
|
|
588
|
+
const active = getActiveManagedAccount(summary.accounts, summary.state);
|
|
589
|
+
const hiddenCount = summary.state.removedAccountIds.length;
|
|
590
|
+
const lines = [
|
|
591
|
+
"DuckMind Usage managed account summary",
|
|
592
|
+
`active: ${active ? `${active.label} (${active.id})` : "none"}`,
|
|
593
|
+
`manual: ${summary.state.manualAccountId ?? "none"}`,
|
|
594
|
+
`accounts: ${summary.accounts.length}${hiddenCount ? ` · hidden=${hiddenCount}` : ""}`,
|
|
595
|
+
`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)}`,
|
|
596
|
+
"",
|
|
597
|
+
...summary.accounts.length ? summary.accounts.map((account) => formatManagedAccountLine(account, summary.data, summary.state)) : [managedAccountNoDataMessage()]
|
|
598
|
+
];
|
|
599
|
+
if (ctx.hasUI) {
|
|
600
|
+
await ctx.ui.select("DuckMind Usage Summary", lines);
|
|
601
|
+
} else {
|
|
602
|
+
ctx.ui.notify(lines.join(`
|
|
603
|
+
`), "info");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async function runUsageRotationSubcommand(ctx) {
|
|
607
|
+
const lines = [
|
|
608
|
+
"Current policy: manual account first, then accounts without quota cooldown, then highest all-time local spend, then alphabetical fallback.",
|
|
609
|
+
"dm-usage is observational: this state changes /usage account summaries only and does not route provider requests or store API secrets.",
|
|
610
|
+
"Use /usage reset manual to clear manual selection, or /usage reset quota to clear quota cooldown markers."
|
|
611
|
+
];
|
|
612
|
+
if (ctx.hasUI) {
|
|
613
|
+
await ctx.ui.select("DuckMind Usage Rotation", lines);
|
|
614
|
+
} else {
|
|
615
|
+
ctx.ui.notify(lines.join(" "), "info");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async function isDirectoryReadable(path) {
|
|
619
|
+
try {
|
|
620
|
+
await access(path, fsConstants.R_OK);
|
|
621
|
+
return true;
|
|
622
|
+
} catch {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async function isWritableDirectoryFor(filePath) {
|
|
627
|
+
try {
|
|
628
|
+
const directory = dirname(filePath);
|
|
629
|
+
await mkdir(directory, { recursive: true });
|
|
630
|
+
await access(directory, fsConstants.R_OK | fsConstants.W_OK);
|
|
631
|
+
return true;
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function runUsageVerifySubcommand(ctx) {
|
|
637
|
+
const sessionsReadable = await isDirectoryReadable(getSessionsDir());
|
|
638
|
+
const stateWritable = await isWritableDirectoryFor(getUsageStatePath());
|
|
639
|
+
const settingsWritable = await isWritableDirectoryFor(getAgentSettingsPath());
|
|
640
|
+
const stateRead = await readUsageManagedState();
|
|
641
|
+
const summary = await loadUsageManagedSummary(ctx);
|
|
642
|
+
const accounts = summary?.accounts.length ?? 0;
|
|
643
|
+
const active = summary ? getActiveManagedAccount(summary.accounts, summary.state)?.id ?? "none" : "none";
|
|
644
|
+
const ok = sessionsReadable && stateWritable && settingsWritable && !stateRead.error;
|
|
645
|
+
const lines = [
|
|
646
|
+
`verify: ${ok ? "PASS" : "WARN"}`,
|
|
647
|
+
`sessions readable: ${sessionsReadable ? "yes" : "no"}`,
|
|
648
|
+
`state directory writable: ${stateWritable ? "yes" : "no"}`,
|
|
649
|
+
`settings directory writable: ${settingsWritable ? "yes" : "no"}`,
|
|
650
|
+
`state json: ${stateRead.error ? `invalid (${stateRead.error})` : "ok"}`,
|
|
651
|
+
`managed accounts: ${accounts}`,
|
|
652
|
+
`active account: ${active}`
|
|
653
|
+
];
|
|
654
|
+
if (ctx.hasUI) {
|
|
655
|
+
await ctx.ui.select(`DuckMind Usage Verify (${ok ? "PASS" : "WARN"})`, lines);
|
|
656
|
+
} else {
|
|
657
|
+
ctx.ui.notify(lines.join(`
|
|
658
|
+
`), ok ? "info" : "warning");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
async function runUsagePathSubcommand(ctx) {
|
|
662
|
+
const lines = [
|
|
663
|
+
`Session storage: ${getSessionsDir()}`,
|
|
664
|
+
`Managed account state: ${getUsageStatePath()}`,
|
|
665
|
+
`Agent settings: ${getAgentSettingsPath()}`
|
|
666
|
+
];
|
|
667
|
+
if (ctx.hasUI) {
|
|
668
|
+
await ctx.ui.select("DuckMind Usage Paths", lines);
|
|
669
|
+
} else {
|
|
670
|
+
ctx.ui.notify(lines.join(`
|
|
671
|
+
`), "info");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async function chooseUsageResetTarget(ctx, argument) {
|
|
675
|
+
const explicitTarget = parseUsageResetTarget(argument.toLowerCase());
|
|
676
|
+
if (explicitTarget)
|
|
677
|
+
return explicitTarget;
|
|
678
|
+
if (argument) {
|
|
679
|
+
ctx.ui.notify("Unknown reset target. Use: /usage reset [manual|quota|all]", "warning");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!ctx.hasUI)
|
|
683
|
+
return "all";
|
|
684
|
+
const selected = await ctx.ui.select("Reset DuckMind Usage State", [
|
|
685
|
+
"manual - clear manual account override",
|
|
686
|
+
"quota - clear quota cooldown markers",
|
|
687
|
+
"all - clear manual override, quota cooldown markers, and hidden account state"
|
|
688
|
+
]);
|
|
689
|
+
if (!selected)
|
|
690
|
+
return;
|
|
691
|
+
if (selected.startsWith("manual"))
|
|
692
|
+
return "manual";
|
|
693
|
+
if (selected.startsWith("quota"))
|
|
694
|
+
return "quota";
|
|
695
|
+
return "all";
|
|
696
|
+
}
|
|
697
|
+
async function runUsageResetSubcommand(ctx, rest) {
|
|
698
|
+
const target = await chooseUsageResetTarget(ctx, rest);
|
|
699
|
+
if (!target)
|
|
700
|
+
return;
|
|
701
|
+
const { state } = await readUsageManagedState();
|
|
702
|
+
const hadManual = Boolean(state.manualAccountId);
|
|
703
|
+
if (target === "manual" || target === "all")
|
|
704
|
+
state.manualAccountId = undefined;
|
|
705
|
+
let quotaCleared = 0;
|
|
706
|
+
if (target === "quota" || target === "all") {
|
|
707
|
+
state.accounts = state.accounts.map((account) => {
|
|
708
|
+
if (account.quotaExhaustedUntil === undefined)
|
|
709
|
+
return account;
|
|
710
|
+
quotaCleared++;
|
|
711
|
+
const { quotaExhaustedUntil: _quotaExhaustedUntil, ...restAccount } = account;
|
|
712
|
+
return restAccount;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
const hiddenCleared = target === "all" ? state.removedAccountIds.length : 0;
|
|
716
|
+
if (target === "all")
|
|
717
|
+
state.removedAccountIds = [];
|
|
718
|
+
await saveUsageManagedState(state);
|
|
719
|
+
ctx.ui.notify(`reset: target=${target} manualCleared=${hadManual && !state.manualAccountId ? "yes" : "no"} quotaCleared=${quotaCleared} hiddenCleared=${hiddenCleared}`, "info");
|
|
720
|
+
}
|
|
721
|
+
async function runUsageSubcommand(subcommand, rest, ctx) {
|
|
722
|
+
if (subcommand === "use")
|
|
723
|
+
return runUsageUseSubcommand(ctx, rest);
|
|
724
|
+
if (subcommand === "show")
|
|
725
|
+
return runUsageShowSubcommand(ctx);
|
|
726
|
+
if (subcommand === "rotation")
|
|
727
|
+
return runUsageRotationSubcommand(ctx);
|
|
728
|
+
if (subcommand === "verify")
|
|
729
|
+
return runUsageVerifySubcommand(ctx);
|
|
730
|
+
if (subcommand === "path")
|
|
731
|
+
return runUsagePathSubcommand(ctx);
|
|
732
|
+
if (subcommand === "reset")
|
|
733
|
+
return runUsageResetSubcommand(ctx, rest);
|
|
734
|
+
ctx.ui.notify(USAGE_HELP_TEXT, "info");
|
|
735
|
+
}
|
|
736
|
+
const PARALLEL_WINDOW_MS = 2 * 60000;
|
|
737
|
+
const PARALLEL_SESSION_THRESHOLD = 4;
|
|
738
|
+
const LARGE_CONTEXT_THRESHOLD = 150000;
|
|
739
|
+
const LARGE_CACHE_MISS_THRESHOLD = 1e5;
|
|
740
|
+
const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
|
|
741
|
+
const TOP_SESSION_COUNT = 5;
|
|
742
|
+
const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
|
|
743
|
+
const MIN_PERCENT_TO_SHOW = 1;
|
|
744
|
+
function computeInsights(raw, longSessionIds) {
|
|
745
|
+
if (raw.messages.length === 0) {
|
|
746
|
+
return { insights: [] };
|
|
747
|
+
}
|
|
748
|
+
const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
|
|
749
|
+
if (total <= 0) {
|
|
750
|
+
return { insights: [] };
|
|
751
|
+
}
|
|
752
|
+
const candidates = [];
|
|
753
|
+
const parallelWeight = computeParallelCostWeight(raw.messages);
|
|
754
|
+
if (parallelWeight !== null) {
|
|
755
|
+
candidates.push({
|
|
756
|
+
percent: parallelWeight / total * 100,
|
|
757
|
+
headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
|
|
758
|
+
advice: "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly."
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
const largeContextWeight = raw.messages.filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD).reduce((sum, m) => sum + m.cost, 0);
|
|
762
|
+
candidates.push({
|
|
763
|
+
percent: largeContextWeight / total * 100,
|
|
764
|
+
headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
|
|
765
|
+
advice: "Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks."
|
|
766
|
+
});
|
|
767
|
+
const uncachedWeight = raw.messages.filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD).reduce((sum, m) => sum + m.cost, 0);
|
|
768
|
+
candidates.push({
|
|
769
|
+
percent: uncachedWeight / total * 100,
|
|
770
|
+
headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
|
|
771
|
+
advice: "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."
|
|
772
|
+
});
|
|
773
|
+
const longWeight = raw.messages.filter((m) => longSessionIds.has(m.sessionId)).reduce((sum, m) => sum + m.cost, 0);
|
|
774
|
+
if (longWeight > 0) {
|
|
775
|
+
candidates.push({
|
|
776
|
+
percent: longWeight / total * 100,
|
|
777
|
+
headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3600000}+ hours`,
|
|
778
|
+
advice: "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional."
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
|
|
782
|
+
const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
|
|
783
|
+
const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
|
|
784
|
+
const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
|
|
785
|
+
candidates.push({
|
|
786
|
+
percent: topWeight / total * 100,
|
|
787
|
+
headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
|
|
788
|
+
advice: "A small number of sessions drives most of your spend. The table view can help pinpoint which ones."
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
|
|
792
|
+
return { insights };
|
|
793
|
+
}
|
|
794
|
+
function computeParallelCostWeight(messages) {
|
|
795
|
+
const timed = messages.filter((m) => m.timestamp > 0);
|
|
796
|
+
if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT)
|
|
797
|
+
return null;
|
|
798
|
+
const distinctSessions = new Set(timed.map((m) => m.sessionId));
|
|
799
|
+
if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD)
|
|
800
|
+
return null;
|
|
801
|
+
const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
|
|
802
|
+
const sidCount = new Map;
|
|
803
|
+
let uniqueCount = 0;
|
|
804
|
+
let left = 0;
|
|
805
|
+
let right = 0;
|
|
806
|
+
let parallelCost = 0;
|
|
807
|
+
for (let i = 0;i < sorted.length; i++) {
|
|
808
|
+
const current = sorted[i];
|
|
809
|
+
const high = current.timestamp + PARALLEL_WINDOW_MS;
|
|
810
|
+
const low = current.timestamp - PARALLEL_WINDOW_MS;
|
|
811
|
+
while (right < sorted.length && sorted[right].timestamp <= high) {
|
|
812
|
+
const sid = sorted[right].sessionId;
|
|
813
|
+
const next = (sidCount.get(sid) ?? 0) + 1;
|
|
814
|
+
sidCount.set(sid, next);
|
|
815
|
+
if (next === 1)
|
|
816
|
+
uniqueCount++;
|
|
817
|
+
right++;
|
|
818
|
+
}
|
|
819
|
+
while (left < right && sorted[left].timestamp < low) {
|
|
820
|
+
const sid = sorted[left].sessionId;
|
|
821
|
+
const next = (sidCount.get(sid) ?? 0) - 1;
|
|
822
|
+
if (next === 0) {
|
|
823
|
+
sidCount.delete(sid);
|
|
824
|
+
uniqueCount--;
|
|
825
|
+
} else {
|
|
826
|
+
sidCount.set(sid, next);
|
|
827
|
+
}
|
|
828
|
+
left++;
|
|
829
|
+
}
|
|
830
|
+
if (uniqueCount >= PARALLEL_SESSION_THRESHOLD)
|
|
831
|
+
parallelCost += current.cost;
|
|
832
|
+
}
|
|
833
|
+
return parallelCost;
|
|
834
|
+
}
|
|
835
|
+
function formatThresholdTokens(n) {
|
|
836
|
+
if (n >= 1e6)
|
|
837
|
+
return `${n / 1e6}M`;
|
|
838
|
+
if (n >= 1000)
|
|
839
|
+
return `${n / 1000}k`;
|
|
840
|
+
return String(n);
|
|
841
|
+
}
|
|
842
|
+
function formatInsightPercent(p) {
|
|
843
|
+
if (p >= 10)
|
|
844
|
+
return `${Math.round(p)}%`;
|
|
845
|
+
return `${Math.round(p * 10) / 10}%`;
|
|
846
|
+
}
|
|
847
|
+
function formatCost(cost) {
|
|
848
|
+
if (cost === 0)
|
|
849
|
+
return "-";
|
|
850
|
+
if (cost < 0.01)
|
|
851
|
+
return `$${cost.toFixed(4)}`;
|
|
852
|
+
if (cost < 1)
|
|
853
|
+
return `$${cost.toFixed(2)}`;
|
|
854
|
+
if (cost < 10)
|
|
855
|
+
return `$${cost.toFixed(2)}`;
|
|
856
|
+
if (cost < 100)
|
|
857
|
+
return `$${cost.toFixed(1)}`;
|
|
858
|
+
return `$${Math.round(cost)}`;
|
|
859
|
+
}
|
|
860
|
+
function formatTokens(count) {
|
|
861
|
+
if (count === 0)
|
|
862
|
+
return "-";
|
|
863
|
+
if (count < 1000)
|
|
864
|
+
return count.toString();
|
|
865
|
+
if (count < 1e4)
|
|
866
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
867
|
+
if (count < 1e6)
|
|
868
|
+
return `${Math.round(count / 1000)}k`;
|
|
869
|
+
if (count < 1e7)
|
|
870
|
+
return `${(count / 1e6).toFixed(1)}M`;
|
|
871
|
+
return `${Math.round(count / 1e6)}M`;
|
|
872
|
+
}
|
|
873
|
+
function formatNumber(n) {
|
|
874
|
+
if (n === 0)
|
|
875
|
+
return "-";
|
|
876
|
+
return n.toLocaleString();
|
|
877
|
+
}
|
|
878
|
+
function padLeft(s, len) {
|
|
879
|
+
const vis = visibleWidth(s);
|
|
880
|
+
if (vis >= len)
|
|
881
|
+
return s;
|
|
882
|
+
return " ".repeat(len - vis) + s;
|
|
883
|
+
}
|
|
884
|
+
function padRight(s, len) {
|
|
885
|
+
const vis = visibleWidth(s);
|
|
886
|
+
if (vis >= len)
|
|
887
|
+
return s;
|
|
888
|
+
return s + " ".repeat(len - vis);
|
|
889
|
+
}
|
|
890
|
+
function sumColumnWidths(columns) {
|
|
891
|
+
return columns.reduce((sum, col) => sum + col.width, 0);
|
|
892
|
+
}
|
|
893
|
+
function fitCell(s, len, align = "left") {
|
|
894
|
+
if (len <= 0)
|
|
895
|
+
return "";
|
|
896
|
+
const truncated = truncateToWidth(s, len);
|
|
897
|
+
return align === "right" ? padLeft(truncated, len) : padRight(truncated, len);
|
|
898
|
+
}
|
|
899
|
+
function clampLines(lines, width) {
|
|
900
|
+
return lines.map((line) => truncateToWidth(line, Math.max(width, 0)));
|
|
901
|
+
}
|
|
902
|
+
function pickFittingText(width, variants) {
|
|
903
|
+
for (const variant of variants) {
|
|
904
|
+
if (visibleWidth(variant) <= width)
|
|
905
|
+
return variant;
|
|
906
|
+
}
|
|
907
|
+
return variants[variants.length - 1] || "";
|
|
908
|
+
}
|
|
909
|
+
function getTableLayout(width) {
|
|
910
|
+
const safeWidth = Math.max(width, 0);
|
|
911
|
+
for (const candidate of TABLE_LAYOUTS) {
|
|
912
|
+
const columnsWidth = sumColumnWidths(candidate.columns);
|
|
913
|
+
const nameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - columnsWidth, 0));
|
|
914
|
+
if (nameWidth >= candidate.minNameWidth) {
|
|
915
|
+
return {
|
|
916
|
+
columns: candidate.columns,
|
|
917
|
+
nameWidth,
|
|
918
|
+
tableWidth: nameWidth + columnsWidth,
|
|
919
|
+
compact: candidate.compact ?? false
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const fallback = TABLE_LAYOUTS[TABLE_LAYOUTS.length - 1];
|
|
924
|
+
const fallbackColumnsWidth = sumColumnWidths(fallback.columns);
|
|
925
|
+
const fallbackNameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - fallbackColumnsWidth, 0));
|
|
926
|
+
return {
|
|
927
|
+
columns: fallback.columns,
|
|
928
|
+
nameWidth: fallbackNameWidth,
|
|
929
|
+
tableWidth: fallbackNameWidth + fallbackColumnsWidth,
|
|
930
|
+
compact: fallback.compact ?? false
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function renderPanel(title, rows, width, theme) {
|
|
934
|
+
const safeWidth = Math.max(Math.floor(width), 0);
|
|
935
|
+
if (safeWidth < 12) {
|
|
936
|
+
return clampLines([theme.fg("accent", theme.bold(title)), ...rows, ""], safeWidth);
|
|
937
|
+
}
|
|
938
|
+
const innerWidth = Math.max(safeWidth - 2, 1);
|
|
939
|
+
const titleText = ` ${title} `;
|
|
940
|
+
const visibleTitle = truncateToWidth(titleText, Math.max(innerWidth - 1, 1), "");
|
|
941
|
+
const titleWidth = visibleWidth(visibleTitle);
|
|
942
|
+
const topRight = Math.max(innerWidth - titleWidth, 0);
|
|
943
|
+
const top = `╭${visibleTitle}${"─".repeat(topRight)}╮`;
|
|
944
|
+
const bottom = `╰${"─".repeat(innerWidth)}╯`;
|
|
945
|
+
const body = rows.length > 0 ? rows : [theme.fg("dim", "No data")];
|
|
946
|
+
return [
|
|
947
|
+
theme.fg("border", top),
|
|
948
|
+
...body.map((line) => panelRow(line, innerWidth, theme)),
|
|
949
|
+
theme.fg("border", bottom),
|
|
950
|
+
""
|
|
951
|
+
];
|
|
952
|
+
}
|
|
953
|
+
function panelRow(content, innerWidth, theme) {
|
|
954
|
+
const fitted = truncateToWidth(content, innerWidth);
|
|
955
|
+
const padding = Math.max(innerWidth - visibleWidth(fitted), 0);
|
|
956
|
+
return `${theme.fg("border", "│")}${fitted}${" ".repeat(padding)}${theme.fg("border", "│")}`;
|
|
957
|
+
}
|
|
958
|
+
function renderSpendSparkline(dailySpend, maxWidth) {
|
|
959
|
+
if (dailySpend.size === 0)
|
|
960
|
+
return "No spend data";
|
|
961
|
+
const sortedDays = Array.from(dailySpend.keys()).sort();
|
|
962
|
+
const visibleDays = sortedDays.slice(-Math.max(1, Math.min(30, maxWidth)));
|
|
963
|
+
const values = visibleDays.map((day) => dailySpend.get(day) ?? 0);
|
|
964
|
+
const max = Math.max(...values);
|
|
965
|
+
if (max <= 0)
|
|
966
|
+
return "No spend";
|
|
967
|
+
const blocks = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
968
|
+
return values.map((value) => {
|
|
969
|
+
const idx = Math.max(0, Math.min(blocks.length - 1, Math.ceil(value / max * blocks.length) - 1));
|
|
970
|
+
return blocks[idx];
|
|
971
|
+
}).join("");
|
|
972
|
+
}
|
|
973
|
+
function formatDailyAverage(stats) {
|
|
974
|
+
const days = stats.dailySpend.size;
|
|
975
|
+
if (days === 0)
|
|
976
|
+
return "avg/day -";
|
|
977
|
+
return `avg/day ${formatCost(stats.totals.cost / days)}`;
|
|
978
|
+
}
|
|
979
|
+
const TAB_LABELS = {
|
|
980
|
+
today: "Today",
|
|
981
|
+
thisWeek: "This Week",
|
|
982
|
+
lastWeek: "Last Week",
|
|
983
|
+
allTime: "All Time"
|
|
984
|
+
};
|
|
985
|
+
const TAB_ORDER = ["today", "thisWeek", "lastWeek", "allTime"];
|
|
986
|
+
|
|
987
|
+
class UsageComponent {
|
|
988
|
+
activeTab = "allTime";
|
|
989
|
+
viewMode = "table";
|
|
990
|
+
data;
|
|
991
|
+
selectedIndex = 0;
|
|
992
|
+
expanded = new Set;
|
|
993
|
+
providerOrder = [];
|
|
994
|
+
theme;
|
|
995
|
+
requestRender;
|
|
996
|
+
done;
|
|
997
|
+
constructor(theme, data, requestRender, done) {
|
|
998
|
+
this.theme = theme;
|
|
999
|
+
this.requestRender = requestRender;
|
|
1000
|
+
this.done = done;
|
|
1001
|
+
this.data = data;
|
|
1002
|
+
this.updateProviderOrder();
|
|
1003
|
+
}
|
|
1004
|
+
updateProviderOrder() {
|
|
1005
|
+
const stats = this.data[this.activeTab];
|
|
1006
|
+
this.providerOrder = Array.from(stats.providers.entries()).sort((a, b) => b[1].cost - a[1].cost).map(([name]) => name);
|
|
1007
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
|
|
1008
|
+
}
|
|
1009
|
+
handleInput(data) {
|
|
1010
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
1011
|
+
this.done();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (matchesKey(data, "v")) {
|
|
1015
|
+
this.viewMode = this.viewMode === "table" ? "insights" : "table";
|
|
1016
|
+
this.requestRender();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
|
|
1020
|
+
const idx = TAB_ORDER.indexOf(this.activeTab);
|
|
1021
|
+
this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length];
|
|
1022
|
+
this.updateProviderOrder();
|
|
1023
|
+
this.requestRender();
|
|
1024
|
+
} else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
|
|
1025
|
+
const idx = TAB_ORDER.indexOf(this.activeTab);
|
|
1026
|
+
this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length];
|
|
1027
|
+
this.updateProviderOrder();
|
|
1028
|
+
this.requestRender();
|
|
1029
|
+
} else if (this.viewMode === "table" && matchesKey(data, "up")) {
|
|
1030
|
+
if (this.selectedIndex > 0) {
|
|
1031
|
+
this.selectedIndex--;
|
|
1032
|
+
this.requestRender();
|
|
1033
|
+
}
|
|
1034
|
+
} else if (this.viewMode === "table" && matchesKey(data, "down")) {
|
|
1035
|
+
if (this.selectedIndex < this.providerOrder.length - 1) {
|
|
1036
|
+
this.selectedIndex++;
|
|
1037
|
+
this.requestRender();
|
|
1038
|
+
}
|
|
1039
|
+
} else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
|
|
1040
|
+
const provider = this.providerOrder[this.selectedIndex];
|
|
1041
|
+
if (provider) {
|
|
1042
|
+
if (this.expanded.has(provider)) {
|
|
1043
|
+
this.expanded.delete(provider);
|
|
1044
|
+
} else {
|
|
1045
|
+
this.expanded.add(provider);
|
|
1046
|
+
}
|
|
1047
|
+
this.requestRender();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
render(width) {
|
|
1052
|
+
if (this.viewMode === "insights") {
|
|
1053
|
+
return clampLines([
|
|
1054
|
+
...this.renderTitle(width),
|
|
1055
|
+
...this.renderTabs(width, getTableLayout(width)),
|
|
1056
|
+
...this.renderSpendChart(width),
|
|
1057
|
+
...this.renderInsights(width),
|
|
1058
|
+
...this.renderHelp(width)
|
|
1059
|
+
], width);
|
|
1060
|
+
}
|
|
1061
|
+
const layout = getTableLayout(width);
|
|
1062
|
+
return clampLines([
|
|
1063
|
+
...this.renderTitle(width),
|
|
1064
|
+
...this.renderTabs(width, layout),
|
|
1065
|
+
...this.renderHeader(layout),
|
|
1066
|
+
...this.renderRows(layout),
|
|
1067
|
+
...this.renderTotals(layout),
|
|
1068
|
+
...this.renderFormulaNote(width),
|
|
1069
|
+
...this.renderHelp(width)
|
|
1070
|
+
], width);
|
|
1071
|
+
}
|
|
1072
|
+
renderTitle(width) {
|
|
1073
|
+
const th = this.theme;
|
|
1074
|
+
const stats = this.data[this.activeTab];
|
|
1075
|
+
const label = this.viewMode === "insights" ? "Usage Insights" : "Usage Statistics";
|
|
1076
|
+
return renderPanel("DuckMind Usage", [
|
|
1077
|
+
`${th.fg("accent", th.bold(label))} · ${TAB_LABELS[this.activeTab]}`,
|
|
1078
|
+
`Spend ${formatCost(stats.totals.cost)} · ${formatNumber(stats.totals.messages)} messages · ${formatTokens(stats.totals.tokens.total)} tokens`,
|
|
1079
|
+
`Providers ${formatNumber(stats.providers.size)} · Sessions ${formatNumber(stats.totals.sessions)} · ${formatDailyAverage(stats)}`
|
|
1080
|
+
], width, th);
|
|
1081
|
+
}
|
|
1082
|
+
renderSpendChart(width) {
|
|
1083
|
+
const th = this.theme;
|
|
1084
|
+
const stats = this.data[this.activeTab];
|
|
1085
|
+
return renderPanel("Daily Spend", [
|
|
1086
|
+
th.fg("accent", renderSpendSparkline(stats.dailySpend, Math.max(width - 4, 10))),
|
|
1087
|
+
th.fg("dim", "Local session spend, last 30 active UTC days")
|
|
1088
|
+
], width, th);
|
|
1089
|
+
}
|
|
1090
|
+
renderInsights(width) {
|
|
1091
|
+
const th = this.theme;
|
|
1092
|
+
const stats = this.data[this.activeTab];
|
|
1093
|
+
const { insights } = stats.insights;
|
|
1094
|
+
const hasMessages = stats.totals.messages > 0;
|
|
1095
|
+
const hasCost = stats.totals.cost > 0;
|
|
1096
|
+
const lines = [];
|
|
1097
|
+
lines.push("What's contributing to your cost?");
|
|
1098
|
+
lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
|
|
1099
|
+
lines.push("");
|
|
1100
|
+
const note = `${TAB_LABELS[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;
|
|
1101
|
+
lines.push(th.fg("dim", note));
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
if (!hasMessages) {
|
|
1104
|
+
lines.push(th.fg("dim", " No usage recorded for this period."));
|
|
1105
|
+
lines.push("");
|
|
1106
|
+
return lines;
|
|
1107
|
+
}
|
|
1108
|
+
if (!hasCost) {
|
|
1109
|
+
lines.push(th.fg("dim", " No cost data recorded for this period."));
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
return lines;
|
|
1112
|
+
}
|
|
1113
|
+
if (insights.length === 0) {
|
|
1114
|
+
lines.push(th.fg("dim", " No insights above 1% for this period."));
|
|
1115
|
+
lines.push("");
|
|
1116
|
+
return lines;
|
|
1117
|
+
}
|
|
1118
|
+
const indent = " ";
|
|
1119
|
+
const adviceWidth = Math.max(width - indent.length, 30);
|
|
1120
|
+
for (const insight of insights) {
|
|
1121
|
+
const pct = th.fg("accent", th.bold(formatInsightPercent(insight.percent)));
|
|
1122
|
+
lines.push(`${pct} ${insight.headline}`);
|
|
1123
|
+
for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
|
|
1124
|
+
lines.push(`${indent}${th.fg("dim", wrapped)}`);
|
|
1125
|
+
}
|
|
1126
|
+
lines.push("");
|
|
1127
|
+
}
|
|
1128
|
+
return lines;
|
|
1129
|
+
}
|
|
1130
|
+
renderTabs(width, layout) {
|
|
1131
|
+
const th = this.theme;
|
|
1132
|
+
const fullTabs = TAB_ORDER.map((tab) => {
|
|
1133
|
+
const label = TAB_LABELS[tab];
|
|
1134
|
+
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
1135
|
+
}).join(" ");
|
|
1136
|
+
const activeTabOnly = th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`);
|
|
1137
|
+
const tabLine = pickFittingText(width, [
|
|
1138
|
+
fullTabs,
|
|
1139
|
+
`${activeTabOnly} ${th.fg("dim", "[Tab/←→]")}`,
|
|
1140
|
+
activeTabOnly
|
|
1141
|
+
]);
|
|
1142
|
+
const infoLines = this.viewMode === "table" && layout.compact ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1)) : [];
|
|
1143
|
+
return [tabLine, ...infoLines, ""];
|
|
1144
|
+
}
|
|
1145
|
+
renderHeader(layout) {
|
|
1146
|
+
const th = this.theme;
|
|
1147
|
+
let headerLine = fitCell("Provider / Model", layout.nameWidth);
|
|
1148
|
+
for (const col of layout.columns) {
|
|
1149
|
+
const label = fitCell(col.label, col.width, "right");
|
|
1150
|
+
headerLine += col.dimmed ? th.fg("dim", label) : label;
|
|
1151
|
+
}
|
|
1152
|
+
return [th.fg("muted", headerLine), th.fg("border", "─".repeat(layout.tableWidth))];
|
|
1153
|
+
}
|
|
1154
|
+
renderDataRow(name, stats, layout, options = {}) {
|
|
1155
|
+
const th = this.theme;
|
|
1156
|
+
const { indent = 0, selected = false, dimAll = false, prefix } = options;
|
|
1157
|
+
const rawPrefix = prefix ?? " ".repeat(indent);
|
|
1158
|
+
const safePrefix = layout.nameWidth > 0 ? truncateToWidth(rawPrefix, layout.nameWidth, "") : "";
|
|
1159
|
+
const prefixWidth = visibleWidth(safePrefix);
|
|
1160
|
+
const innerNameWidth = Math.max(layout.nameWidth - prefixWidth, 0);
|
|
1161
|
+
const truncName = innerNameWidth > 0 ? truncateToWidth(name, innerNameWidth) : "";
|
|
1162
|
+
const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
|
|
1163
|
+
let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
|
|
1164
|
+
for (const col of layout.columns) {
|
|
1165
|
+
const value = fitCell(col.getValue(stats), col.width, "right");
|
|
1166
|
+
const shouldDim = col.dimmed || dimAll;
|
|
1167
|
+
row += shouldDim ? th.fg("dim", value) : value;
|
|
1168
|
+
}
|
|
1169
|
+
return row;
|
|
1170
|
+
}
|
|
1171
|
+
renderRows(layout) {
|
|
1172
|
+
const th = this.theme;
|
|
1173
|
+
const stats = this.data[this.activeTab];
|
|
1174
|
+
const lines = [];
|
|
1175
|
+
if (this.providerOrder.length === 0) {
|
|
1176
|
+
lines.push(th.fg("dim", " No usage data for this period"));
|
|
1177
|
+
return lines;
|
|
1178
|
+
}
|
|
1179
|
+
for (let i = 0;i < this.providerOrder.length; i++) {
|
|
1180
|
+
const providerName = this.providerOrder[i];
|
|
1181
|
+
const providerStats = stats.providers.get(providerName);
|
|
1182
|
+
const isSelected = i === this.selectedIndex;
|
|
1183
|
+
const isExpanded = this.expanded.has(providerName);
|
|
1184
|
+
const arrow = isExpanded ? "▾" : "▸";
|
|
1185
|
+
const prefix = isSelected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
|
|
1186
|
+
lines.push(this.renderDataRow(formatProviderName(providerName), providerStats, layout, {
|
|
1187
|
+
selected: isSelected,
|
|
1188
|
+
prefix
|
|
1189
|
+
}));
|
|
1190
|
+
if (isExpanded) {
|
|
1191
|
+
const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
1192
|
+
for (const [modelName, modelStats] of models) {
|
|
1193
|
+
lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return lines;
|
|
1198
|
+
}
|
|
1199
|
+
renderTotals(layout) {
|
|
1200
|
+
const th = this.theme;
|
|
1201
|
+
const stats = this.data[this.activeTab];
|
|
1202
|
+
let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
|
|
1203
|
+
for (const col of layout.columns) {
|
|
1204
|
+
const value = fitCell(col.getValue(stats.totals), col.width, "right");
|
|
1205
|
+
totalRow += col.dimmed ? th.fg("dim", value) : value;
|
|
1206
|
+
}
|
|
1207
|
+
return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
|
|
1208
|
+
}
|
|
1209
|
+
renderFormulaNote(width) {
|
|
1210
|
+
const line = pickFittingText(width, [
|
|
1211
|
+
"Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite (as of 0.2.0)",
|
|
1212
|
+
"Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)",
|
|
1213
|
+
"Tokens & ↑In include CacheWrite (v0.2.0+)",
|
|
1214
|
+
"Incl. CacheWrite (v0.2.0+)"
|
|
1215
|
+
]);
|
|
1216
|
+
return [this.theme.fg("dim", line), ""];
|
|
1217
|
+
}
|
|
1218
|
+
renderHelp(width) {
|
|
1219
|
+
const variants = this.viewMode === "insights" ? [
|
|
1220
|
+
"[Tab/←→] period [v] table view [q] close",
|
|
1221
|
+
"[Tab] period [v] table [q] close",
|
|
1222
|
+
"[v] table [q] close",
|
|
1223
|
+
"[q] close"
|
|
1224
|
+
] : [
|
|
1225
|
+
"[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close",
|
|
1226
|
+
"[Tab] period [↑↓] select [Enter] expand [v] insights [q] close",
|
|
1227
|
+
"[↑↓] select [Enter] expand [v] insights [q] close",
|
|
1228
|
+
"[↑↓] select [v] insights [q] close",
|
|
1229
|
+
"[↑↓] select [q] close",
|
|
1230
|
+
"[q] close"
|
|
1231
|
+
];
|
|
1232
|
+
const line = pickFittingText(width, variants);
|
|
1233
|
+
return [this.theme.fg("dim", line)];
|
|
1234
|
+
}
|
|
1235
|
+
invalidate() {}
|
|
1236
|
+
dispose() {}
|
|
1237
|
+
}
|
|
1238
|
+
export default function dm_usage_default(pi) {
|
|
1239
|
+
pi.registerCommand("usage", {
|
|
1240
|
+
description: `Show usage statistics dashboard and account tools: ${USAGE_SUBCOMMANDS.join(", ")}`,
|
|
1241
|
+
getArgumentCompletions: getUsageCommandCompletions,
|
|
1242
|
+
handler: async (args, ctx) => {
|
|
1243
|
+
const parsed = parseUsageCommandArgs(args);
|
|
1244
|
+
if (parsed.subcommand) {
|
|
1245
|
+
if (!isUsageSubcommand(parsed.subcommand)) {
|
|
1246
|
+
ctx.ui.notify(`Unknown /usage subcommand: ${parsed.subcommand}
|
|
1247
|
+
${USAGE_HELP_TEXT}`, "warning");
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
await runUsageSubcommand(parsed.subcommand, parsed.rest, ctx);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (!ctx.hasUI) {
|
|
1254
|
+
await runUsageShowSubcommand(ctx);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const data = await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
1258
|
+
const loader = new CancellableLoader(tui, (s) => theme.fg("accent", s), (s) => theme.fg("muted", s), "Loading Usage...");
|
|
1259
|
+
let finished = false;
|
|
1260
|
+
const finish = (value) => {
|
|
1261
|
+
if (finished)
|
|
1262
|
+
return;
|
|
1263
|
+
finished = true;
|
|
1264
|
+
loader.dispose();
|
|
1265
|
+
done(value);
|
|
1266
|
+
};
|
|
1267
|
+
loader.onAbort = () => finish(null);
|
|
1268
|
+
collectUsageData(loader.signal).then(finish).catch(() => finish(null));
|
|
1269
|
+
return loader;
|
|
1270
|
+
});
|
|
1271
|
+
if (!data) {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
await ctx.ui.custom((tui, theme, _kb, done) => {
|
|
1275
|
+
const container = new Container;
|
|
1276
|
+
container.addChild(new Spacer(1));
|
|
1277
|
+
container.addChild(new DynamicBorder((s) => theme.fg("border", s)));
|
|
1278
|
+
container.addChild(new Spacer(1));
|
|
1279
|
+
const usage = new UsageComponent(theme, data, () => tui.requestRender(), () => done());
|
|
1280
|
+
return {
|
|
1281
|
+
render: (w) => {
|
|
1282
|
+
const borderLines = clampLines(container.render(w), w);
|
|
1283
|
+
const usageLines = usage.render(w);
|
|
1284
|
+
const bottomBorder = theme.fg("border", "─".repeat(w));
|
|
1285
|
+
return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
|
|
1286
|
+
},
|
|
1287
|
+
invalidate: () => container.invalidate(),
|
|
1288
|
+
handleInput: (input) => usage.handleInput(input),
|
|
1289
|
+
dispose: () => {}
|
|
1290
|
+
};
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
}
|