@duckmind/dm-darwin-x64 0.35.6 → 0.35.9
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 +0 -0
- package/extensions/dm-subagents/package-lock.json +3 -3
- package/extensions/dm-tasks/node_modules/.package-lock.json +3 -3
- package/extensions/dm-tasks/node_modules/typebox/build/type/script/mapping.d.mts +16 -20
- package/extensions/dm-tasks/node_modules/typebox/build/type/script/mapping.mjs +17 -23
- package/extensions/dm-tasks/node_modules/typebox/build/type/script/parser.d.mts +9 -13
- package/extensions/dm-tasks/node_modules/typebox/build/type/script/parser.mjs +5 -7
- package/extensions/dm-tasks/node_modules/typebox/package.json +1 -1
- package/extensions/dm-tasks/package-lock.json +3 -3
- package/extensions/dm-usage/README.md +8 -0
- package/extensions/dm-usage/index.ts +594 -12
- package/extensions/greedysearch-dm/node_modules/.package-lock.json +3 -3
- package/extensions/greedysearch-dm/node_modules/nwsapi/LICENSE +1 -1
- package/extensions/greedysearch-dm/node_modules/nwsapi/package.json +1 -1
- package/extensions/greedysearch-dm/node_modules/nwsapi/src/nwsapi.js +37 -37
- package/extensions/greedysearch-dm/package-lock.json +3 -3
- package/package.json +1 -1
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
12
|
-
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi, type AutocompleteItem } from "@mariozechner/pi-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";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
17
|
|
|
17
18
|
// =============================================================================
|
|
@@ -77,6 +78,7 @@ interface GlobalSessionSpan {
|
|
|
77
78
|
interface TimeFilteredStats {
|
|
78
79
|
providers: Map<string, ProviderStats>;
|
|
79
80
|
totals: TotalStats;
|
|
81
|
+
dailySpend: Map<string, number>;
|
|
80
82
|
insights: PeriodInsights;
|
|
81
83
|
}
|
|
82
84
|
|
|
@@ -89,6 +91,31 @@ interface UsageData {
|
|
|
89
91
|
|
|
90
92
|
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
|
|
91
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
|
+
}
|
|
92
119
|
|
|
93
120
|
// =============================================================================
|
|
94
121
|
// Column Configuration
|
|
@@ -194,10 +221,25 @@ const TABLE_LAYOUTS: TableLayoutCandidate[] = [
|
|
|
194
221
|
// Data Collection
|
|
195
222
|
// =============================================================================
|
|
196
223
|
|
|
197
|
-
function
|
|
224
|
+
function getAgentDir(): string {
|
|
198
225
|
// Respect DM_CODING_AGENT_DIR, keeping PI_CODING_AGENT_DIR only as a compatibility fallback
|
|
199
|
-
|
|
200
|
-
|
|
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");
|
|
201
243
|
}
|
|
202
244
|
|
|
203
245
|
async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
|
|
@@ -335,6 +377,7 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
|
335
377
|
return {
|
|
336
378
|
providers: new Map(),
|
|
337
379
|
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
380
|
+
dailySpend: new Map(),
|
|
338
381
|
insights: { insights: [] },
|
|
339
382
|
};
|
|
340
383
|
}
|
|
@@ -422,6 +465,10 @@ function addMessagesToUsageData(
|
|
|
422
465
|
accumulateStats(providerStats, msg.cost, tokens);
|
|
423
466
|
|
|
424
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
|
+
}
|
|
425
472
|
sessionContributed[period] = true;
|
|
426
473
|
|
|
427
474
|
const raw = rawByPeriod[period];
|
|
@@ -507,6 +554,449 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
507
554
|
return data;
|
|
508
555
|
}
|
|
509
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
|
+
|
|
510
1000
|
// =============================================================================
|
|
511
1001
|
// Insights
|
|
512
1002
|
// =============================================================================
|
|
@@ -753,6 +1243,61 @@ function getTableLayout(width: number): TableLayout {
|
|
|
753
1243
|
};
|
|
754
1244
|
}
|
|
755
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
|
+
|
|
756
1301
|
// =============================================================================
|
|
757
1302
|
// Component
|
|
758
1303
|
// =============================================================================
|
|
@@ -846,8 +1391,9 @@ class UsageComponent {
|
|
|
846
1391
|
if (this.viewMode === "insights") {
|
|
847
1392
|
return clampLines(
|
|
848
1393
|
[
|
|
849
|
-
...this.renderTitle(),
|
|
1394
|
+
...this.renderTitle(width),
|
|
850
1395
|
...this.renderTabs(width, getTableLayout(width)),
|
|
1396
|
+
...this.renderSpendChart(width),
|
|
851
1397
|
...this.renderInsights(width),
|
|
852
1398
|
...this.renderHelp(width),
|
|
853
1399
|
],
|
|
@@ -858,7 +1404,7 @@ class UsageComponent {
|
|
|
858
1404
|
const layout = getTableLayout(width);
|
|
859
1405
|
return clampLines(
|
|
860
1406
|
[
|
|
861
|
-
...this.renderTitle(),
|
|
1407
|
+
...this.renderTitle(width),
|
|
862
1408
|
...this.renderTabs(width, layout),
|
|
863
1409
|
...this.renderHeader(layout),
|
|
864
1410
|
...this.renderRows(layout),
|
|
@@ -870,10 +1416,34 @@ class UsageComponent {
|
|
|
870
1416
|
);
|
|
871
1417
|
}
|
|
872
1418
|
|
|
873
|
-
private renderTitle(): string[] {
|
|
1419
|
+
private renderTitle(width: number): string[] {
|
|
874
1420
|
const th = this.theme;
|
|
1421
|
+
const stats = this.data[this.activeTab];
|
|
875
1422
|
const label = this.viewMode === "insights" ? "Usage Insights" : "Usage Statistics";
|
|
876
|
-
return
|
|
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
|
+
);
|
|
877
1447
|
}
|
|
878
1448
|
|
|
879
1449
|
private renderInsights(width: number): string[] {
|
|
@@ -1075,9 +1645,21 @@ class UsageComponent {
|
|
|
1075
1645
|
|
|
1076
1646
|
export default function (pi: ExtensionAPI) {
|
|
1077
1647
|
pi.registerCommand("usage", {
|
|
1078
|
-
description:
|
|
1079
|
-
|
|
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
|
+
|
|
1080
1661
|
if (!ctx.hasUI) {
|
|
1662
|
+
await runUsageShowSubcommand(ctx);
|
|
1081
1663
|
return;
|
|
1082
1664
|
}
|
|
1083
1665
|
|