@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.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -2
- package/dist/chunks/{billing-bkpxy4kv.js → billing-22az60hy.js} +2 -2
- package/dist/chunks/{config-j9mv3ehk.js → config-hsp7ndhj.js} +2 -2
- package/dist/chunks/{index-npsjjv96.js → index-9arv0b1j.js} +162 -36
- package/dist/chunks/{index-hesxq90g.js → index-9wa1f2kv.js} +1 -1
- package/dist/chunks/{index-zpvea08j.js → index-c7h2rxx1.js} +38 -12
- package/dist/chunks/{index-9d2aky06.js → index-ct3zdefz.js} +3 -3
- package/dist/chunks/{index-k540hfww.js → index-dk5rq1rq.js} +1 -1
- package/dist/chunks/{index-ez9thsr7.js → index-f5jv6q5q.js} +38 -12
- package/dist/chunks/{index-dj3tctqh.js → index-n1wwqxfe.js} +14 -13
- package/dist/chunks/{index-kzwqw3ve.js → index-nheq2nrz.js} +2 -2
- package/dist/chunks/{index-2hwzmfgv.js → index-vajzfzgv.js} +38 -12
- package/dist/chunks/{open-projects-6z4pq6mx.js → open-projects-8qm46m3d.js} +1 -1
- package/dist/chunks/{pricing-vf2pycmh.js → pricing-462f8xkm.js} +1 -1
- package/dist/chunks/{pricing-f80p81gr.js → pricing-k9wsjwbz.js} +1 -1
- package/dist/chunks/{pricing-y21mcjsa.js → pricing-vxejj1qa.js} +1 -1
- package/dist/chunks/{serve-p2yarhm8.js → serve-ra46ms69.js} +18 -10
- package/dist/chunks/{sqlite-store-st99garx.js → sqlite-store-13nhr1z0.js} +2 -2
- package/dist/chunks/{sqlite-store-501f32ts.js → sqlite-store-gamn65xp.js} +2 -2
- package/dist/chunks/{sqlite-store-fq4natnf.js → sqlite-store-ht6frztj.js} +2 -2
- package/dist/chunks/{tui-x64hjfrh.js → tui-a1tvcw2j.js} +4 -4
- package/dist/chunks/{watch-bxstjpbx.js → watch-rvd3dn6r.js} +5 -5
- package/dist/chunks/{webhooks-k4fh2t9c.js → webhooks-nw4daw9y.js} +20 -9
- package/dist/cli/index.js +27 -27
- package/dist/db/database.d.ts +2 -2
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/sync-pg.d.ts.map +1 -1
- package/dist/index.js +15 -14
- package/dist/lib/accounts-store.d.ts +25 -1
- package/dist/lib/accounts-store.d.ts.map +1 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/analytics.d.ts +1 -1
- package/dist/lib/analytics.d.ts.map +1 -1
- package/dist/lib/periods.d.ts +20 -1
- package/dist/lib/periods.d.ts.map +1 -1
- package/dist/lib/spikes.d.ts +3 -3
- package/dist/lib/spikes.d.ts.map +1 -1
- package/dist/lib/subscriptions-registry.d.ts +26 -0
- package/dist/lib/subscriptions-registry.d.ts.map +1 -0
- package/dist/lib/test-keychain-fixture.d.ts +3 -2
- package/dist/lib/test-keychain-fixture.d.ts.map +1 -1
- package/dist/mcp/index.js +245 -118
- package/dist/otel/index.js +56 -27
- package/dist/server/index.js +246 -77
- package/dist/server/service-operations.d.ts.map +1 -1
- package/dist/server/service-worker.js +237 -68
- 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
|
|
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(
|
|
1123
|
+
const start = new Date(now);
|
|
1124
|
+
start.setUTCHours(0, 0, 0, 0);
|
|
1124
1125
|
const end = new Date(start);
|
|
1125
|
-
|
|
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",
|
|
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",
|
|
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 ? [
|
|
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 >=
|
|
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
|
|
3036
|
-
const
|
|
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 >=
|
|
3783
|
+
WHERE timestamp >= ? AND timestamp < ?
|
|
3748
3784
|
GROUP BY DATE(timestamp)
|
|
3749
3785
|
ORDER BY date ASC
|
|
3750
|
-
`).all(
|
|
3786
|
+
`).all(bounds.start, bounds.end_exclusive);
|
|
3751
3787
|
return detectCostSpikes(rows);
|
|
3752
3788
|
}
|
|
3753
|
-
function getTodaySpike(db) {
|
|
3754
|
-
const today =
|
|
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("${
|
|
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/
|
|
4279
|
-
|
|
4280
|
-
|
|
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:
|
|
4283
|
-
tool:
|
|
4349
|
+
name: nonempty,
|
|
4350
|
+
tool: nonempty,
|
|
4284
4351
|
email: z.string().optional(),
|
|
4285
4352
|
displayName: z.string().optional(),
|
|
4286
|
-
dir:
|
|
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
|
-
|
|
4289
|
-
|
|
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 =
|
|
4292
|
-
id:
|
|
4293
|
-
label:
|
|
4294
|
-
envVar:
|
|
4295
|
-
defaultDir:
|
|
4296
|
-
bin:
|
|
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 =
|
|
4299
|
-
version:
|
|
4300
|
-
current:
|
|
4301
|
-
applied:
|
|
4302
|
-
toolLocks:
|
|
4303
|
-
profiles:
|
|
4304
|
-
tools:
|
|
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(
|
|
4740
|
+
const credential = resolveCredential(app, clientEnv, credentials);
|
|
4588
4741
|
if (credential === null) {
|
|
4589
4742
|
try {
|
|
4590
|
-
resolveClientTransport(
|
|
4743
|
+
resolveClientTransport(app, clientEnv, credentials ? { credentials } : {});
|
|
4591
4744
|
} catch (err) {
|
|
4592
4745
|
if (isNoAccountsCredentialConfigurationError(err))
|
|
4593
|
-
return
|
|
4746
|
+
return null;
|
|
4594
4747
|
throw err;
|
|
4595
4748
|
}
|
|
4596
|
-
return
|
|
4749
|
+
return null;
|
|
4597
4750
|
}
|
|
4598
4751
|
if (credential.tier === "pointer") {
|
|
4599
|
-
throw new Error(
|
|
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(
|
|
4602
|
-
credentials: { ...
|
|
4754
|
+
const resolution = resolveClientTransport(app, clientEnv, {
|
|
4755
|
+
credentials: { ...credentials, apiKey: credential.apiKey }
|
|
4603
4756
|
});
|
|
4604
|
-
return
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 >=
|
|
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
|
|
7163
|
-
const
|
|
7164
|
-
const
|
|
7165
|
-
const
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|