@hasna/economy 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # @hasna/economy
2
2
 
3
- AI coding cost tracker for Claude Code, Takumi, Codex, and Gemini. It ships as a CLI, MCP server, REST API, web dashboard, and native macOS menu bar app.
3
+ AI coding cost tracker for Claude Code, Takumi, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes. It ships as a CLI, MCP server, REST API, web dashboard, and native macOS menu bar app.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@hasna/economy)](https://www.npmjs.com/package/@hasna/economy)
6
6
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
7
7
 
8
8
  ## Features
9
9
 
10
- - Ingests local Claude Code, Takumi, Codex, and Gemini CLI usage.
10
+ - Ingests local Claude Code, Takumi, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes usage.
11
11
  - Tracks sessions, requests, projects, machines, models, cache tokens, budgets, goals, and provider billing.
12
12
  - Attributes usage to `@hasna/accounts` profiles when agents run under managed account/profile config dirs.
13
+ - Breaks down API-equivalent, metered API, subscription-included, estimated, and unknown cost by account and coding agent.
13
14
  - Seeds editable model pricing with input, output, cache-read, 5-minute cache-write, 1-hour cache-write, and context-cache storage rates.
14
15
  - Handles tiered pricing such as Gemini long-prompt rates and OpenAI long-context rates.
15
16
  - Reconciles estimates against Anthropic, OpenAI, and Gemini billing sources.
@@ -70,7 +71,7 @@ Gemini settings:
70
71
  }
71
72
  ```
72
73
 
73
- The MCP server exposes read tools for summaries, sessions, machines, pricing, daily spend, budgets, goals, and provider billing. It also exposes mutation tools for budgets, pricing rows, and goals so Claude Code, Codex, and Gemini can manage Economy data through the same validated surface as the CLI and REST API.
74
+ The MCP server exposes read tools for summaries, sessions, machines, pricing, daily spend, budgets, goals, provider billing, usage snapshots, savings, project/account/agent breakdowns, and subscriptions. It also exposes mutation tools for budgets, pricing rows, goals, and subscriptions so coding agents can manage Economy data through the same validated surface as the CLI and REST API.
74
75
 
75
76
  ## Ingest
76
77
 
@@ -87,6 +88,10 @@ economy sync --claude
87
88
  economy sync --codex
88
89
  economy sync --gemini
89
90
  economy sync --takumi
91
+ economy sync --opencode
92
+ economy sync --cursor
93
+ economy sync --pi
94
+ economy sync --hermes
90
95
  ```
91
96
 
92
97
  Useful repair options:
@@ -103,6 +108,15 @@ Account attribution is automatic when `@hasna/accounts` has a matching active, a
103
108
 
104
109
  Account breakdowns report `api_equivalent_usd` for the API list-price value of the usage, plus `billable_usd`/`metered_api_usd` for known direct API spend and `subscription_included_usd` for usage covered by a subscription.
105
110
 
111
+ Subscription plans can be configured locally and are used by savings calculations:
112
+
113
+ ```bash
114
+ economy subscriptions set --provider cursor --plan pro --fee 20 --included 20 --agent cursor
115
+ economy subscriptions list
116
+ economy savings month
117
+ economy usage month --agent cursor
118
+ ```
119
+
106
120
  ## Pricing
107
121
 
108
122
  Default pricing is seeded into SQLite and can be edited locally:
@@ -150,7 +164,7 @@ economy config set webhook-url https://example.com/economy-webhook
150
164
  economy config webhook-test
151
165
  ```
152
166
 
153
- Budgets and goals can be global, project-scoped with `--project`, agent-scoped with `--agent`, or both. Valid agent scopes are `claude`, `takumi`, `codex`, and `gemini`.
167
+ Budgets and goals can be global, project-scoped with `--project`, agent-scoped with `--agent`, or both. Valid agent scopes are `claude`, `takumi`, `codex`, `gemini`, `opencode`, `cursor`, `pi`, and `hermes`.
154
168
 
155
169
  Budget webhooks fire after sync when the alert threshold is crossed. Failed webhook deliveries are not marked as fired, so the next sync can retry them.
156
170
 
@@ -169,7 +183,14 @@ Common endpoints:
169
183
  - `GET /api/sessions?agent=codex&limit=20`
170
184
  - `GET /api/sessions/:id/requests`
171
185
  - `GET /api/models`
172
- - `GET /api/projects`
186
+ - `GET /api/projects?period=month`
187
+ - `GET /api/breakdown?by=agent&period=month`
188
+ - `GET /api/accounts?period=month`
189
+ - `GET /api/usage?period=month`
190
+ - `GET /api/savings?period=month`
191
+ - `GET /api/subscriptions`
192
+ - `POST /api/subscriptions`
193
+ - `DELETE /api/subscriptions/:id`
173
194
  - `GET /api/budgets`
174
195
  - `POST /api/budgets`
175
196
  - `DELETE /api/budgets/:id`
@@ -183,13 +204,13 @@ Common endpoints:
183
204
  - `POST /api/sync`
184
205
  - `POST /api/billing/sync`
185
206
 
186
- Budget and goal mutation endpoints validate agent scopes against `claude`, `takumi`, `codex`, and `gemini`.
207
+ Budget, goal, and subscription mutation endpoints validate agent scopes against `claude`, `takumi`, `codex`, `gemini`, `opencode`, `cursor`, `pi`, and `hermes`.
187
208
 
188
209
  The server also serves the built dashboard when `dashboard/dist` is present.
189
210
 
190
211
  ## Native macOS Menubar
191
212
 
192
- The `menubar/` app is a native SwiftUI `MenuBarExtra` app, not Electron. It targets Swift 5.9+ and macOS 14+, and talks to the REST API exposed by `economy-serve`. The default server URL is `http://127.0.0.1:3456`.
213
+ The `menubar/` app is a native SwiftUI `MenuBarExtra` app, not Electron. It targets Swift 5.9+ and macOS 14+, and talks to the REST API exposed by `economy-serve`. It shows today/week/month spend, token and request counts, top agents, top accounts, top projects, subscription savings, usage snapshots, recent sessions, and fleet status. The default server URL is `http://127.0.0.1:3456`.
193
214
 
194
215
  Build it on macOS:
195
216
 
package/dist/cli/index.js CHANGED
@@ -4574,6 +4574,50 @@ function createHandler(db) {
4574
4574
  const agent = url.searchParams.get("agent") ?? undefined;
4575
4575
  return ok(querySavingsSummary(db, period, agent && isAgent(agent) ? agent : undefined));
4576
4576
  }
4577
+ if (path === "/api/subscriptions" && method === "GET") {
4578
+ return ok(listSubscriptions(db));
4579
+ }
4580
+ if (path === "/api/subscriptions" && method === "POST") {
4581
+ const body = await jsonBody(req);
4582
+ if (!body)
4583
+ return err("invalid JSON body");
4584
+ const provider = optionalString(body["provider"])?.trim();
4585
+ const plan = optionalString(body["plan"])?.trim();
4586
+ if (!provider)
4587
+ return err("provider is required");
4588
+ if (!plan)
4589
+ return err("plan is required");
4590
+ const monthlyFee = finiteNumber(body["monthly_fee_usd"] ?? body["fee_usd"] ?? 0);
4591
+ const includedUsage = finiteNumber(body["included_usage_usd"] ?? 0);
4592
+ if (monthlyFee == null || monthlyFee < 0)
4593
+ return err("monthly_fee_usd must be a non-negative number");
4594
+ if (includedUsage == null || includedUsage < 0)
4595
+ return err("included_usage_usd must be a non-negative number");
4596
+ const agent = optionalAgent(body["agent"]);
4597
+ if (agent === undefined)
4598
+ return err(AGENT_ERROR);
4599
+ const now = new Date().toISOString();
4600
+ const subscription = {
4601
+ id: optionalString(body["id"])?.trim() || randomUUID2(),
4602
+ agent,
4603
+ provider,
4604
+ plan,
4605
+ monthly_fee_usd: monthlyFee,
4606
+ included_usage_usd: includedUsage,
4607
+ billing_cycle_start: optionalString(body["billing_cycle_start"]),
4608
+ reset_policy: optionalString(body["reset_policy"]) ?? "monthly",
4609
+ active: body["active"] === false || body["active"] === 0 ? 0 : 1,
4610
+ created_at: optionalString(body["created_at"]) ?? now,
4611
+ updated_at: now
4612
+ };
4613
+ upsertSubscription(db, subscription);
4614
+ return ok(subscription);
4615
+ }
4616
+ const subscriptionMatch = path.match(/^\/api\/subscriptions\/(.+)$/);
4617
+ if (subscriptionMatch && method === "DELETE") {
4618
+ deleteSubscription(db, decodeURIComponent(subscriptionMatch[1]));
4619
+ return ok({ ok: true });
4620
+ }
4577
4621
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
4578
4622
  if (sessionRequestsMatch && method === "GET") {
4579
4623
  const sessionId = decodeURIComponent(sessionRequestsMatch[1]);
package/dist/mcp/index.js CHANGED
@@ -1343,6 +1343,12 @@ function upsertSubscription(db, sub) {
1343
1343
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1344
1344
  `).run(sub.id, sub.agent, sub.provider, sub.plan, sub.monthly_fee_usd, sub.included_usage_usd, sub.billing_cycle_start, sub.reset_policy, sub.active, sub.created_at, sub.updated_at);
1345
1345
  }
1346
+ function listSubscriptions(db) {
1347
+ return db.prepare(`SELECT * FROM subscriptions ORDER BY provider, plan`).all();
1348
+ }
1349
+ function deleteSubscription(db, id) {
1350
+ db.prepare(`DELETE FROM subscriptions WHERE id = ?`).run(id);
1351
+ }
1346
1352
  function upsertUsageSnapshot(db, snap) {
1347
1353
  const now = snap.updated_at ?? new Date().toISOString();
1348
1354
  const id = snap.id ?? `${snap.agent}-${snap.date}-${snap.metric}-${snap.machine_id}`;
@@ -3314,6 +3320,9 @@ var TOOL_NAMES = [
3314
3320
  "get_session_detail",
3315
3321
  "get_usage",
3316
3322
  "get_savings",
3323
+ "list_subscriptions",
3324
+ "set_subscription",
3325
+ "remove_subscription",
3317
3326
  "estimate_cost",
3318
3327
  "sync",
3319
3328
  "search_tools",
@@ -3348,6 +3357,9 @@ var TOOL_DESCRIPTIONS = {
3348
3357
  get_session_detail: "session_id(prefix ok) -> per-request breakdown with model, tokens, cost",
3349
3358
  get_usage: `period(today|week|month), agent?(${AGENTS.join("|")}) -> usage snapshots and all-machine summary`,
3350
3359
  get_savings: `period(today|week|month|year|all), agent?(${AGENTS.join("|")}) -> subscription/API-equivalent savings`,
3360
+ list_subscriptions: "no params -> configured subscription plans and included usage",
3361
+ set_subscription: `provider, plan, monthly_fee_usd?, included_usage_usd?, agent?(${AGENTS.join("|")}) -> create/update subscription plan`,
3362
+ remove_subscription: "id -> delete subscription plan",
3351
3363
  estimate_cost: "model, input_tokens?, output_tokens? -> pre-flight token cost estimate",
3352
3364
  sync: `sources(all|${AGENTS.join("|")}) -> ingest latest cost data`,
3353
3365
  search_tools: "query substring -> tool name list",
@@ -3608,6 +3620,57 @@ server.tool("get_usage", "Usage snapshots and fleet summary. period: today|week|
3608
3620
  server.tool("get_savings", "Subscription vs API savings summary", { period: z.enum(["today", "week", "month", "year", "all"]).optional(), agent: z.enum(AGENTS).optional() }, async ({ period, agent }) => {
3609
3621
  return text(JSON.stringify(querySavingsSummary(db, period ?? "month", agent), null, 2));
3610
3622
  });
3623
+ server.tool("list_subscriptions", "List configured subscription plans and included usage caps. No params.", {}, async () => {
3624
+ const rows = listSubscriptions(db);
3625
+ if (rows.length === 0)
3626
+ return text("No subscriptions configured.");
3627
+ const lines = ["id provider plan agent fee included active"];
3628
+ for (const row of rows) {
3629
+ lines.push(`${String(row["id"]).slice(0, 8).padEnd(9)}` + `${String(row["provider"]).slice(0, 12).padEnd(13)}` + `${String(row["plan"]).slice(0, 10).padEnd(11)}` + `${String(row["agent"] ?? "all").slice(0, 10).padEnd(11)}` + `${fmtUsd(Number(row["monthly_fee_usd"] ?? 0)).padEnd(10)}` + `${fmtUsd(Number(row["included_usage_usd"] ?? 0)).padEnd(10)}` + `${Number(row["active"] ?? 0) ? "yes" : "no"}`);
3630
+ }
3631
+ return text(lines.join(`
3632
+ `));
3633
+ });
3634
+ server.tool("set_subscription", `Create or update a subscription plan. agent may be ${AGENTS.join("|")}.`, {
3635
+ id: z.string().optional(),
3636
+ provider: z.string(),
3637
+ plan: z.string(),
3638
+ agent: z.enum(AGENTS).optional(),
3639
+ monthly_fee_usd: z.number().optional(),
3640
+ included_usage_usd: z.number().optional(),
3641
+ billing_cycle_start: z.string().optional(),
3642
+ reset_policy: z.string().optional(),
3643
+ active: z.boolean().optional()
3644
+ }, async (input) => {
3645
+ if (input.monthly_fee_usd != null && input.monthly_fee_usd < 0)
3646
+ return text("monthly_fee_usd must be non-negative");
3647
+ if (input.included_usage_usd != null && input.included_usage_usd < 0)
3648
+ return text("included_usage_usd must be non-negative");
3649
+ const now = new Date().toISOString();
3650
+ const subscription = {
3651
+ id: input.id?.trim() || randomUUID(),
3652
+ agent: input.agent ?? null,
3653
+ provider: input.provider.trim(),
3654
+ plan: input.plan.trim(),
3655
+ monthly_fee_usd: input.monthly_fee_usd ?? 0,
3656
+ included_usage_usd: input.included_usage_usd ?? 0,
3657
+ billing_cycle_start: input.billing_cycle_start ?? null,
3658
+ reset_policy: input.reset_policy ?? "monthly",
3659
+ active: input.active === false ? 0 : 1,
3660
+ created_at: now,
3661
+ updated_at: now
3662
+ };
3663
+ if (!subscription.provider)
3664
+ return text("provider is required");
3665
+ if (!subscription.plan)
3666
+ return text("plan is required");
3667
+ upsertSubscription(db, subscription);
3668
+ return text(JSON.stringify(subscription, null, 2));
3669
+ });
3670
+ server.tool("remove_subscription", "Remove a subscription plan by id.", { id: z.string() }, async ({ id }) => {
3671
+ deleteSubscription(db, id);
3672
+ return text(`Removed subscription ${id}`);
3673
+ });
3611
3674
  server.tool("estimate_cost", "Pre-flight cost estimate for token counts", { model: z.string(), input_tokens: z.number().optional(), output_tokens: z.number().optional() }, async ({ model, input_tokens, output_tokens }) => {
3612
3675
  const cost = computeCostFromDb(db, model, input_tokens ?? 0, output_tokens ?? 0, 0, 0, 0);
3613
3676
  return text(`${model}: ${fmtUsd(cost)} (${input_tokens ?? 0} in / ${output_tokens ?? 0} out)`);
@@ -1364,6 +1364,12 @@ function upsertSubscription(db, sub) {
1364
1364
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1365
1365
  `).run(sub.id, sub.agent, sub.provider, sub.plan, sub.monthly_fee_usd, sub.included_usage_usd, sub.billing_cycle_start, sub.reset_policy, sub.active, sub.created_at, sub.updated_at);
1366
1366
  }
1367
+ function listSubscriptions(db) {
1368
+ return db.prepare(`SELECT * FROM subscriptions ORDER BY provider, plan`).all();
1369
+ }
1370
+ function deleteSubscription(db, id) {
1371
+ db.prepare(`DELETE FROM subscriptions WHERE id = ?`).run(id);
1372
+ }
1367
1373
  function upsertUsageSnapshot(db, snap) {
1368
1374
  const now = snap.updated_at ?? new Date().toISOString();
1369
1375
  const id = snap.id ?? `${snap.agent}-${snap.date}-${snap.metric}-${snap.machine_id}`;
@@ -4112,6 +4118,50 @@ function createHandler(db) {
4112
4118
  const agent = url.searchParams.get("agent") ?? undefined;
4113
4119
  return ok(querySavingsSummary(db, period, agent && isAgent(agent) ? agent : undefined));
4114
4120
  }
4121
+ if (path === "/api/subscriptions" && method === "GET") {
4122
+ return ok(listSubscriptions(db));
4123
+ }
4124
+ if (path === "/api/subscriptions" && method === "POST") {
4125
+ const body = await jsonBody(req);
4126
+ if (!body)
4127
+ return err("invalid JSON body");
4128
+ const provider = optionalString(body["provider"])?.trim();
4129
+ const plan = optionalString(body["plan"])?.trim();
4130
+ if (!provider)
4131
+ return err("provider is required");
4132
+ if (!plan)
4133
+ return err("plan is required");
4134
+ const monthlyFee = finiteNumber(body["monthly_fee_usd"] ?? body["fee_usd"] ?? 0);
4135
+ const includedUsage = finiteNumber(body["included_usage_usd"] ?? 0);
4136
+ if (monthlyFee == null || monthlyFee < 0)
4137
+ return err("monthly_fee_usd must be a non-negative number");
4138
+ if (includedUsage == null || includedUsage < 0)
4139
+ return err("included_usage_usd must be a non-negative number");
4140
+ const agent = optionalAgent(body["agent"]);
4141
+ if (agent === undefined)
4142
+ return err(AGENT_ERROR);
4143
+ const now = new Date().toISOString();
4144
+ const subscription = {
4145
+ id: optionalString(body["id"])?.trim() || randomUUID(),
4146
+ agent,
4147
+ provider,
4148
+ plan,
4149
+ monthly_fee_usd: monthlyFee,
4150
+ included_usage_usd: includedUsage,
4151
+ billing_cycle_start: optionalString(body["billing_cycle_start"]),
4152
+ reset_policy: optionalString(body["reset_policy"]) ?? "monthly",
4153
+ active: body["active"] === false || body["active"] === 0 ? 0 : 1,
4154
+ created_at: optionalString(body["created_at"]) ?? now,
4155
+ updated_at: now
4156
+ };
4157
+ upsertSubscription(db, subscription);
4158
+ return ok(subscription);
4159
+ }
4160
+ const subscriptionMatch = path.match(/^\/api\/subscriptions\/(.+)$/);
4161
+ if (subscriptionMatch && method === "DELETE") {
4162
+ deleteSubscription(db, decodeURIComponent(subscriptionMatch[1]));
4163
+ return ok({ ok: true });
4164
+ }
4115
4165
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
4116
4166
  if (sessionRequestsMatch && method === "GET") {
4117
4167
  const sessionId = decodeURIComponent(sessionRequestsMatch[1]);
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/server/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;AAqC7D,UAAU,kBAAkB;IAC1B,EAAE,CAAC,EAAE,QAAQ,CAAA;IACb,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAqED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAAE,YAAY,SAAwB,IACzF,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAwB7D;AAQD,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,IACV,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAuT/D;AAED,wBAAgB,WAAW,CAAC,IAAI,SAAO,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAAC,OAAO,GAAG,CAAC,KAAK,CAAC,CAcvG"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/server/serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;AAsC7D,UAAU,kBAAkB;IAC1B,EAAE,CAAC,EAAE,QAAQ,CAAA;IACb,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAqED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAAE,YAAY,SAAwB,IACzF,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAwB7D;AAQD,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,IACV,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAgW/D;AAED,wBAAgB,WAAW,CAAC,IAAI,SAAO,EAAE,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAAC,OAAO,GAAG,CAAC,KAAK,CAAC,CAcvG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",