@shanepadgett/tau-agent 0.39.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import { access, mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { buildCostReport } from "./analyze.ts";
6
+ import { renderCostReportHtml } from "./html.ts";
7
+ import { CostReportStatusPanel } from "./panel.ts";
8
+ import { LIVE_RANGE_CHOICES, liveRange, MONTH_CHOICES, specificMonthRange, yearChoices } from "./range.ts";
9
+ import type { ReportRange, ReportScope } from "./types.ts";
10
+
11
+ const COMMAND = "cost-report";
12
+
13
+ export default function costReportExtension(pi: ExtensionAPI): void {
14
+ pi.registerCommand(COMMAND, {
15
+ description: "Generate an HTML cost report from local sessions",
16
+ handler: async (_args, ctx) => {
17
+ if (ctx.mode !== "tui") {
18
+ ctx.ui.notify("/cost-report requires TUI mode", "warning");
19
+ return;
20
+ }
21
+
22
+ await ctx.waitForIdle();
23
+
24
+ const range = await pickRange(ctx);
25
+ if (!range) return;
26
+
27
+ const scope = await pickScope(ctx);
28
+ if (!scope) return;
29
+
30
+ const secondary = `${range.label} · ${scope === "project" ? "Current project" : "All sessions"}`;
31
+
32
+ const path = outputPath(range, scope);
33
+ if (await fileExists(path)) {
34
+ try {
35
+ await openReport(pi, ctx, path);
36
+ } catch {
37
+ // Still notify the path if the OS open fails.
38
+ }
39
+ ctx.ui.notify(`Opening existing cost report · ${path}`, "info");
40
+ return;
41
+ }
42
+
43
+ const outcome = await ctx.ui.custom<
44
+ | { kind: "ok"; path: string }
45
+ | { kind: "empty" }
46
+ | { kind: "cancelled" }
47
+ | { kind: "error"; message: string }
48
+ >((tui, theme, _keys, done) => {
49
+ const panel = new CostReportStatusPanel(tui, theme, secondary);
50
+ void (async () => {
51
+ try {
52
+ const report = await buildCostReport({
53
+ cwd: ctx.cwd,
54
+ range,
55
+ scope,
56
+ signal: panel.signal,
57
+ onProgress: (status) => panel.update(status),
58
+ });
59
+ if (panel.signal.aborted) {
60
+ done({ kind: "cancelled" });
61
+ return;
62
+ }
63
+ if (!report) {
64
+ done({ kind: "empty" });
65
+ return;
66
+ }
67
+
68
+ panel.update("Writing file…");
69
+ await mkdir(join(homedir(), ".pi", "tau", "cost-reports"), { recursive: true });
70
+ await writeFile(path, renderCostReportHtml(report), "utf8");
71
+
72
+ if (panel.signal.aborted) {
73
+ done({ kind: "cancelled" });
74
+ return;
75
+ }
76
+
77
+ panel.update("Opening…");
78
+ try {
79
+ await openReport(pi, ctx, path);
80
+ } catch {
81
+ // File is written; notify still carries the path if open fails.
82
+ }
83
+ done({ kind: "ok", path });
84
+ } catch (error) {
85
+ if (panel.signal.aborted) {
86
+ done({ kind: "cancelled" });
87
+ return;
88
+ }
89
+ done({
90
+ kind: "error",
91
+ message: error instanceof Error ? error.message : String(error),
92
+ });
93
+ } finally {
94
+ panel.dispose();
95
+ }
96
+ })();
97
+ return panel;
98
+ });
99
+
100
+ if (outcome.kind === "ok") {
101
+ ctx.ui.notify(`Cost report written · ${outcome.path}`, "info");
102
+ return;
103
+ }
104
+ if (outcome.kind === "empty") {
105
+ ctx.ui.notify(`No session spend found for ${secondary}.`, "warning");
106
+ return;
107
+ }
108
+ if (outcome.kind === "cancelled") {
109
+ ctx.ui.notify("Cost report cancelled.", "info");
110
+ return;
111
+ }
112
+ ctx.ui.notify(`Unable to build cost report: ${outcome.message}`, "error");
113
+ },
114
+ });
115
+ }
116
+
117
+ async function pickRange(ctx: ExtensionCommandContext): Promise<ReportRange | undefined> {
118
+ const choice = await ctx.ui.select(
119
+ "Time frame",
120
+ LIVE_RANGE_CHOICES.map((item) => item.label),
121
+ );
122
+ if (!choice) return undefined;
123
+
124
+ const selected = LIVE_RANGE_CHOICES.find((item) => item.label === choice);
125
+ if (!selected) return undefined;
126
+ if (selected.id !== "specific-month") return liveRange(selected.id);
127
+
128
+ const monthLabel = await ctx.ui.select(
129
+ "Month",
130
+ MONTH_CHOICES.map((item) => item.label),
131
+ );
132
+ if (!monthLabel) return undefined;
133
+ const month = MONTH_CHOICES.find((item) => item.label === monthLabel);
134
+ if (!month) return undefined;
135
+
136
+ const years = yearChoices().map(String);
137
+ const yearLabel = await ctx.ui.select("Year", years);
138
+ if (!yearLabel) return undefined;
139
+ const year = Number(yearLabel);
140
+ if (!Number.isFinite(year)) return undefined;
141
+
142
+ return specificMonthRange(year, month.month);
143
+ }
144
+
145
+ async function pickScope(ctx: ExtensionCommandContext): Promise<ReportScope | undefined> {
146
+ const choice = await ctx.ui.select("Scope", ["Current project", "All sessions"]);
147
+ if (choice === "Current project") return "project";
148
+ if (choice === "All sessions") return "all";
149
+ return undefined;
150
+ }
151
+
152
+ function outputPath(range: ReportRange, scope: ReportScope): string {
153
+ // One file per window identity + scope. Re-running the same picks reopens this path.
154
+ return join(homedir(), ".pi", "tau", "cost-reports", `cost-report-${range.slug}-${scope}.html`);
155
+ }
156
+
157
+ async function fileExists(path: string): Promise<boolean> {
158
+ try {
159
+ await access(path);
160
+ return true;
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+
166
+ async function openReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, path: string): Promise<void> {
167
+ const command = openCommand(path);
168
+ const result = await pi.exec(command.command, command.args, {
169
+ signal: ctx.signal,
170
+ timeout: 5_000,
171
+ });
172
+ if (result.code !== 0) {
173
+ throw new Error(result.stderr.trim() || `open command failed with exit ${result.code}`);
174
+ }
175
+ }
176
+
177
+ function openCommand(path: string): { command: string; args: string[] } {
178
+ if (process.platform === "darwin") return { command: "open", args: [path] };
179
+ if (process.platform === "win32") return { command: "cmd", args: ["/c", "start", "", path] };
180
+ return { command: "xdg-open", args: [path] };
181
+ }
@@ -0,0 +1,64 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { getKeybindings, type Component, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { bindingHint, ToolPanel, type ToolPanelConfig } from "@shanepadgett/tau-tui";
4
+
5
+ export class CostReportStatusPanel implements Component {
6
+ private readonly tui: TUI;
7
+ private readonly panel: ToolPanel;
8
+ private readonly config: ToolPanelConfig;
9
+ private readonly controller = new AbortController();
10
+ private readonly startedAt = Date.now();
11
+ private readonly timer: ReturnType<typeof setInterval>;
12
+ private status = "Starting…";
13
+ private readonly secondary: string;
14
+
15
+ constructor(tui: TUI, theme: Theme, secondary: string) {
16
+ this.tui = tui;
17
+ this.secondary = secondary;
18
+ this.config = {
19
+ title: "Cost report",
20
+ secondary,
21
+ body: {
22
+ render: (width) => [truncateToWidth(theme.fg("muted", this.status), width, "…")],
23
+ invalidate: () => {},
24
+ },
25
+ footer: { kind: "hints", hints: [bindingHint("tui.select.cancel", "cancel")] },
26
+ border: "box",
27
+ };
28
+ this.panel = new ToolPanel(theme, this.config);
29
+ this.timer = setInterval(() => this.tui.requestRender(), 1_000);
30
+ this.timer.unref();
31
+ }
32
+
33
+ get signal(): AbortSignal {
34
+ return this.controller.signal;
35
+ }
36
+
37
+ handleInput(data: string): void {
38
+ if (!getKeybindings().matches(data, "tui.select.cancel")) return;
39
+ if (this.controller.signal.aborted) return;
40
+ this.update("Cancelling…");
41
+ this.controller.abort();
42
+ }
43
+
44
+ update(status: string): void {
45
+ this.status = status.trim() || this.status;
46
+ this.tui.requestRender();
47
+ }
48
+
49
+ render(width: number): string[] {
50
+ const elapsed = Math.floor((Date.now() - this.startedAt) / 1_000);
51
+ const clock = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`;
52
+ this.config.title = `Cost report · ${clock}`;
53
+ this.config.secondary = this.secondary;
54
+ return this.panel.render(width);
55
+ }
56
+
57
+ invalidate(): void {
58
+ this.panel.invalidate();
59
+ }
60
+
61
+ dispose(): void {
62
+ clearInterval(this.timer);
63
+ }
64
+ }
@@ -0,0 +1,145 @@
1
+ import type { LiveRangeKind, ReportRange } from "./types.ts";
2
+
3
+ const MONTH_LABELS = [
4
+ "January",
5
+ "February",
6
+ "March",
7
+ "April",
8
+ "May",
9
+ "June",
10
+ "July",
11
+ "August",
12
+ "September",
13
+ "October",
14
+ "November",
15
+ "December",
16
+ ] as const;
17
+
18
+ export const LIVE_RANGE_CHOICES: ReadonlyArray<{ id: LiveRangeKind | "specific-month"; label: string }> = [
19
+ { id: "past-7-days", label: "Past 7 days" },
20
+ { id: "current-week", label: "Current week" },
21
+ { id: "current-month", label: "Current month" },
22
+ { id: "year-to-date", label: "Year to date" },
23
+ { id: "specific-month", label: "Specific month…" },
24
+ ];
25
+
26
+ export const MONTH_CHOICES: ReadonlyArray<{ month: number; label: string }> = MONTH_LABELS.map((label, month) => ({
27
+ month,
28
+ label,
29
+ }));
30
+
31
+ export function yearChoices(now = new Date()): number[] {
32
+ const current = now.getFullYear();
33
+ return Array.from({ length: 6 }, (_, index) => current - index);
34
+ }
35
+
36
+ export function liveRange(kind: LiveRangeKind, now = new Date()): ReportRange {
37
+ const endMs = now.getTime();
38
+ if (kind === "past-7-days") {
39
+ const start = startOfLocalDay(now);
40
+ start.setDate(start.getDate() - 6);
41
+ // Slides daily; slug pins the window end day so same-day reopens match.
42
+ return {
43
+ kind,
44
+ label: "Past 7 days",
45
+ slug: `7d-${localDateKey(endMs)}`,
46
+ startMs: start.getTime(),
47
+ endMs,
48
+ layout: "row",
49
+ };
50
+ }
51
+ if (kind === "current-week") {
52
+ const start = startOfLocalDay(now);
53
+ const day = start.getDay(); // 0 Sun … 6 Sat
54
+ const mondayOffset = day === 0 ? -6 : 1 - day;
55
+ start.setDate(start.getDate() + mondayOffset);
56
+ return {
57
+ kind,
58
+ label: "Current week",
59
+ slug: `week-${localDateKey(start.getTime())}`,
60
+ startMs: start.getTime(),
61
+ endMs,
62
+ layout: "row",
63
+ };
64
+ }
65
+ if (kind === "current-month") {
66
+ const start = new Date(now.getFullYear(), now.getMonth(), 1);
67
+ const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
68
+ return {
69
+ kind,
70
+ label: "Current month",
71
+ slug: `month-${ym}`,
72
+ startMs: start.getTime(),
73
+ endMs,
74
+ layout: "row",
75
+ year: now.getFullYear(),
76
+ month: now.getMonth(),
77
+ };
78
+ }
79
+ const start = new Date(now.getFullYear(), 0, 1);
80
+ return {
81
+ kind,
82
+ label: "Year to date",
83
+ slug: `ytd-${now.getFullYear()}`,
84
+ startMs: start.getTime(),
85
+ endMs,
86
+ layout: "ytd",
87
+ year: now.getFullYear(),
88
+ };
89
+ }
90
+
91
+ export function specificMonthRange(year: number, month: number, now = new Date()): ReportRange {
92
+ const start = new Date(year, month, 1);
93
+ const endExclusive = new Date(year, month + 1, 1);
94
+ const endMs = Math.min(now.getTime(), endExclusive.getTime() - 1);
95
+ const monthLabel = MONTH_LABELS[month] ?? `Month ${month + 1}`;
96
+ const ym = `${year}-${String(month + 1).padStart(2, "0")}`;
97
+ return {
98
+ kind: "specific-month",
99
+ label: `${monthLabel} ${year}`,
100
+ slug: ym,
101
+ startMs: start.getTime(),
102
+ endMs,
103
+ layout: "row",
104
+ year,
105
+ month,
106
+ };
107
+ }
108
+
109
+ export function startOfLocalDay(date: Date): Date {
110
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
111
+ }
112
+
113
+ export function localDateKey(timestampMs: number): string {
114
+ const date = new Date(timestampMs);
115
+ const y = date.getFullYear();
116
+ const m = String(date.getMonth() + 1).padStart(2, "0");
117
+ const d = String(date.getDate()).padStart(2, "0");
118
+ return `${y}-${m}-${d}`;
119
+ }
120
+
121
+ export function shortDateLabel(timestampMs: number): string {
122
+ const date = new Date(timestampMs);
123
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
124
+ return `${date.getDate()} ${months[date.getMonth()]}`;
125
+ }
126
+
127
+ export function isWeekend(timestampMs: number): boolean {
128
+ const day = new Date(timestampMs).getDay();
129
+ return day === 0 || day === 6;
130
+ }
131
+
132
+ export function eachLocalDay(startMs: number, endMs: number): number[] {
133
+ const days: number[] = [];
134
+ const cursor = startOfLocalDay(new Date(startMs));
135
+ const end = startOfLocalDay(new Date(endMs));
136
+ while (cursor.getTime() <= end.getTime()) {
137
+ days.push(cursor.getTime());
138
+ cursor.setDate(cursor.getDate() + 1);
139
+ }
140
+ return days;
141
+ }
142
+
143
+ export function daysInMonth(year: number, month: number): number {
144
+ return new Date(year, month + 1, 0).getDate();
145
+ }
@@ -0,0 +1,93 @@
1
+ export type ReportScope = "project" | "all";
2
+
3
+ export type LiveRangeKind = "past-7-days" | "current-week" | "current-month" | "year-to-date";
4
+
5
+ export type RangeKind = LiveRangeKind | "specific-month";
6
+
7
+ export interface ReportRange {
8
+ kind: RangeKind;
9
+ label: string;
10
+ slug: string;
11
+ startMs: number;
12
+ endMs: number;
13
+ /** Single full-width day row vs one row per month (YTD). */
14
+ layout: "row" | "ytd";
15
+ year?: number;
16
+ month?: number; // 0-11
17
+ }
18
+
19
+ export interface CostTotals {
20
+ input: number;
21
+ output: number;
22
+ cacheRead: number;
23
+ cacheWrite: number;
24
+ totalTokens: number;
25
+ cost: number;
26
+ }
27
+
28
+ export interface ModelCost {
29
+ key: string;
30
+ provider: string;
31
+ model: string;
32
+ cost: number;
33
+ tokens: number;
34
+ sessions: number;
35
+ }
36
+
37
+ export interface SubagentCost {
38
+ agent: string;
39
+ cost: number;
40
+ calls: number;
41
+ }
42
+
43
+ export interface ProjectCost {
44
+ key: string;
45
+ label: string;
46
+ cost: number;
47
+ sessions: number;
48
+ tokens: number;
49
+ }
50
+
51
+ export interface SessionModelCost {
52
+ key: string;
53
+ label: string;
54
+ cost: number;
55
+ }
56
+
57
+ export interface SessionCost {
58
+ id: string;
59
+ path: string;
60
+ name: string;
61
+ projectKey: string;
62
+ projectLabel: string;
63
+ startedAtMs: number;
64
+ cost: number;
65
+ tokens: number;
66
+ models: SessionModelCost[];
67
+ }
68
+
69
+ export interface DayCost {
70
+ dateKey: string; // YYYY-MM-DD local
71
+ label: string;
72
+ timestampMs: number;
73
+ cost: number;
74
+ weekend: boolean;
75
+ }
76
+
77
+ export interface CostReport {
78
+ generatedAtMs: number;
79
+ cwd: string;
80
+ scope: ReportScope;
81
+ range: ReportRange;
82
+ directCost: number;
83
+ subagentCost: number;
84
+ totalCost: number;
85
+ totalTokens: number;
86
+ sessionCount: number;
87
+ projectCount: number;
88
+ days: DayCost[];
89
+ models: ModelCost[];
90
+ subagents: SubagentCost[];
91
+ projects: ProjectCost[];
92
+ sessions: SessionCost[];
93
+ }
@@ -40,6 +40,10 @@ Adds `/clear-screen` to clear terminal output without changing the session.
40
40
 
41
41
  Adds `/commit` for semantic commit grouping, review, and committing selected repository changes.
42
42
 
43
+ ## cost-report
44
+
45
+ Adds `/cost-report` to build an HTML spend report from local session usage. Pick a time frame (past 7 days, current week, current month, year to date, or a specific month) and scope (current project or all sessions). Tau scans sessions, writes under `~/.pi/tau/cost-reports/`, opens the file, and notifies with the path. Empty windows warn without writing a file.
46
+
43
47
  ## context
44
48
 
45
49
  Adds `/context` to inject reusable repository work scopes from `.pi/contexts`, and `/context-sync` or `/context-sync <nudge>` for human-driven catalog sync. Selecting entries injects them once into the conversation: `read` paths as complete files, `show` targets as current declaration slices, `outline` paths as Explore structures, and one hidden note listing `references` plus instructions to treat the injected material as current. Run `/context` again to inject more. Manual sync replaces the editor with a status panel; Escape or Ctrl+C cancels. When `sync.automation` is on, coding agent can also run `context-sync` after meaningful uncommitted work. Sync catalogs durable code and long-lived documentation; recurring scratch, planning, interview, and rough-idea paths belong in `validation.ignoreGlobs`. `sync.enabled` is master switch for command, automation, and validation auto-run. Context validation is off by default; when on (and sync enabled), Tau auto-runs context-sync on failure. Domain folders are `NN_slug` tabs (ordered by the two-digit prefix; UI shows the slug), TOML files are concepts, and TOML sections are selectable entries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.39.2",
3
+ "version": "0.41.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@ast-grep/wasm": "0.45.1",
38
- "@shanepadgett/tau-tui": "0.39.2",
38
+ "@shanepadgett/tau-tui": "0.41.0",
39
39
  "@vscode/tree-sitter-wasm": "0.3.1",
40
40
  "image-size": "2.0.2",
41
41
  "smol-toml": "1.8.0",