@hasna/economy 0.5.2 → 0.5.4

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 +12 -0
  2. package/README.md +4 -2
  3. package/dist/chunks/{billing-bkpxy4kv.js → billing-22az60hy.js} +2 -2
  4. package/dist/chunks/{config-j9mv3ehk.js → config-hsp7ndhj.js} +2 -2
  5. package/dist/chunks/{index-npsjjv96.js → index-9arv0b1j.js} +162 -36
  6. package/dist/chunks/{index-hesxq90g.js → index-9wa1f2kv.js} +1 -1
  7. package/dist/chunks/{index-zpvea08j.js → index-c7h2rxx1.js} +38 -12
  8. package/dist/chunks/{index-9d2aky06.js → index-ct3zdefz.js} +3 -3
  9. package/dist/chunks/{index-k540hfww.js → index-dk5rq1rq.js} +1 -1
  10. package/dist/chunks/{index-ez9thsr7.js → index-f5jv6q5q.js} +38 -12
  11. package/dist/chunks/{index-dj3tctqh.js → index-n1wwqxfe.js} +14 -13
  12. package/dist/chunks/{index-kzwqw3ve.js → index-nheq2nrz.js} +2 -2
  13. package/dist/chunks/{index-2hwzmfgv.js → index-vajzfzgv.js} +38 -12
  14. package/dist/chunks/{open-projects-6z4pq6mx.js → open-projects-8qm46m3d.js} +1 -1
  15. package/dist/chunks/{pricing-vf2pycmh.js → pricing-462f8xkm.js} +1 -1
  16. package/dist/chunks/{pricing-f80p81gr.js → pricing-k9wsjwbz.js} +1 -1
  17. package/dist/chunks/{pricing-y21mcjsa.js → pricing-vxejj1qa.js} +1 -1
  18. package/dist/chunks/{serve-p2yarhm8.js → serve-ra46ms69.js} +18 -10
  19. package/dist/chunks/{sqlite-store-st99garx.js → sqlite-store-13nhr1z0.js} +2 -2
  20. package/dist/chunks/{sqlite-store-501f32ts.js → sqlite-store-gamn65xp.js} +2 -2
  21. package/dist/chunks/{sqlite-store-fq4natnf.js → sqlite-store-ht6frztj.js} +2 -2
  22. package/dist/chunks/{tui-x64hjfrh.js → tui-a1tvcw2j.js} +4 -4
  23. package/dist/chunks/{watch-bxstjpbx.js → watch-rvd3dn6r.js} +5 -5
  24. package/dist/chunks/{webhooks-k4fh2t9c.js → webhooks-nw4daw9y.js} +20 -9
  25. package/dist/cli/index.js +27 -27
  26. package/dist/db/database.d.ts +2 -2
  27. package/dist/db/database.d.ts.map +1 -1
  28. package/dist/db/sync-pg.d.ts.map +1 -1
  29. package/dist/index.js +15 -14
  30. package/dist/lib/accounts-store.d.ts +25 -1
  31. package/dist/lib/accounts-store.d.ts.map +1 -1
  32. package/dist/lib/accounts.d.ts.map +1 -1
  33. package/dist/lib/analytics.d.ts +1 -1
  34. package/dist/lib/analytics.d.ts.map +1 -1
  35. package/dist/lib/periods.d.ts +20 -1
  36. package/dist/lib/periods.d.ts.map +1 -1
  37. package/dist/lib/spikes.d.ts +3 -3
  38. package/dist/lib/spikes.d.ts.map +1 -1
  39. package/dist/lib/subscriptions-registry.d.ts +26 -0
  40. package/dist/lib/subscriptions-registry.d.ts.map +1 -0
  41. package/dist/lib/test-keychain-fixture.d.ts +3 -2
  42. package/dist/lib/test-keychain-fixture.d.ts.map +1 -1
  43. package/dist/mcp/index.js +245 -118
  44. package/dist/otel/index.js +56 -27
  45. package/dist/server/index.js +246 -77
  46. package/dist/server/service-operations.d.ts.map +1 -1
  47. package/dist/server/service-worker.js +237 -68
  48. package/package.json +1 -1
@@ -1117,12 +1117,32 @@ function meteredBudgetAmounts(groups, limit, threshold) {
1117
1117
  function ymd(date2) {
1118
1118
  return date2.toISOString().substring(0, 10);
1119
1119
  }
1120
- function calendarPeriodBounds(period, now = new Date) {
1120
+ function resolveReportingInterval(interval, now = new Date) {
1121
1121
  if (!Number.isFinite(now.getTime()))
1122
1122
  throw new RangeError("Invalid reporting clock");
1123
- const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
1123
+ const start = new Date(now);
1124
+ start.setUTCHours(0, 0, 0, 0);
1124
1125
  const end = new Date(start);
1125
- switch (period) {
1126
+ if (interval.kind === "rolling") {
1127
+ if (!Number.isSafeInteger(interval.count) || interval.count < 1)
1128
+ throw new RangeError("Invalid reporting window");
1129
+ if (interval.unit === "hour") {
1130
+ start.setTime(now.getTime() - interval.count * 3600000);
1131
+ if (!Number.isFinite(start.getTime()))
1132
+ throw new RangeError("Invalid reporting window");
1133
+ return { time_zone: "UTC", start: start.toISOString(), end_exclusive: now.toISOString() };
1134
+ }
1135
+ if (interval.unit !== "day")
1136
+ throw new RangeError("Invalid reporting unit");
1137
+ start.setUTCDate(start.getUTCDate() - interval.count + 1);
1138
+ end.setUTCDate(end.getUTCDate() + 1);
1139
+ if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime()))
1140
+ throw new RangeError("Invalid reporting window");
1141
+ return { time_zone: "UTC", start: ymd(start), end_exclusive: ymd(end) };
1142
+ }
1143
+ if (interval.kind !== "calendar")
1144
+ throw new RangeError("Invalid reporting interval");
1145
+ switch (interval.period) {
1126
1146
  case "today":
1127
1147
  end.setUTCDate(end.getUTCDate() + 1);
1128
1148
  break;
@@ -1145,11 +1165,15 @@ function calendarPeriodBounds(period, now = new Date) {
1145
1165
  end.setUTCFullYear(end.getUTCFullYear() + 1);
1146
1166
  break;
1147
1167
  case "all":
1148
- return { time_zone: "UTC", start_date: null, end_date_exclusive: null };
1168
+ return { time_zone: "UTC", start: null, end_exclusive: null };
1149
1169
  default:
1150
1170
  throw new RangeError("Invalid reporting period");
1151
1171
  }
1152
- return { time_zone: "UTC", start_date: ymd(start), end_date_exclusive: ymd(end) };
1172
+ return { time_zone: "UTC", start: ymd(start), end_exclusive: ymd(end) };
1173
+ }
1174
+ function calendarPeriodBounds(period, now = new Date) {
1175
+ const bounds = resolveReportingInterval({ kind: "calendar", period }, now);
1176
+ return { time_zone: bounds.time_zone, start_date: bounds.start, end_date_exclusive: bounds.end_exclusive };
1153
1177
  }
1154
1178
  function calendarPeriodWhere(period, column, now = new Date) {
1155
1179
  if (!/^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/i.test(column)) {
@@ -3020,20 +3044,22 @@ function queryAccountBreakdownSince(db, since) {
3020
3044
  ORDER BY api_equivalent_usd DESC
3021
3045
  `).all(since, since);
3022
3046
  }
3023
- function queryDailyBreakdown(db, days = 30, machine) {
3047
+ function queryDailyBreakdown(db, days = 30, machine, now = new Date) {
3048
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
3024
3049
  const machineClause = machine ? " AND machine_id = ?" : "";
3025
- const params = machine ? [`-${days}`, machine] : [`-${days}`];
3050
+ const params = machine ? [bounds.start, bounds.end_exclusive, machine] : [bounds.start, bounds.end_exclusive];
3026
3051
  return db.prepare(`
3027
3052
  SELECT DATE(timestamp) as date, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
3028
3053
  FROM economy_effective_requests
3029
- WHERE timestamp >= DATE('now', ? || ' days')${machineClause}
3054
+ WHERE timestamp >= ? AND timestamp < ?${machineClause}
3030
3055
  GROUP BY DATE(timestamp), agent
3031
3056
  ORDER BY date ASC
3032
3057
  `).all(...params);
3033
3058
  }
3034
- function queryHourlyBreakdown(db, machine, hours) {
3035
- const clauses = hours == null ? [`DATE(timestamp) = DATE('now')`] : [`DATETIME(timestamp) >= DATETIME('now', ?)`];
3036
- const params = hours == null ? [] : [`-${hours} hours`];
3059
+ function queryHourlyBreakdown(db, machine, hours, now = new Date) {
3060
+ const bounds = resolveReportingInterval(hours == null ? { kind: "calendar", period: "today" } : { kind: "rolling", unit: "hour", count: hours }, now);
3061
+ const clauses = hours == null ? ["timestamp >= ? AND timestamp < ?"] : ["STRFTIME('%Y-%m-%dT%H:%M:%fZ', timestamp) >= ? AND STRFTIME('%Y-%m-%dT%H:%M:%fZ', timestamp) < ?"];
3062
+ const params = [bounds.start, bounds.end_exclusive];
3037
3063
  if (machine) {
3038
3064
  clauses.push("machine_id = ?");
3039
3065
  params.push(machine);
@@ -3723,12 +3749,21 @@ var init_config = __esm(() => {
3723
3749
  function detectCostSpikes(dailyTotals, opts) {
3724
3750
  const windowDays = opts?.windowDays ?? 7;
3725
3751
  const multiplier = opts?.multiplier ?? 2;
3752
+ if (!Number.isSafeInteger(windowDays) || windowDays < 1)
3753
+ throw new RangeError("Invalid spike window");
3754
+ if (!Number.isFinite(multiplier) || multiplier <= 0)
3755
+ throw new RangeError("Invalid spike multiplier");
3726
3756
  const sorted = [...dailyTotals].sort((a, b) => a.date.localeCompare(b.date));
3727
3757
  const spikes = [];
3728
3758
  for (let i = windowDays;i < sorted.length; i++) {
3729
3759
  const window = sorted.slice(i - windowDays, i);
3730
- const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
3731
3760
  const current = sorted[i];
3761
+ const previous = new Date(current.date + "T00:00:00.000Z");
3762
+ previous.setUTCDate(previous.getUTCDate() - 1);
3763
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: windowDays }, previous);
3764
+ if (window[0].date !== bounds.start || window[window.length - 1].date !== previous.toISOString().slice(0, 10) || new Set(window.map((day) => day.date)).size !== windowDays)
3765
+ continue;
3766
+ const avg = window.reduce((s, d) => s + d.cost_usd, 0) / window.length;
3732
3767
  if (avg > 0 && current.cost_usd > avg * multiplier) {
3733
3768
  spikes.push({
3734
3769
  date: current.date,
@@ -3740,21 +3775,23 @@ function detectCostSpikes(dailyTotals, opts) {
3740
3775
  }
3741
3776
  return spikes;
3742
3777
  }
3743
- function queryRecentSpikes(db, days = 14) {
3778
+ function queryRecentSpikes(db, days = 14, now = new Date) {
3779
+ const bounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
3744
3780
  const rows = db.prepare(`
3745
3781
  SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd), 0) as cost_usd
3746
3782
  FROM economy_effective_requests
3747
- WHERE timestamp >= DATE('now', ? || ' days')
3783
+ WHERE timestamp >= ? AND timestamp < ?
3748
3784
  GROUP BY DATE(timestamp)
3749
3785
  ORDER BY date ASC
3750
- `).all(-days);
3786
+ `).all(bounds.start, bounds.end_exclusive);
3751
3787
  return detectCostSpikes(rows);
3752
3788
  }
3753
- function getTodaySpike(db) {
3754
- const today = new Date().toISOString().substring(0, 10);
3755
- const spikes = queryRecentSpikes(db, 14);
3789
+ function getTodaySpike(db, now = new Date) {
3790
+ const today = resolveReportingInterval({ kind: "calendar", period: "today" }, now).start;
3791
+ const spikes = queryRecentSpikes(db, 14, now);
3756
3792
  return spikes.find((s) => s.date === today) ?? null;
3757
3793
  }
3794
+ var init_spikes = () => {};
3758
3795
 
3759
3796
  // src/lib/webhooks.ts
3760
3797
  var exports_webhooks = {};
@@ -3821,6 +3858,7 @@ async function fireWebhook(url, payload) {
3821
3858
  var init_webhooks = __esm(() => {
3822
3859
  init_config();
3823
3860
  init_database();
3861
+ init_spikes();
3824
3862
  });
3825
3863
 
3826
3864
  // src/db/sync-pg.ts
@@ -3882,15 +3920,16 @@ function translateSqliteDates(sql) {
3882
3920
  s = s.replace(/DATE\('now',\s*(\$\d+)\s*\|\|\s*' days'\)/gi, (_m, p) => isoDate(`now() + ((${p}) || ' days')::interval`));
3883
3921
  s = s.replace(/DATETIME\('now',\s*(\$\d+)\)/gi, (_m, p) => `(now() + (${p})::interval)`);
3884
3922
  s = s.replace(/DATETIME\('now',\s*'([^']+)'\)/gi, (_m, lit) => `(now() + interval '${lit}')`);
3923
+ s = s.replace(/STRFTIME\('%Y-%m-%dT%H:%M:%fZ',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, col) => `to_char(CAST("${col}" AS timestamptz) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`);
3885
3924
  const strftimeMap = { "%H": "HH24", "%Y": "YYYY", "%m": "MM", "%d": "DD", "%M": "MI", "%S": "SS" };
3886
3925
  s = s.replace(/STRFTIME\('([^']+)',\s*([A-Za-z_][A-Za-z0-9_]*)\)/gi, (_m, fmt, col) => {
3887
3926
  const pgFmt = Object.entries(strftimeMap).reduce((acc, [k, v]) => acc.split(k).join(v), fmt);
3888
- return `to_char(CAST("${col}" AS timestamptz), '${pgFmt}')`;
3927
+ return `to_char(CAST("${col}" AS timestamptz) AT TIME ZONE 'UTC', '${pgFmt}')`;
3889
3928
  });
3890
3929
  s = s.replace(/\bDATE\('now'\)/gi, isoDate("now()"));
3891
3930
  s = s.replace(/\bDATETIME\('now'\)/gi, "now()");
3892
3931
  s = s.replace(/\bDATETIME\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `CAST("${col}" AS timestamptz)`);
3893
- s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gi, (_m, col) => `substr("${col}", 1, 10)`);
3932
+ s = s.replace(/\bDATE\(\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\)/gi, (_m, col) => `substr(${col.split(".").map((part) => `"${part}"`).join(".")}, 1, 10)`);
3894
3933
  return s;
3895
3934
  }
3896
3935
  function resolveWorkerPath() {
@@ -4256,7 +4295,7 @@ import { join as join4, basename } from "path";
4256
4295
  import { existsSync as existsSync4, readFileSync } from "fs";
4257
4296
  import { homedir as homedir2 } from "os";
4258
4297
  import { join as join3 } from "path";
4259
- import { z } from "zod";
4298
+ import { z as z2 } from "zod";
4260
4299
  import { resolveClientTransport, resolveCredential } from "@hasna/contracts/client";
4261
4300
 
4262
4301
  // src/lib/cloud-storage.ts
@@ -4275,33 +4314,138 @@ import { createHasnaStorageClient } from "@hasna/contracts/client/storage";
4275
4314
  var LOCAL_STORAGE_OPT_IN_KEYS = ["HASNA_ECONOMY_LOCAL", "ECONOMY_LOCAL"];
4276
4315
  var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
4277
4316
 
4278
- // src/lib/accounts-store.ts
4279
- var ACCOUNTS_APP = "accounts";
4280
- var profileNameSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
4317
+ // src/lib/subscriptions-registry.ts
4318
+ import { execFile } from "child_process";
4319
+ import { promisify } from "util";
4320
+ import { isAbsolute } from "path";
4321
+ import { z } from "zod";
4322
+ var execute = promisify(execFile);
4323
+ var readSubscriptionsMetadata = async (args, env) => {
4324
+ let stdout;
4325
+ try {
4326
+ const result = await execute("subscriptions", args, {
4327
+ env,
4328
+ encoding: "utf8",
4329
+ timeout: 30000,
4330
+ maxBuffer: 16 * 1024 * 1024,
4331
+ windowsHide: true
4332
+ });
4333
+ stdout = result.stdout;
4334
+ } catch (error) {
4335
+ const code = error?.code;
4336
+ if (code === "ENOENT")
4337
+ throw new Error("subscriptions: CLI not found on PATH; install the Subscriptions CLI to resolve station account metadata");
4338
+ const status = typeof code === "number" && Number.isInteger(code) ? `exit ${code}` : "execution limit or transport failure";
4339
+ throw new Error(`subscriptions: read-only metadata command failed (${status}); verify the installed CLI and its hosted credentials`);
4340
+ }
4341
+ try {
4342
+ return JSON.parse(stdout);
4343
+ } catch {
4344
+ throw new Error("subscriptions: metadata command returned invalid JSON");
4345
+ }
4346
+ };
4347
+ var nonempty = z.string().min(1);
4281
4348
  var profileSchema = z.object({
4282
- name: z.string(),
4283
- tool: z.string(),
4349
+ name: nonempty,
4350
+ tool: nonempty,
4284
4351
  email: z.string().optional(),
4285
4352
  displayName: z.string().optional(),
4286
- dir: z.string(),
4353
+ dir: nonempty.refine(isAbsolute).refine((value) => !/[\0\r\n]/.test(value)),
4354
+ createdAt: nonempty,
4355
+ lastUsedAt: z.string().optional(),
4287
4356
  description: z.string().optional(),
4288
- createdAt: z.string(),
4289
- lastUsedAt: z.string().optional()
4357
+ active: z.boolean(),
4358
+ applied: z.boolean(),
4359
+ switchedAway: z.object({ profile: nonempty, email: z.string().optional() }).optional()
4360
+ });
4361
+ var toolsSchema = z.array(z.object({
4362
+ id: nonempty,
4363
+ label: nonempty,
4364
+ envVar: nonempty,
4365
+ defaultDir: nonempty,
4366
+ bin: nonempty
4367
+ }));
4368
+ function unique(rows, identity) {
4369
+ if (new Set(rows.map(identity)).size !== rows.length) {
4370
+ throw new Error("subscriptions: duplicate identity in metadata response");
4371
+ }
4372
+ return rows;
4373
+ }
4374
+
4375
+ class SubscriptionsRegistry {
4376
+ read;
4377
+ transport = "api";
4378
+ registry = "subscriptions";
4379
+ env;
4380
+ profiles;
4381
+ tools;
4382
+ constructor(baseUrl, env, read = readSubscriptionsMetadata) {
4383
+ this.read = read;
4384
+ this.env = { ...env, HASNA_SUBSCRIPTIONS_API_URL: baseUrl.replace(/\/v1\/?$/, "") };
4385
+ }
4386
+ allProfiles() {
4387
+ return this.profiles ??= this.read(["list", "--json"], this.env).then((raw) => {
4388
+ const parsed = z.array(profileSchema).safeParse(raw);
4389
+ if (!parsed.success)
4390
+ throw new Error("subscriptions: invalid profile metadata response");
4391
+ return unique(parsed.data, (row) => JSON.stringify([row.tool, row.name]));
4392
+ });
4393
+ }
4394
+ async listProfiles(tool) {
4395
+ const rows = await this.allProfiles();
4396
+ return rows.filter((row) => !tool || row.tool === tool);
4397
+ }
4398
+ async findProfile(name, tool) {
4399
+ const matches = (await this.listProfiles(tool)).filter((row) => row.name === name);
4400
+ return matches.length === 1 ? matches[0] : undefined;
4401
+ }
4402
+ async appliedProfile(tool) {
4403
+ const matches = (await this.allProfiles()).filter((row) => row.tool === tool && row.applied);
4404
+ if (matches.length > 1)
4405
+ throw new Error("subscriptions: ambiguous station applied-profile binding");
4406
+ return matches[0];
4407
+ }
4408
+ async currentProfile(_tool) {
4409
+ return;
4410
+ }
4411
+ async listTools() {
4412
+ return this.tools ??= this.read(["tools", "list", "--json"], this.env).then((raw) => {
4413
+ const parsed = toolsSchema.safeParse(raw);
4414
+ if (!parsed.success)
4415
+ throw new Error("subscriptions: invalid tool metadata response");
4416
+ return unique(parsed.data, (row) => row.id);
4417
+ });
4418
+ }
4419
+ }
4420
+
4421
+ // src/lib/accounts-store.ts
4422
+ var ACCOUNTS_APP = "accounts";
4423
+ var profileNameSchema = z2.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
4424
+ var profileSchema2 = z2.object({
4425
+ name: z2.string(),
4426
+ tool: z2.string(),
4427
+ email: z2.string().optional(),
4428
+ displayName: z2.string().optional(),
4429
+ dir: z2.string(),
4430
+ description: z2.string().optional(),
4431
+ createdAt: z2.string(),
4432
+ lastUsedAt: z2.string().optional(),
4433
+ switchedAway: z2.object({ profile: z2.string().min(1), email: z2.string().optional() }).optional()
4290
4434
  });
4291
- var toolDefSchema = z.object({
4292
- id: z.string(),
4293
- label: z.string(),
4294
- envVar: z.string(),
4295
- defaultDir: z.string(),
4296
- bin: z.string()
4435
+ var toolDefSchema = z2.object({
4436
+ id: z2.string(),
4437
+ label: z2.string(),
4438
+ envVar: z2.string(),
4439
+ defaultDir: z2.string(),
4440
+ bin: z2.string()
4297
4441
  });
4298
- var storeSchema = z.object({
4299
- version: z.literal(1),
4300
- current: z.record(z.string(), z.string()).default({}),
4301
- applied: z.record(z.string(), z.string()).default({}),
4302
- toolLocks: z.record(z.string(), z.string()).default({}),
4303
- profiles: z.array(profileSchema).default([]),
4304
- tools: z.array(toolDefSchema).default([])
4442
+ var storeSchema = z2.object({
4443
+ version: z2.literal(1),
4444
+ current: z2.record(z2.string(), z2.string()).default({}),
4445
+ applied: z2.record(z2.string(), z2.string()).default({}),
4446
+ toolLocks: z2.record(z2.string(), z2.string()).default({}),
4447
+ profiles: z2.array(profileSchema2).default([]),
4448
+ tools: z2.array(toolDefSchema).default([])
4305
4449
  });
4306
4450
  function localRegistryAllowed(env = process.env) {
4307
4451
  return LOCAL_STORAGE_OPT_IN_KEYS.some((key) => {
@@ -4583,25 +4727,34 @@ class ApiStore {
4583
4727
  }
4584
4728
  }
4585
4729
  function resolveStore(env = process.env, options = {}) {
4730
+ const legacy = resolveRegistryTransport(ACCOUNTS_APP, env, options.credentials);
4731
+ if (legacy)
4732
+ return new ApiStore(legacy.baseUrl, legacy.apiKey);
4733
+ const current = resolveRegistryTransport("subscriptions", env, options.subscriptionsCredentials);
4734
+ if (current)
4735
+ return new SubscriptionsRegistry(current.baseUrl, env, options.readSubscriptionsMetadata);
4736
+ return unconfiguredStore(env);
4737
+ }
4738
+ function resolveRegistryTransport(app, env, credentials) {
4586
4739
  const clientEnv = env;
4587
- const credential = resolveCredential(ACCOUNTS_APP, clientEnv, options.credentials);
4740
+ const credential = resolveCredential(app, clientEnv, credentials);
4588
4741
  if (credential === null) {
4589
4742
  try {
4590
- resolveClientTransport(ACCOUNTS_APP, clientEnv, options.credentials ? { credentials: options.credentials } : {});
4743
+ resolveClientTransport(app, clientEnv, credentials ? { credentials } : {});
4591
4744
  } catch (err) {
4592
4745
  if (isNoAccountsCredentialConfigurationError(err))
4593
- return unconfiguredStore(env);
4746
+ return null;
4594
4747
  throw err;
4595
4748
  }
4596
- return unconfiguredStore(env);
4749
+ return null;
4597
4750
  }
4598
4751
  if (credential.tier === "pointer") {
4599
- throw new Error(`accounts: ${credential.source} names a secrets-vault item, and the local accounts API store resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + "hasna.credentials.accounts.api-key, ~/.hasna/accounts/config/credentials, or HASNA_ACCOUNTS_API_KEY.");
4752
+ throw new Error(`${app}: ${credential.source} names a secrets-vault item, and the registry adapter resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + `hasna.credentials.${app}.api-key, ~/.hasna/${app}/config/credentials, or HASNA_${app.toUpperCase()}_API_KEY.`);
4600
4753
  }
4601
- const resolution = resolveClientTransport(ACCOUNTS_APP, clientEnv, {
4602
- credentials: { ...options.credentials, apiKey: credential.apiKey }
4754
+ const resolution = resolveClientTransport(app, clientEnv, {
4755
+ credentials: { ...credentials, apiKey: credential.apiKey }
4603
4756
  });
4604
- return new ApiStore(resolution.baseUrl, credential.apiKey);
4757
+ return { baseUrl: resolution.baseUrl, apiKey: credential.apiKey };
4605
4758
  }
4606
4759
  function isNoAccountsCredentialConfigurationError(error) {
4607
4760
  if (typeof error !== "object" || error === null)
@@ -4618,10 +4771,10 @@ function unconfiguredStore(env) {
4618
4771
  return new NoRegistryStore;
4619
4772
  }
4620
4773
  function accountsLocalModeNotice() {
4621
- return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 no Hasna Accounts credential resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "To go hosted, put the fleet key in the Keychain item hasna.credentials.accounts.api-key, write " + "~/.hasna/accounts/config/credentials, or set HASNA_ACCOUNTS_API_KEY.";
4774
+ return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 neither Accounts nor Subscriptions credentials resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "For hosted attribution, configure the Subscriptions CLI through its normal sign-in or credential resolver.";
4622
4775
  }
4623
4776
  function accountsRefusalNotice() {
4624
- return "accounts: no Hasna Accounts credential resolved (Keychain item hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
4777
+ return "accounts: neither Accounts nor Subscriptions credentials resolved (legacy hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY; current hasna.credentials.subscriptions.api-key, " + "~/.hasna/subscriptions/config/credentials, HASNA_SUBSCRIPTIONS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
4625
4778
  }
4626
4779
  var accountsLocalNoticePrinted = false;
4627
4780
  function announceAccountsLocalMode() {
@@ -4709,7 +4862,10 @@ async function profileForEnvDir(store, tool, env) {
4709
4862
  if (!configuredDir)
4710
4863
  return null;
4711
4864
  const normalized = normalizeDir(configuredDir);
4712
- return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
4865
+ const matches = (await store.listProfiles(tool.id)).filter((profile) => normalizeDir(profile.dir) === normalized);
4866
+ if (matches.length > 1)
4867
+ throw new Error("account attribution: multiple profiles match the configured directory");
4868
+ return matches[0] ?? null;
4713
4869
  }
4714
4870
  async function resolveAccountForAgent(agent, env = process.env, options = {}) {
4715
4871
  const override = envOverride(agent, env);
@@ -4723,8 +4879,15 @@ async function resolveAccountForAgent(agent, env = process.env, options = {}) {
4723
4879
  continue;
4724
4880
  const envProfile = await profileForEnvDir(store, tool, env);
4725
4881
  if (envProfile)
4726
- return fromProfile(envProfile, "env");
4727
- const appliedName = appliedProfileName(toolId);
4882
+ return envProfile.switchedAway ? null : fromProfile(envProfile, "env");
4883
+ if (store.registry === "subscriptions" && env[tool.envVar])
4884
+ return null;
4885
+ if (store.appliedProfile) {
4886
+ const applied = await store.appliedProfile(toolId);
4887
+ if (applied)
4888
+ return applied.switchedAway ? null : fromProfile(applied, "applied");
4889
+ }
4890
+ const appliedName = store.registry === "subscriptions" ? undefined : appliedProfileName(toolId);
4728
4891
  if (appliedName) {
4729
4892
  const applied = await store.findProfile(appliedName, toolId);
4730
4893
  if (applied)
@@ -7105,7 +7268,8 @@ function buildBrief(db, opts = {}) {
7105
7268
  }
7106
7269
 
7107
7270
  // src/lib/analytics.ts
7108
- function queryProjectDetail(db, nameOrPath) {
7271
+ function queryProjectDetail(db, nameOrPath, now = new Date) {
7272
+ const dailyBounds = resolveReportingInterval({ kind: "rolling", unit: "day", count: 14 }, now);
7109
7273
  const like = `%${nameOrPath}%`;
7110
7274
  const sessions = db.prepare(`SELECT id, project_name, project_path, total_cost_usd, total_tokens, started_at
7111
7275
  FROM economy_effective_sessions
@@ -7119,8 +7283,8 @@ function queryProjectDetail(db, nameOrPath) {
7119
7283
  const daily = db.prepare(`SELECT DATE(r.timestamp) as date, COALESCE(SUM(r.cost_usd), 0) as cost_usd
7120
7284
  FROM economy_effective_requests r JOIN economy_effective_sessions s ON r.session_id = s.id
7121
7285
  WHERE (s.project_name LIKE ? OR s.project_path LIKE ?)
7122
- AND r.timestamp >= DATE('now', '-14 days')
7123
- GROUP BY date ORDER BY date ASC`).all(like, like);
7286
+ AND r.timestamp >= ? AND r.timestamp < ?
7287
+ GROUP BY date ORDER BY date ASC`).all(like, like, dailyBounds.start, dailyBounds.end_exclusive);
7124
7288
  const models = db.prepare(`SELECT r.model as model, COUNT(*) as requests, COALESCE(SUM(r.cost_usd), 0) as cost_usd
7125
7289
  FROM economy_effective_requests r JOIN economy_effective_sessions s ON r.session_id = s.id
7126
7290
  WHERE s.project_name LIKE ? OR s.project_path LIKE ?
@@ -7159,18 +7323,17 @@ function queryRangeStats(db, from, to) {
7159
7323
  return { cost: r.cost, requests: r.requests, tokens: r.tokens, sessions: s.sessions };
7160
7324
  }
7161
7325
  function queryForecast(db, now = new Date) {
7162
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
7163
- const dayOfMonth = now.getDate();
7164
- const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
7165
- const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE DATE(timestamp) >= ?`).get(monthStart);
7326
+ const month = resolveReportingInterval({ kind: "calendar", period: "month" }, now);
7327
+ const recent = resolveReportingInterval({ kind: "rolling", unit: "day", count: 7 }, now);
7328
+ const daysInMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)).getUTCDate();
7329
+ const dayOfMonth = now.getUTCDate();
7330
+ const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ?`).get(month.start, recent.end_exclusive);
7166
7331
  const dailyAvg = dayOfMonth > 0 ? monthSoFar.cost / dayOfMonth : 0;
7167
7332
  const projected = dailyAvg * daysInMonth;
7168
- const sevenDaysAgo = new Date(now);
7169
- sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
7170
- const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE DATE(timestamp) >= ?`).get(sevenDaysAgo.toISOString().substring(0, 10));
7333
+ const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ?`).get(recent.start, recent.end_exclusive);
7171
7334
  const last7DailyAvg = last7.cost / 7;
7172
7335
  const dailyCosts = db.prepare(`SELECT DATE(timestamp) as date, COALESCE(SUM(cost_usd),0) as cost_usd
7173
- FROM economy_effective_requests WHERE DATE(timestamp) >= ? GROUP BY date ORDER BY cost_usd ASC`).all(monthStart);
7336
+ FROM economy_effective_requests WHERE timestamp >= ? AND timestamp < ? GROUP BY date ORDER BY cost_usd ASC`).all(month.start, recent.end_exclusive);
7174
7337
  return {
7175
7338
  day_of_month: dayOfMonth,
7176
7339
  days_in_month: daysInMonth,
@@ -7266,7 +7429,7 @@ import { randomUUID } from "crypto";
7266
7429
  // package.json
7267
7430
  var package_default = {
7268
7431
  name: "@hasna/economy",
7269
- version: "0.5.2",
7432
+ version: "0.5.4",
7270
7433
  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",
7271
7434
  repository: {
7272
7435
  type: "git",
@@ -7471,7 +7634,13 @@ function createDataHandler(db, actorId) {
7471
7634
  if (path === "/api/daily" && method === "GET") {
7472
7635
  const days = Number(url.searchParams.get("days") ?? 30);
7473
7636
  const machine = url.searchParams.get("machine") ?? undefined;
7474
- return ok(queryDailyBreakdown(db, days, machine));
7637
+ const now = new Date;
7638
+ try {
7639
+ resolveReportingInterval({ kind: "rolling", unit: "day", count: days }, now);
7640
+ } catch {
7641
+ return err("days must be a positive integer within the supported UTC date range");
7642
+ }
7643
+ return ok(queryDailyBreakdown(db, days, machine, now));
7475
7644
  }
7476
7645
  if (path === "/api/hourly" && method === "GET") {
7477
7646
  const machine = url.searchParams.get("machine") ?? undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API for Claude Code, Codex, OpenCode, Cursor, Pi, and Hermes, with legacy Gemini CLI session and Gemini API billing ingestion",
5
5
  "repository": {
6
6
  "type": "git",