@hasna/economy 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/chunks/{billing-22az60hy.js → billing-7twygmtj.js} +2 -2
  3. package/dist/chunks/{config-hsp7ndhj.js → config-mjgbekxj.js} +2 -2
  4. package/dist/chunks/{index-9wa1f2kv.js → index-7ek4r0ww.js} +1 -1
  5. package/dist/chunks/{index-qvcafabj.js → index-9za4cmq2.js} +2 -2
  6. package/dist/chunks/{index-zs5dba3t.js → index-a1gh3d6t.js} +5 -4
  7. package/dist/chunks/{index-vajzfzgv.js → index-ep1pkyr3.js} +1 -1
  8. package/dist/chunks/{index-zqsx461z.js → index-gf3kan31.js} +2 -2
  9. package/dist/chunks/{index-dk5rq1rq.js → index-gtjqy0kd.js} +1 -1
  10. package/dist/chunks/{index-m12xttw1.js → index-h2mcj7gv.js} +82 -7
  11. package/dist/chunks/{index-c7h2rxx1.js → index-t0n32g8r.js} +1 -1
  12. package/dist/chunks/{index-f5jv6q5q.js → index-zgvmrqyc.js} +1 -1
  13. package/dist/chunks/{open-projects-8qm46m3d.js → open-projects-bj561k5j.js} +1 -1
  14. package/dist/chunks/{pricing-k9wsjwbz.js → pricing-bkgvzpe9.js} +1 -1
  15. package/dist/chunks/{pricing-462f8xkm.js → pricing-bn88mhqk.js} +1 -1
  16. package/dist/chunks/{pricing-vxejj1qa.js → pricing-nt31hf53.js} +1 -1
  17. package/dist/chunks/{serve-ept6cfbe.js → serve-14cgmx02.js} +14 -8
  18. package/dist/chunks/{sqlite-store-ht6frztj.js → sqlite-store-8t4anw98.js} +2 -2
  19. package/dist/chunks/{sqlite-store-13nhr1z0.js → sqlite-store-jfy0e8b6.js} +2 -2
  20. package/dist/chunks/{sqlite-store-gamn65xp.js → sqlite-store-r219qrjt.js} +2 -2
  21. package/dist/chunks/{tui-4bh067a3.js → tui-s6tpce3h.js} +4 -4
  22. package/dist/chunks/{watch-227kxmra.js → watch-cv5va8x6.js} +5 -5
  23. package/dist/chunks/{webhooks-nw4daw9y.js → webhooks-td46p6gk.js} +2 -2
  24. package/dist/cli/index.js +32 -28
  25. package/dist/cost-imports/contract.d.ts +84 -0
  26. package/dist/cost-imports/contract.d.ts.map +1 -0
  27. package/dist/cost-imports/decimal.d.ts +6 -0
  28. package/dist/cost-imports/decimal.d.ts.map +1 -0
  29. package/dist/cost-imports/providers.d.ts +4 -0
  30. package/dist/cost-imports/providers.d.ts.map +1 -0
  31. package/dist/index.js +66 -8
  32. package/dist/lib/analytics.d.ts +3 -0
  33. package/dist/lib/analytics.d.ts.map +1 -1
  34. package/dist/lib/comparison-contract.d.ts +8 -0
  35. package/dist/lib/comparison-contract.d.ts.map +1 -0
  36. package/dist/lib/comparison-test-contract.d.ts +3 -0
  37. package/dist/lib/comparison-test-contract.d.ts.map +1 -0
  38. package/dist/lib/store/index.d.ts.map +1 -1
  39. package/dist/mcp/index.js +69 -11
  40. package/dist/otel/index.js +1 -1
  41. package/dist/server/index.js +40 -6
  42. package/dist/server/service-operations.d.ts.map +1 -1
  43. package/dist/server/service-worker.js +40 -6
  44. package/docs/comparison-coverage.md +30 -0
  45. package/native/README.md +70 -2
  46. package/native/Tools/pair-qr.py +130 -34
  47. package/native/Tools/pair-qr.swift +19 -1
  48. package/package.json +1 -1
package/dist/mcp/index.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  MAX_INGEST_BYTES,
6
6
  MAX_INGEST_ROWS,
7
7
  assessCostFromDb,
8
+ buildCostSummary,
8
9
  cacheDir,
9
10
  calendarPeriodBounds,
10
11
  calendarPeriodWhere,
@@ -75,7 +76,7 @@ import {
75
76
  upsertUsageSnapshot,
76
77
  usageSnapshotFilterForPeriod,
77
78
  validateIngest
78
- } from "../chunks/index-vajzfzgv.js";
79
+ } from "../chunks/index-ep1pkyr3.js";
79
80
  import {
80
81
  __require
81
82
  } from "../chunks/index-p35cyhc4.js";
@@ -85,7 +86,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
85
86
  // package.json
86
87
  var package_default = {
87
88
  name: "@hasna/economy",
88
- version: "0.5.6",
89
+ version: "0.5.8",
89
90
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
90
91
  repository: {
91
92
  type: "git",
@@ -3705,7 +3706,7 @@ function backfillMachineId(db) {
3705
3706
  return { requests: Number(reqCount.changes), sessions: Number(sessCount.changes) };
3706
3707
  }
3707
3708
  async function recalculateZeroCostRequests(db) {
3708
- const { assessCostFromDb: assessCostFromDb2 } = await import("../chunks/pricing-vxejj1qa.js");
3709
+ const { assessCostFromDb: assessCostFromDb2 } = await import("../chunks/pricing-nt31hf53.js");
3709
3710
  const zeroRows = db.prepare(`SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, cache_create_5m_tokens, cache_create_1h_tokens
3710
3711
  FROM requests
3711
3712
  WHERE cost_usd = 0
@@ -3914,7 +3915,7 @@ async function postIngest(cloud, body) {
3914
3915
  }
3915
3916
  async function syncAllToCloud(cloud, opts = {}) {
3916
3917
  await runIngestJournalOperation(cloud, { action: "reconcile" });
3917
- const { openDatabase } = await import("../chunks/sqlite-store-gamn65xp.js");
3918
+ const { openDatabase } = await import("../chunks/sqlite-store-jfy0e8b6.js");
3918
3919
  const scratch = openDatabase(":memory:", true);
3919
3920
  ensurePricingSeeded(scratch);
3920
3921
  const cachePath = opts.cachePath ?? getIngestCachePath();
@@ -3948,6 +3949,54 @@ function syncOutcomeLines(result) {
3948
3949
  return (result.collections ?? []).map((row) => `${row.source}: ${row.status}${row.error_code ? ` (${row.error_code})` : ""}`);
3949
3950
  }
3950
3951
 
3952
+ // src/lib/comparison-contract.ts
3953
+ function comparisonDateBounds(from, to) {
3954
+ const valid = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + "T00:00:00Z")) && new Date(value + "T00:00:00Z").toISOString().slice(0, 10) === value;
3955
+ if (!valid(from) || !valid(to) || from > to) {
3956
+ throw new RangeError("from and to must be real YYYY-MM-DD UTC dates with from <= to");
3957
+ }
3958
+ const end = new Date(to + "T00:00:00Z");
3959
+ end.setUTCDate(end.getUTCDate() + 1);
3960
+ const exclusive = end.toISOString().slice(0, 10);
3961
+ if (!valid(exclusive))
3962
+ throw new RangeError("to exceeds the supported UTC date range");
3963
+ return { time_zone: "UTC", start_date: from, end_date_exclusive: exclusive };
3964
+ }
3965
+ function requireCurrentRangeStats(value, from, to) {
3966
+ if (!value || typeof value !== "object")
3967
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Invalid response");
3968
+ const report = value;
3969
+ let money;
3970
+ try {
3971
+ money = requireCurrentCostSummary(report.money);
3972
+ } catch {
3973
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: The service must support comparison coverage");
3974
+ }
3975
+ for (const key of ["sessions", "requests", "tokens"]) {
3976
+ if (!Number.isSafeInteger(report[key]) || report[key] < 0)
3977
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Invalid " + key);
3978
+ }
3979
+ if (report.cost !== money.total_usd || report.requests !== money.request_rows || report.tokens !== money.tokens || money.session_only_sessions !== 0 || money.session_only_value_usd !== 0 || money.requests !== money.request_rows) {
3980
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Inconsistent monetary coverage");
3981
+ }
3982
+ const knownRows = money.request_rows - money.unknown_price_requests;
3983
+ const completeness = money.request_rows === 0 ? "no_records" : money.unknown_price_requests === 0 ? "complete" : knownRows > 0 ? "partial" : "unavailable";
3984
+ if (money.completeness !== completeness || knownRows === 0 && money.total_usd !== 0 || money.sessions > money.request_rows || money.request_rows === 0 && money.tokens !== 0) {
3985
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Inconsistent request coverage");
3986
+ }
3987
+ const bounds = money.period_bounds;
3988
+ if (!bounds.start_date || !bounds.end_date_exclusive || bounds.start_date >= bounds.end_date_exclusive) {
3989
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Missing comparison dates");
3990
+ }
3991
+ if (from !== undefined || to !== undefined) {
3992
+ const expected = comparisonDateBounds(from, to);
3993
+ if (bounds.start_date !== expected.start_date || bounds.end_date_exclusive !== expected.end_date_exclusive) {
3994
+ throw new Error("COMPARISON_REPORT_UNAVAILABLE: Response dates do not match the requested range");
3995
+ }
3996
+ }
3997
+ return report;
3998
+ }
3999
+
3951
4000
  // src/lib/store/index.ts
3952
4001
  import { randomUUID } from "crypto";
3953
4002
 
@@ -4888,11 +4937,20 @@ function queryExportRows(db, type, period) {
4888
4937
  return db.prepare(`SELECT * FROM economy_effective_sessions WHERE ${where} ORDER BY started_at DESC`).all();
4889
4938
  }
4890
4939
  function queryRangeStats(db, from, to) {
4891
- const r = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as requests,
4892
- COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0) as tokens
4893
- FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?`).get(from, to);
4940
+ const bounds = comparisonDateBounds(from, to);
4941
+ const groups = db.prepare(`
4942
+ SELECT cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END AS zero_cost,
4943
+ SUM(cost_usd) AS cost_usd, COUNT(*) AS requests,
4944
+ COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens,
4945
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, COALESCE(SUM(cache_create_tokens),0) AS cache_create_tokens,
4946
+ COALESCE(SUM(cache_create_1h_tokens),0) AS cache_create_1h_tokens
4947
+ FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?
4948
+ GROUP BY cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END
4949
+ `).all(from, to);
4950
+ const requestSessions = db.prepare("SELECT COUNT(DISTINCT session_id) AS sessions FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?").get(from, to);
4951
+ const money = buildCostSummary("all", bounds, groups, { sessions: 0, requests: 0, tokens: 0, cost_usd: 0 }, requestSessions.sessions, listModelPricing(db));
4894
4952
  const s = db.prepare(`SELECT COUNT(*) as sessions FROM economy_effective_sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
4895
- return { cost: r.cost, requests: r.requests, tokens: r.tokens, sessions: s.sessions };
4953
+ return { cost: money.total_usd, money, requests: money.request_rows, tokens: money.tokens, sessions: s.sessions };
4896
4954
  }
4897
4955
  function queryForecast(db, now2 = new Date) {
4898
4956
  const month = resolveReportingInterval({ kind: "calendar", period: "month" }, now2);
@@ -4956,7 +5014,7 @@ class LocalStore2 {
4956
5014
  _db;
4957
5015
  async db() {
4958
5016
  if (!this._db) {
4959
- const { openDatabase } = await import("../chunks/sqlite-store-gamn65xp.js");
5017
+ const { openDatabase } = await import("../chunks/sqlite-store-jfy0e8b6.js");
4960
5018
  this._db = openDatabase();
4961
5019
  ensurePricingSeeded(this._db);
4962
5020
  }
@@ -5233,7 +5291,7 @@ class ApiStore2 {
5233
5291
  return cloudListItems(this.cloud, "export", q({ type, period }));
5234
5292
  }
5235
5293
  async rangeStats(from, to) {
5236
- return cloudObject(this.cloud, "/compare", q({ from, to }));
5294
+ return requireCurrentRangeStats(await cloudObject(this.cloud, "/compare", q({ from, to })), from, to);
5237
5295
  }
5238
5296
  async forecast() {
5239
5297
  return cloudObject(this.cloud, "/forecast");
@@ -5319,7 +5377,7 @@ function buildServer() {
5319
5377
  let _db;
5320
5378
  const localDb = async () => {
5321
5379
  if (!_db) {
5322
- const { openDatabase } = await import("../chunks/sqlite-store-gamn65xp.js");
5380
+ const { openDatabase } = await import("../chunks/sqlite-store-jfy0e8b6.js");
5323
5381
  _db = openDatabase();
5324
5382
  ensurePricingSeeded(_db);
5325
5383
  }
@@ -3480,7 +3480,7 @@ async function pushIngestRows(cloud, scratch) {
3480
3480
  // package.json
3481
3481
  var package_default = {
3482
3482
  name: "@hasna/economy",
3483
- version: "0.5.6",
3483
+ version: "0.5.8",
3484
3484
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
3485
3485
  repository: {
3486
3486
  type: "git",
@@ -5578,7 +5578,7 @@ function isAuthorizedRequest(req, path) {
5578
5578
  // package.json
5579
5579
  var package_default = {
5580
5580
  name: "@hasna/economy",
5581
- version: "0.5.6",
5581
+ version: "0.5.8",
5582
5582
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5583
5583
  repository: {
5584
5584
  type: "git",
@@ -11839,6 +11839,25 @@ function buildBrief(db, opts = {}) {
11839
11839
  };
11840
11840
  }
11841
11841
 
11842
+ // src/lib/analytics.ts
11843
+ init_database();
11844
+ init_summary_contract();
11845
+
11846
+ // src/lib/comparison-contract.ts
11847
+ init_summary_contract();
11848
+ function comparisonDateBounds(from, to) {
11849
+ const valid = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + "T00:00:00Z")) && new Date(value + "T00:00:00Z").toISOString().slice(0, 10) === value;
11850
+ if (!valid(from) || !valid(to) || from > to) {
11851
+ throw new RangeError("from and to must be real YYYY-MM-DD UTC dates with from <= to");
11852
+ }
11853
+ const end = new Date(to + "T00:00:00Z");
11854
+ end.setUTCDate(end.getUTCDate() + 1);
11855
+ const exclusive = end.toISOString().slice(0, 10);
11856
+ if (!valid(exclusive))
11857
+ throw new RangeError("to exceeds the supported UTC date range");
11858
+ return { time_zone: "UTC", start_date: from, end_date_exclusive: exclusive };
11859
+ }
11860
+
11842
11861
  // src/lib/analytics.ts
11843
11862
  function queryProjectDetail(db, nameOrPath, now = new Date) {
11844
11863
  const dailyBounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: 14 }, now);
@@ -11888,11 +11907,20 @@ function queryExportRows(db, type, period) {
11888
11907
  return db.prepare(`SELECT * FROM economy_effective_sessions WHERE ${where} ORDER BY started_at DESC`).all();
11889
11908
  }
11890
11909
  function queryRangeStats(db, from, to) {
11891
- const r = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as requests,
11892
- COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0) as tokens
11893
- FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?`).get(from, to);
11910
+ const bounds = comparisonDateBounds(from, to);
11911
+ const groups = db.prepare(`
11912
+ SELECT cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END AS zero_cost,
11913
+ SUM(cost_usd) AS cost_usd, COUNT(*) AS requests,
11914
+ COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens,
11915
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, COALESCE(SUM(cache_create_tokens),0) AS cache_create_tokens,
11916
+ COALESCE(SUM(cache_create_1h_tokens),0) AS cache_create_1h_tokens
11917
+ FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?
11918
+ GROUP BY cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END
11919
+ `).all(from, to);
11920
+ const requestSessions = db.prepare("SELECT COUNT(DISTINCT session_id) AS sessions FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?").get(from, to);
11921
+ const money = buildCostSummary("all", bounds, groups, { sessions: 0, requests: 0, tokens: 0, cost_usd: 0 }, requestSessions.sessions, listModelPricing(db));
11894
11922
  const s = db.prepare(`SELECT COUNT(*) as sessions FROM economy_effective_sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
11895
- return { cost: r.cost, requests: r.requests, tokens: r.tokens, sessions: s.sessions };
11923
+ return { cost: money.total_usd, money, requests: money.request_rows, tokens: money.tokens, sessions: s.sessions };
11896
11924
  }
11897
11925
  function queryForecast(db, now = new Date) {
11898
11926
  const month = resolveReportingInterval({ kind: "calendar", period: "month" }, now);
@@ -12394,7 +12422,13 @@ function createDataHandler(db, actorId) {
12394
12422
  const to = url.searchParams.get("to")?.trim();
12395
12423
  if (!from || !to)
12396
12424
  return err("from and to are required (YYYY-MM-DD)");
12397
- return ok(queryRangeStats(db, from, to));
12425
+ try {
12426
+ return ok(queryRangeStats(db, from, to));
12427
+ } catch (error) {
12428
+ if (error instanceof RangeError)
12429
+ return err(error.message);
12430
+ throw error;
12431
+ }
12398
12432
  }
12399
12433
  if (path === "/api/forecast" && method === "GET") {
12400
12434
  return ok(queryForecast(db));
@@ -1 +1 @@
1
- {"version":3,"file":"service-operations.d.ts","sourceRoot":"","sources":["../../src/server/service-operations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAuCxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAgkB5E,gHAAgH;AAChH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAe/G"}
1
+ {"version":3,"file":"service-operations.d.ts","sourceRoot":"","sources":["../../src/server/service-operations.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,IAAI,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAuCxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAokB5E,gHAAgH;AAChH,wBAAsB,uBAAuB,CAAC,EAAE,EAAE,QAAQ,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,aAAa,CAAC,CAe/G"}
@@ -7264,6 +7264,25 @@ function buildBrief(db, opts = {}) {
7264
7264
  };
7265
7265
  }
7266
7266
 
7267
+ // src/lib/analytics.ts
7268
+ init_database();
7269
+ init_summary_contract();
7270
+
7271
+ // src/lib/comparison-contract.ts
7272
+ init_summary_contract();
7273
+ function comparisonDateBounds(from, to) {
7274
+ const valid = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + "T00:00:00Z")) && new Date(value + "T00:00:00Z").toISOString().slice(0, 10) === value;
7275
+ if (!valid(from) || !valid(to) || from > to) {
7276
+ throw new RangeError("from and to must be real YYYY-MM-DD UTC dates with from <= to");
7277
+ }
7278
+ const end = new Date(to + "T00:00:00Z");
7279
+ end.setUTCDate(end.getUTCDate() + 1);
7280
+ const exclusive = end.toISOString().slice(0, 10);
7281
+ if (!valid(exclusive))
7282
+ throw new RangeError("to exceeds the supported UTC date range");
7283
+ return { time_zone: "UTC", start_date: from, end_date_exclusive: exclusive };
7284
+ }
7285
+
7267
7286
  // src/lib/analytics.ts
7268
7287
  function queryProjectDetail(db, nameOrPath, now = new Date) {
7269
7288
  const dailyBounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: 14 }, now);
@@ -7313,11 +7332,20 @@ function queryExportRows(db, type, period) {
7313
7332
  return db.prepare(`SELECT * FROM economy_effective_sessions WHERE ${where} ORDER BY started_at DESC`).all();
7314
7333
  }
7315
7334
  function queryRangeStats(db, from, to) {
7316
- const r = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as requests,
7317
- COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0) as tokens
7318
- FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?`).get(from, to);
7335
+ const bounds = comparisonDateBounds(from, to);
7336
+ const groups = db.prepare(`
7337
+ SELECT cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END AS zero_cost,
7338
+ SUM(cost_usd) AS cost_usd, COUNT(*) AS requests,
7339
+ COALESCE(SUM(input_tokens),0) AS input_tokens, COALESCE(SUM(output_tokens),0) AS output_tokens,
7340
+ COALESCE(SUM(cache_read_tokens),0) AS cache_read_tokens, COALESCE(SUM(cache_create_tokens),0) AS cache_create_tokens,
7341
+ COALESCE(SUM(cache_create_1h_tokens),0) AS cache_create_1h_tokens
7342
+ FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?
7343
+ GROUP BY cost_basis, model, CASE WHEN cost_usd = 0 THEN 1 WHEN cost_usd < 0 THEN 2 ELSE 0 END
7344
+ `).all(from, to);
7345
+ const requestSessions = db.prepare("SELECT COUNT(DISTINCT session_id) AS sessions FROM economy_effective_requests WHERE DATE(timestamp) BETWEEN ? AND ?").get(from, to);
7346
+ const money = buildCostSummary("all", bounds, groups, { sessions: 0, requests: 0, tokens: 0, cost_usd: 0 }, requestSessions.sessions, listModelPricing(db));
7319
7347
  const s = db.prepare(`SELECT COUNT(*) as sessions FROM economy_effective_sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
7320
- return { cost: r.cost, requests: r.requests, tokens: r.tokens, sessions: s.sessions };
7348
+ return { cost: money.total_usd, money, requests: money.request_rows, tokens: money.tokens, sessions: s.sessions };
7321
7349
  }
7322
7350
  function queryForecast(db, now = new Date) {
7323
7351
  const month = resolveReportingInterval({ kind: "calendar", period: "month" }, now);
@@ -7426,7 +7454,7 @@ import { randomUUID } from "crypto";
7426
7454
  // package.json
7427
7455
  var package_default = {
7428
7456
  name: "@hasna/economy",
7429
- version: "0.5.6",
7457
+ version: "0.5.8",
7430
7458
  description: "AI coding cost tracker \u2014 CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
7431
7459
  repository: {
7432
7460
  type: "git",
@@ -7945,7 +7973,13 @@ function createDataHandler(db, actorId) {
7945
7973
  const to = url.searchParams.get("to")?.trim();
7946
7974
  if (!from || !to)
7947
7975
  return err("from and to are required (YYYY-MM-DD)");
7948
- return ok(queryRangeStats(db, from, to));
7976
+ try {
7977
+ return ok(queryRangeStats(db, from, to));
7978
+ } catch (error) {
7979
+ if (error instanceof RangeError)
7980
+ return err(error.message);
7981
+ throw error;
7982
+ }
7949
7983
  }
7950
7984
  if (path === "/api/forecast" && method === "GET") {
7951
7985
  return ok(queryForecast(db));
@@ -0,0 +1,30 @@
1
+ # Historical comparison coverage
2
+
3
+ The comparison endpoint returns request/token counts and session counts as before.
4
+ Its numeric `cost` field is a **known usage value subtotal**, following the same
5
+ contract as the summary report's `total_usd`. It is not a billed or paid amount.
6
+
7
+ `money` carries the full monetary evidence:
8
+
9
+ - `complete_total_usd` is null for incomplete or empty selected request data.
10
+ - Recorded metered amounts, estimated usage value and subscription-included
11
+ value are separate subtotals.
12
+ - Unknown billing basis and unavailable pricing remain explicit counts.
13
+ - `source_coverage: "not_established"` makes clear that completeness of the
14
+ selected stored records does not establish complete provider collection.
15
+ - UTC bounds identify the selected inclusive date range through an exclusive
16
+ ending date.
17
+
18
+ The CLI displays usage value as unavailable when the complete total is null,
19
+ alongside the known subtotal and coverage. It calculates a monetary percentage
20
+ only when both selected sets are complete and the baseline is nonzero.
21
+ Genuinely recorded free usage can be complete zero; no records cannot.
22
+
23
+ The hosted SDK refuses older responses without coverage and responses for a
24
+ different date range. Deploy the service support before relying on a new
25
+ comparison client. No stored original amount or billing classification is
26
+ rewritten by this read-only reporting correction.
27
+
28
+ Comparison usage values cover request rows. Lifetime-only session amounts are
29
+ not allocated to dates by this endpoint. Session counts retain the existing
30
+ started-within-range definition.
package/native/README.md CHANGED
@@ -30,8 +30,28 @@ Never run real codes in a captured agent terminal. The helper obtains a
30
30
  single-use code, hands only that temporary code to the CoreImage renderer and
31
31
  clears the display within five minutes. `--self-test` decodes a synthetic QR;
32
32
  `test_pair_qr.py` proves transcript/agent-key refusal and credential confinement.
33
- The existing Identities human login flow remains the bootstrap when no human
34
- session exists; pairing does not create or assume an account.
33
+ Without a saved human session, use
34
+ `python3 native/Tools/pair-qr.py --tenant <workspace-id> --login-email <human-email>`.
35
+ The helper opens the owner's Terminal, asks for confirmation before requesting
36
+ one sign-in email, and reads the verification code there with echo disabled.
37
+ It verifies a human principal and the selected workspace before displaying the
38
+ one-use QR. The new setup session stays in memory. On renderer exit or catchable
39
+ Terminal shutdown (HUP, TERM or Ctrl-C), setup makes one timeout-limited logout
40
+ attempt and reports an unconfirmed result as a failure. Force-kill, a reboot,
41
+ network failure or an interrupted verification response can prevent confirmation;
42
+ check the account's sessions before retrying in those cases. An existing session
43
+ supplied with `--session-ref` is retained.
44
+ Only the temporary QR code reaches the renderer, and no response or verification
45
+ code is returned to the agent terminal.
46
+
47
+ For a first account, `--create-account` offers signup through the existing
48
+ Identities flow instead of login. It requires direct confirmation in the owner
49
+ Terminal before account creation or email delivery. It does not create an
50
+ organization, grant an owner role, alter memberships or configure Economy's
51
+ store binding. An existing-account refusal is not retried as login, and failed
52
+ delivery/verification is not retried automatically. Never type the owner's code
53
+ for them or copy it into an agent session. A source/fixture test does not prove
54
+ real email delivery, account authority or a successful phone connection.
35
55
 
36
56
  The adapter verifies Economy's `/auth/session` subject, tenant, effective scopes
37
57
  and integer expiry. Viewer sign-in never requests write access. Refresh and period
@@ -129,3 +149,51 @@ fixture checks. Owner-facing screen captures go through Designs.
129
149
  Apple references: [MenuBarExtra](https://developer.apple.com/documentation/swiftui/menubarextra),
130
150
  [native design adoption](https://developer.apple.com/videos/play/wwdc2025/323/),
131
151
  [application delegate adaptor](https://developer.apple.com/documentation/swiftui/nsapplicationdelegateadaptor).
152
+
153
+ ## Vault-backed Mac release signing
154
+
155
+ When the exact approved Developer ID certificate is not in the login Keychain,
156
+ `Tools/prepare-signing.py` prepares a fresh private release Keychain. It does not
157
+ unlock the login Keychain, sign the application, submit it to Apple or install it.
158
+ Use the supported Secrets consumer to project these inputs directly to this
159
+ process: `ECONOMY_SIGNING_CERTIFICATE_B64` (DER certificate),
160
+ `ECONOMY_SIGNING_PRIVATE_KEY` (PEM), `ECONOMY_NOTARY_PRIVATE_KEY` (PEM),
161
+ `ECONOMY_NOTARY_KEY_ID` and `ECONOMY_NOTARY_ISSUER`. Resolve the approved
162
+ references and existing Apple team before execution. Never put their values in
163
+ arguments, output or repository files.
164
+
165
+ Run `python3 Tools/prepare-signing.py --output FRESH_SCRATCH_PATH --certificate EXACT_SHA1`.
166
+ The output must be a new directory under the session Scratchpad. The helper
167
+ imports credentials in memory, verifies the exact certificate, preserves the
168
+ existing ordered search-list entries and default Keychain, and binds access to codesign. Its
169
+ random password enters Apple's non-echoing prompt through its own terminal,
170
+ never the command arguments. Apple's notarytool requires a regular key file and
171
+ refuses stdin and FIFOs. The consumer writes that one input exclusively at mode
172
+ 0600 inside its fresh mode 0700 directory and passes only its path. It stores the
173
+ profile in the encrypted Keychain; credential-consumer output is suppressed.
174
+ The receipt explicitly names the sensitive input awaiting recoverable cleanup.
175
+ If macOS registers the new Keychain, that owned addition is retained and reported
176
+ in the receipt. The helper never replaces the shared search list; concurrent
177
+ additions are retained. An explicit Keychain path also works without registration.
178
+
179
+ Pass the resulting absolute Keychain path with `--keychain` to the existing
180
+ `release.py sign-mac`, `submit-mac` and `check-mac` stages. Their separate
181
+ signature, exact artifact, submission and acceptance checks still apply.
182
+ Omitting the option retains the existing login-Keychain route. Preparation is
183
+ not signing or notarization proof.
184
+
185
+ Keep the preparation receipt and any failed artifact. Do not replay an uncertain
186
+ submission. On failure, timeout, Ctrl-C, HUP or TERM, preparation settles its own
187
+ child, locks the owned Keychain and inspects the original entries and default.
188
+ An incomplete lock or inspection is explicitly recovery-unverified. SIGKILL,
189
+ power loss and a killed parent cannot guarantee cleanup; inspect the retained
190
+ artifact before retrying. After confirmed profile storage, preserve/read back
191
+ the private input and remove that exact owned file through Trash; report pending
192
+ cleanup on failure and retain its restricted permissions. Never treat preparation
193
+ as complete release acceptance while sensitive cleanup remains. After the release,
194
+ lock the exact release Keychain,
195
+ preserve and read back its encrypted bytes through Trash, then retire only its
196
+ owned registration through the supported Keychain operation. Verify other
197
+ entries and the default remain; never replace the complete list during cleanup.
198
+ The Keychain has a 15-minute idle lock; an expired lock requires a fresh
199
+ preparation, not a password copied into a command or a login-Keychain change.