taximeter 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,17 +65,23 @@ var blockedBodySchema = z.object({
65
65
  reason: z.string(),
66
66
  budget: amountSchema.nullable(),
67
67
  spent: integerStringSchema,
68
- remaining: amountSchema.nullable()
68
+ remaining: amountSchema.nullable(),
69
+ fix: z.string().optional()
69
70
  });
70
71
 
71
72
  // src/config.ts
72
73
  import { z as z2 } from "zod";
73
- var budgetSchema = z2.strictObject({
74
- amount: amountSchema,
74
+ var budgetFieldsSchema = z2.strictObject({
75
+ amount: amountSchema.optional(),
76
+ maxPayments: z2.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
75
77
  asset: labelSchema,
76
78
  network: labelSchema.optional(),
77
79
  window: z2.enum(["1h", "24h", "7d", "30d"]).optional()
78
80
  });
81
+ var budgetSchema = budgetFieldsSchema.refine(
82
+ (budget) => budget.amount !== void 0 || budget.maxPayments !== void 0,
83
+ { message: "A budget must set amount or maxPayments", path: ["amount"] }
84
+ );
79
85
  var policySchema = z2.strictObject({
80
86
  allowHosts: z2.array(labelSchema).default([]),
81
87
  denyHosts: z2.array(labelSchema).default([]),
@@ -101,9 +107,9 @@ var configSchema = z2.strictObject({
101
107
  });
102
108
  var configPatchSchema = z2.strictObject({
103
109
  budgets: z2.strictObject({
104
- perTask: budgetSchema.partial().nullable().optional(),
105
- perAgent: budgetSchema.partial().nullable().optional(),
106
- global: budgetSchema.partial().nullable().optional()
110
+ perTask: budgetFieldsSchema.partial().nullable().optional(),
111
+ perAgent: budgetFieldsSchema.partial().nullable().optional(),
112
+ global: budgetFieldsSchema.partial().nullable().optional()
107
113
  }).optional(),
108
114
  policy: z2.strictObject({
109
115
  allowHosts: z2.array(labelSchema).optional(),
@@ -127,8 +133,17 @@ function parseConfig(...layers) {
127
133
  const budgets = { ...current.budgets };
128
134
  for (const key of ["perTask", "perAgent", "global"]) {
129
135
  const next = layer.budgets?.[key];
130
- if (next !== void 0)
131
- budgets[key] = next === null ? null : budgetSchema.parse({ ...budgets[key], ...next });
136
+ if (next === void 0) continue;
137
+ if (next === null) {
138
+ budgets[key] = null;
139
+ continue;
140
+ }
141
+ const parsed = budgetSchema.safeParse({ ...budgets[key], ...next });
142
+ if (!parsed.success)
143
+ throw new z2.ZodError(
144
+ parsed.error.issues.map((issue) => ({ ...issue, path: ["budgets", key, ...issue.path] }))
145
+ );
146
+ budgets[key] = parsed.data;
132
147
  }
133
148
  current = configSchema.parse({
134
149
  ...current,
@@ -158,8 +173,9 @@ function readConfig(path) {
158
173
  if (!existsSync(path)) return {};
159
174
  try {
160
175
  return configPatchSchema.parse(JSON.parse(readFileSync(path, "utf8")));
161
- } catch {
162
- throw new Error(`Invalid Taximeter configuration: ${path}`);
176
+ } catch (error) {
177
+ const detail = error instanceof z3.ZodError ? error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ") : "expected valid JSON";
178
+ throw new Error(`Invalid Taximeter configuration: ${path}: ${detail}`);
163
179
  }
164
180
  }
165
181
  var portInputSchema = z3.string().regex(/^[0-9]{1,5}$/).pipe(z3.coerce.number().int().min(0).max(65535));
@@ -168,7 +184,14 @@ var environmentSchema = z3.object({
168
184
  TAXIMETER_PORT: portInputSchema.optional(),
169
185
  TAXIMETER_DASHBOARD_PORT: portInputSchema.optional()
170
186
  });
171
- function loadConfig(flags = {}, options = {}) {
187
+ function leafKeys(value, prefix = "") {
188
+ if (value === void 0) return [];
189
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return [prefix];
190
+ return Object.entries(value).flatMap(
191
+ ([key, child]) => leafKeys(child, prefix ? `${prefix}.${key}` : key)
192
+ );
193
+ }
194
+ function loadConfigDetails(flags = {}, options = {}) {
172
195
  const cwd = options.cwd ?? process.cwd();
173
196
  const home = options.home ?? homedir();
174
197
  const env = environmentSchema.parse(options.env ?? process.env);
@@ -179,16 +202,78 @@ function loadConfig(flags = {}, options = {}) {
179
202
  ...env.TAXIMETER_DASHBOARD_PORT !== void 0 ? { dashboard: env.TAXIMETER_DASHBOARD_PORT } : {}
180
203
  }
181
204
  };
182
- if (options.configFile && !existsSync(resolve(cwd, options.configFile)))
205
+ const paths = {
206
+ home: expandPath(resolve(home, ".taximeter", "config.json"), home, cwd),
207
+ cwd: expandPath(resolve(cwd, "taximeter.config.json"), home, cwd),
208
+ explicit: options.configFile ? expandPath(options.configFile, home, cwd) : void 0
209
+ };
210
+ const overridden = (path) => Object.hasOwn(options.layerOverrides ?? {}, path);
211
+ const fileValue = (path) => !path ? {} : overridden(path) ? configPatchSchema.parse(options.layerOverrides?.[path]) : readConfig(path);
212
+ if (paths.explicit && !existsSync(paths.explicit) && !overridden(paths.explicit))
183
213
  throw new Error(`Configuration file not found: ${options.configFile}`);
184
- const config = parseConfig(
185
- readConfig(join(home, ".taximeter", "config.json")),
186
- readConfig(join(cwd, "taximeter.config.json")),
187
- ...options.configFile ? [readConfig(resolve(cwd, options.configFile))] : [],
214
+ const values = {
215
+ defaults: parseConfig(),
216
+ home: fileValue(paths.home),
217
+ cwd: fileValue(paths.cwd),
218
+ explicit: fileValue(paths.explicit),
188
219
  environment,
189
- flags
220
+ flags: configPatchSchema.parse(flags)
221
+ };
222
+ const config = parseConfig(
223
+ values.home,
224
+ values.cwd,
225
+ values.explicit,
226
+ values.environment,
227
+ values.flags
190
228
  );
191
- return { ...config, db: expandPath(config.db, home, cwd) };
229
+ const names = ["defaults", "home", "cwd", "explicit", "environment", "flags"];
230
+ const labels = {
231
+ flags: "flags",
232
+ environment: "environment",
233
+ explicit: "--config",
234
+ cwd: "cwd",
235
+ home: "home",
236
+ defaults: "defaults"
237
+ };
238
+ const owners = /* @__PURE__ */ new Map();
239
+ for (const name of names) {
240
+ for (const key of leafKeys(values[name])) {
241
+ for (const owned of owners.keys()) {
242
+ if (owned === key || owned.startsWith(`${key}.`) || key.startsWith(`${owned}.`))
243
+ owners.delete(owned);
244
+ }
245
+ owners.set(key, name);
246
+ }
247
+ }
248
+ const layers = names.map((name) => {
249
+ const path = name === "home" || name === "cwd" || name === "explicit" ? paths[name] : void 0;
250
+ const keys = leafKeys(values[name]);
251
+ const contributedKeys = keys.filter((key) => owners.get(key) === name);
252
+ return {
253
+ name,
254
+ label: labels[name] ?? name,
255
+ ...path ? { path } : {},
256
+ exists: path ? existsSync(path) : name === "defaults" || keys.length > 0,
257
+ supplied: path ? existsSync(path) || overridden(path) : name === "defaults" || keys.length > 0,
258
+ contributed: contributedKeys.length > 0,
259
+ keys,
260
+ contributedKeys,
261
+ overriddenKeys: keys.filter((key) => !contributedKeys.includes(key)),
262
+ ...name === "environment" ? {
263
+ environmentVariables: Object.keys(env).filter(
264
+ (key) => env[key] !== void 0
265
+ )
266
+ } : {}
267
+ };
268
+ }).reverse();
269
+ return {
270
+ config: { ...config, db: expandPath(config.db, home, cwd) },
271
+ layers,
272
+ defaultWritePath: existsSync(paths.cwd) ? paths.cwd : paths.home
273
+ };
274
+ }
275
+ function loadConfig(flags = {}, options = {}) {
276
+ return loadConfigDetails(flags, options).config;
192
277
  }
193
278
 
194
279
  // src/ledger/derive.ts
@@ -289,14 +374,59 @@ var usdc = {
289
374
  function assetMetadata(network, asset) {
290
375
  return usdc[network] === asset.toLowerCase() ? { decimals: 6, decimalsKnown: true, assetSymbol: "USDC" } : { decimals: 0, decimalsKnown: false };
291
376
  }
377
+ function knownAssets() {
378
+ return Object.entries(usdc).map(([network, asset]) => ({
379
+ network,
380
+ asset,
381
+ ...assetMetadata(network, asset)
382
+ }));
383
+ }
292
384
  function matchesAsset(selector, payment) {
293
385
  return selector.toLowerCase() === payment.asset.toLowerCase() || selector === "USDC" && assetMetadata(payment.network, payment.asset).assetSymbol === "USDC";
294
386
  }
295
387
 
296
388
  // src/policy/index.ts
297
389
  var windows = { "1h": 36e5, "24h": 864e5, "7d": 6048e5, "30d": 2592e6 };
298
- function deny(reason, budget = null, spent = "0") {
390
+ var amountReasons = {
391
+ perTask: "per_task_budget",
392
+ perAgent: "per_agent_budget",
393
+ global: "global_budget"
394
+ };
395
+ var countReasons = {
396
+ perTask: "per_task_payment_count",
397
+ perAgent: "per_agent_payment_count",
398
+ global: "global_payment_count"
399
+ };
400
+ var fixedRemedies = {
401
+ host_denied: 'taximeter config set policy.denyHosts "[]"',
402
+ host_not_allowed: 'taximeter config set policy.allowHosts "[]"',
403
+ recipient_not_allowed: 'taximeter config set policy.allowPayTo "[]"',
404
+ unknown_asset: "taximeter config set policy.unknownAsset allow"
405
+ };
406
+ var limitKeys = {
407
+ max_single_payment: "policy.maxSinglePayment",
408
+ per_task_budget: "budgets.perTask.amount",
409
+ per_agent_budget: "budgets.perAgent.amount",
410
+ global_budget: "budgets.global.amount",
411
+ per_task_payment_count: "budgets.perTask.maxPayments",
412
+ per_agent_payment_count: "budgets.perAgent.maxPayments",
413
+ global_payment_count: "budgets.global.maxPayments"
414
+ };
415
+ function deny(reason, budget = null, spent = "0", increment = 0n) {
299
416
  const difference = budget === null ? null : BigInt(budget) - BigInt(spent);
417
+ let fix;
418
+ if (reason in fixedRemedies) {
419
+ fix = fixedRemedies[reason];
420
+ } else {
421
+ const key = limitKeys[reason];
422
+ const minimum = BigInt(budget) + 1n;
423
+ const needed = BigInt(spent) + increment;
424
+ const next = needed > minimum ? needed : minimum;
425
+ const countLimit = key.endsWith(".maxPayments");
426
+ const representable = countLimit ? next <= BigInt(Number.MAX_SAFE_INTEGER) : next.toString().length <= 78;
427
+ const target = key.startsWith("budgets.") ? key.slice(0, key.lastIndexOf(".")) : key;
428
+ fix = representable ? `taximeter config set ${key} ${next}` : `taximeter config set ${target} null`;
429
+ }
300
430
  return {
301
431
  allowed: false,
302
432
  body: {
@@ -304,13 +434,15 @@ function deny(reason, budget = null, spent = "0") {
304
434
  reason,
305
435
  budget,
306
436
  spent,
307
- remaining: difference === null ? null : (difference < 0n ? 0n : difference).toString()
437
+ remaining: difference === null ? null : (difference < 0n ? 0n : difference).toString(),
438
+ fix
308
439
  }
309
440
  };
310
441
  }
311
- function spentForBudget(proposed, events, budget, scope, now) {
442
+ function totalsForBudget(proposed, events, budget, scope, now) {
312
443
  const start = budget.window ? now - windows[budget.window] : -Infinity;
313
444
  let spent = 0n;
445
+ let count = 0n;
314
446
  for (const event of deriveEvents(events)) {
315
447
  if (!countsAsSpend(event) || assetKey(event) !== assetKey(proposed)) continue;
316
448
  const ts = Date.parse(event.attemptedAt ?? event.ts);
@@ -318,21 +450,28 @@ function spentForBudget(proposed, events, budget, scope, now) {
318
450
  if (scope === "perTask" && event.taskId !== proposed.taskId) continue;
319
451
  if (scope === "perAgent" && event.agentId !== proposed.agentId) continue;
320
452
  spent += BigInt(event.amount);
453
+ count += 1n;
321
454
  }
322
- return spent.toString();
455
+ return { amount: spent.toString(), count: count.toString() };
323
456
  }
324
457
  function evaluate(proposed, events, config, now) {
458
+ const spent = { perTask: "0", perAgent: "0", global: "0" };
459
+ const counts = { perTask: "0", perAgent: "0", global: "0" };
460
+ for (const scope of ["perTask", "perAgent", "global"]) {
461
+ const budget = config.budgets[scope];
462
+ if (!budget) continue;
463
+ const total = totalsForBudget(proposed, events, budget, scope, now);
464
+ spent[scope] = total.amount;
465
+ counts[scope] = total.count;
466
+ }
325
467
  return evaluateWithState(
326
468
  proposed,
327
469
  {
328
470
  reserved: events.find(
329
471
  (event) => event.paymentKey === proposed.paymentKey && countsAsSpend(event)
330
472
  ),
331
- spent: {
332
- perTask: config.budgets.perTask ? spentForBudget(proposed, events, config.budgets.perTask, "perTask", now) : "0",
333
- perAgent: config.budgets.perAgent ? spentForBudget(proposed, events, config.budgets.perAgent, "perAgent", now) : "0",
334
- global: config.budgets.global ? spentForBudget(proposed, events, config.budgets.global, "global", now) : "0"
335
- }
473
+ spent,
474
+ counts
336
475
  },
337
476
  config,
338
477
  now
@@ -349,7 +488,7 @@ function evaluateWithState(proposed, state, config, now) {
349
488
  if (policy.unknownAsset === "deny" && !assetMetadata(proposed.network, proposed.asset).decimalsKnown)
350
489
  return deny("unknown_asset");
351
490
  if (policy.maxSinglePayment !== null && matchesAsset(policy.maxSingleAsset, proposed) && BigInt(proposed.amount) > BigInt(policy.maxSinglePayment))
352
- return deny("max_single_payment", policy.maxSinglePayment);
491
+ return deny("max_single_payment", policy.maxSinglePayment, "0", BigInt(proposed.amount));
353
492
  const reserved = state.reserved && countsAsSpend(state.reserved) ? state.reserved : void 0;
354
493
  for (const scope of ["perTask", "perAgent", "global"]) {
355
494
  const budget = config.budgets[scope];
@@ -358,10 +497,14 @@ function evaluateWithState(proposed, state, config, now) {
358
497
  const spent = state.spent[scope];
359
498
  const reservedTime = reserved ? Date.parse(reserved.attemptedAt ?? reserved.ts) : -Infinity;
360
499
  const inWindow = reservedTime <= now && (!budget.window || reservedTime >= now - windows[budget.window]);
361
- const increment = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : BigInt(proposed.amount);
362
- if (BigInt(spent) + increment > BigInt(budget.amount)) {
363
- const reason = scope === "perTask" ? "per_task_budget" : scope === "perAgent" ? "per_agent_budget" : "global_budget";
364
- return deny(reason, budget.amount, spent);
500
+ const countIncrement = reserved && (reserved.settlementStatus === "confirmed" || inWindow) ? 0n : 1n;
501
+ const increment = countIncrement * BigInt(proposed.amount);
502
+ if (budget.amount !== void 0 && BigInt(spent) + increment > BigInt(budget.amount)) {
503
+ return deny(amountReasons[scope], budget.amount, spent, increment);
504
+ }
505
+ const count = state.counts[scope];
506
+ if (budget.maxPayments !== void 0 && BigInt(count) + countIncrement > BigInt(budget.maxPayments)) {
507
+ return deny(countReasons[scope], budget.maxPayments.toString(), count, countIncrement);
365
508
  }
366
509
  }
367
510
  return { allowed: true };
@@ -641,10 +784,27 @@ var X402Rail = class {
641
784
 
642
785
  // src/core.ts
643
786
  import { v7 as v72 } from "uuid";
787
+
788
+ // src/notices.ts
789
+ import { Console } from "console";
790
+ var console = new Console({ stdout: process.stdout, stderr: process.stderr, ignoreErrors: true });
791
+ var shown = /* @__PURE__ */ new Set();
792
+ function blockedNotice(body) {
793
+ if (!body.fix || shown.has(body.reason)) return;
794
+ shown.add(body.reason);
795
+ try {
796
+ console.error(
797
+ `Taximeter blocked (${body.reason}). To change this policy: ${body.fix}. Restart taximeter after editing.`
798
+ );
799
+ } catch {
800
+ }
801
+ }
802
+
803
+ // src/core.ts
644
804
  var Meter = class {
645
805
  constructor(ledger, config) {
646
806
  this.ledger = ledger;
647
- this.config = parseConfig(config);
807
+ this.config = configSchema.parse(config);
648
808
  this.rail = new X402Rail({
649
809
  diagnostic: (code, resource, message) => this.diagnose(code, resource, message)
650
810
  });
@@ -665,7 +825,7 @@ var Meter = class {
665
825
  const proposed = this.rail.parse(request);
666
826
  if (!proposed) return {};
667
827
  try {
668
- return this.ledger.transaction(() => {
828
+ const intake = this.ledger.transaction(() => {
669
829
  const now = Date.now();
670
830
  const state = this.ledger.policyState(proposed, this.config.budgets, now);
671
831
  const decision = evaluateWithState(proposed, state, this.config, now);
@@ -695,6 +855,8 @@ var Meter = class {
695
855
  this.ledger.appendOutcome(attempt);
696
856
  return { payment, attempt };
697
857
  });
858
+ if (intake.body) blockedNotice(intake.body);
859
+ return intake;
698
860
  } catch {
699
861
  this.diagnose(
700
862
  "storage_failed",
@@ -822,12 +984,15 @@ var __default = "CREATE TABLE schema_version (version INTEGER PRIMARY KEY);\nINS
822
984
  // migrations/002.sql
823
985
  var __default2 = "CREATE TABLE cache_cursors (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n eventRowid INTEGER NOT NULL CHECK (eventRowid >= 0),\n outcomeRowid INTEGER NOT NULL CHECK (outcomeRowid >= 0)\n);\nINSERT INTO cache_cursors VALUES (1, 0, 0);\n\nCREATE TABLE cache_payments (\n paymentId TEXT PRIMARY KEY REFERENCES events(id),\n paymentKey TEXT NOT NULL UNIQUE,\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n maxAttemptedAt TEXT\n);\n\nCREATE TABLE cache_attempts (\n paymentId TEXT NOT NULL REFERENCES cache_payments(paymentId),\n attemptId TEXT NOT NULL,\n firstTs TEXT NOT NULL,\n firstId TEXT NOT NULL,\n winnerTs TEXT NOT NULL,\n winnerId TEXT NOT NULL,\n status TEXT NOT NULL CHECK (status IN ('unknown', 'confirmed', 'failed')),\n payload TEXT NOT NULL CHECK (json_valid(payload)),\n PRIMARY KEY (paymentId, attemptId)\n);\nCREATE INDEX cache_attempt_selection ON cache_attempts (\n paymentId, status, firstTs, firstId COLLATE NOCASE, firstId DESC\n);\n\nCREATE TABLE cache_prefix (\n partitionKey TEXT NOT NULL,\n prefix TEXT NOT NULL,\n amount TEXT NOT NULL CHECK (amount <> '' AND amount NOT GLOB '*[^0-9]*'),\n PRIMARY KEY (partitionKey, prefix)\n) WITHOUT ROWID;\n\nINSERT INTO schema_version VALUES (2);\n";
824
986
 
987
+ // migrations/003.sql
988
+ var __default3 = "ALTER TABLE cache_prefix ADD COLUMN count TEXT NOT NULL DEFAULT '0'\n CHECK (count <> '' AND count NOT GLOB '*[^0-9]*');\n\nINSERT INTO schema_version VALUES (3);\n";
989
+
825
990
  // src/ledger/backup.ts
826
991
  import { mkdtempSync, renameSync, writeFileSync } from "fs";
827
992
  import { join as join2, resolve as resolve2 } from "path";
828
993
  import Database from "better-sqlite3";
829
- function backupLedger(path) {
830
- const directory = mkdtempSync(`${resolve2(path)}.backup-v1-`);
994
+ function backupLedger(path, version3) {
995
+ const directory = mkdtempSync(`${resolve2(path)}.backup-v${version3}-`);
831
996
  const partial = join2(directory, "ledger.partial.db");
832
997
  const destination = join2(directory, "ledger.db");
833
998
  writeFileSync(partial, "", { flag: "wx", mode: 384 });
@@ -868,7 +1033,8 @@ var attemptRowSchema = z7.object({
868
1033
  });
869
1034
  var prefixRowSchema = z7.object({
870
1035
  prefix: z7.string().regex(/^[0-9a-f]{0,16}$/),
871
- amount: integerStringSchema
1036
+ amount: integerStringSchema,
1037
+ count: integerStringSchema
872
1038
  });
873
1039
  var scopeSchema = z7.enum(["perTask", "perAgent", "global"]);
874
1040
  var windowSchema = z7.enum(["1h", "24h", "7d", "30d"]);
@@ -968,17 +1134,18 @@ var LedgerCache = class {
968
1134
  const row = payloadSchema.optional().parse(this.statement("SELECT payload FROM cache_payments WHERE paymentKey = ?").get(key));
969
1135
  return row ? paymentEventSchema.parse(JSON.parse(row.payload)) : void 0;
970
1136
  }
971
- spent(proposed, inputScope, inputWindow, now) {
1137
+ totals(proposed, inputScope, inputWindow, now) {
972
1138
  const payment = paymentEventSchema.parse(proposed);
973
1139
  const scope = scopeSchema.parse(inputScope);
974
1140
  const window = windowSchema.optional().parse(inputWindow);
975
1141
  const end = timeSchema.parse(now);
976
1142
  const key = partition(payment, scope);
977
1143
  const upper = this.prefixTotal(key, end);
978
- const lower = window ? this.prefixTotal(key, end - windows2[window] - 1) : 0n;
979
- const result = upper - lower;
980
- if (result < 0n) throw new Error("Cached budget total is inconsistent");
981
- return result.toString();
1144
+ const lower = window ? this.prefixTotal(key, end - windows2[window] - 1) : { amount: 0n, count: 0n };
1145
+ const amount = upper.amount - lower.amount;
1146
+ const count = upper.count - lower.count;
1147
+ if (amount < 0n || count < 0n) throw new Error("Cached budget total is inconsistent");
1148
+ return { amount: amount.toString(), count: count.toString() };
982
1149
  }
983
1150
  applyOutcome(outcome) {
984
1151
  const row = paymentRowSchema.optional().parse(
@@ -1065,14 +1232,16 @@ var LedgerCache = class {
1065
1232
  const add = (payment, sign) => {
1066
1233
  if (!countsAsSpend(payment)) return;
1067
1234
  const amount = BigInt(payment.amount) * sign;
1068
- if (amount === 0n) return;
1069
1235
  const timestamp = timeKey(Date.parse(payment.attemptedAt ?? payment.ts));
1070
1236
  for (const scope of ["perTask", "perAgent", "global"]) {
1071
1237
  const key = partition(payment, scope);
1072
1238
  const nodes = changes.get(key) ?? /* @__PURE__ */ new Map();
1073
1239
  for (let length = 0; length <= timestamp.length; length += 1) {
1074
1240
  const prefix = timestamp.slice(0, length);
1075
- nodes.set(prefix, (nodes.get(prefix) ?? 0n) + amount);
1241
+ const contribution = nodes.get(prefix) ?? { amount: 0n, count: 0n };
1242
+ contribution.amount += amount;
1243
+ contribution.count += sign;
1244
+ nodes.set(prefix, contribution);
1076
1245
  }
1077
1246
  changes.set(key, nodes);
1078
1247
  }
@@ -1080,27 +1249,29 @@ var LedgerCache = class {
1080
1249
  if (previous) add(previous, -1n);
1081
1250
  add(current, 1n);
1082
1251
  for (const [key, nodes] of changes) {
1083
- const pending = [...nodes].filter(([, amount]) => amount !== 0n);
1252
+ const pending = [...nodes].filter(([, value]) => value.amount !== 0n || value.count !== 0n);
1084
1253
  if (pending.length === 0) continue;
1085
1254
  const existing = /* @__PURE__ */ new Map();
1086
1255
  for (const input of this.statement(
1087
- "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1256
+ "SELECT prefix, amount, count FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1088
1257
  ).iterate(key, JSON.stringify(pending.map(([prefix]) => prefix)))) {
1089
1258
  const row = prefixRowSchema.parse(input);
1090
- existing.set(row.prefix, BigInt(row.amount));
1259
+ existing.set(row.prefix, { amount: BigInt(row.amount), count: BigInt(row.count) });
1091
1260
  }
1092
1261
  for (const [prefix, delta] of pending) {
1093
- const next = (existing.get(prefix) ?? 0n) + delta;
1094
- if (next < 0n) throw new Error("Cached monetary contribution underflow");
1095
- if (next === 0n) {
1262
+ const before = existing.get(prefix) ?? { amount: 0n, count: 0n };
1263
+ const amount = before.amount + delta.amount;
1264
+ const count = before.count + delta.count;
1265
+ if (amount < 0n || count < 0n) throw new Error("Cached contribution underflow");
1266
+ if (amount === 0n && count === 0n) {
1096
1267
  this.statement("DELETE FROM cache_prefix WHERE partitionKey = ? AND prefix = ?").run(
1097
1268
  key,
1098
1269
  prefix
1099
1270
  );
1100
1271
  } else {
1101
1272
  this.statement(
1102
- "INSERT INTO cache_prefix (partitionKey, prefix, amount) VALUES (?, ?, ?) ON CONFLICT(partitionKey, prefix) DO UPDATE SET amount = excluded.amount"
1103
- ).run(key, prefix, next.toString());
1273
+ "INSERT INTO cache_prefix (partitionKey, prefix, amount, count) VALUES (?, ?, ?, ?) ON CONFLICT(partitionKey, prefix) DO UPDATE SET amount = excluded.amount, count = excluded.count"
1274
+ ).run(key, prefix, amount.toString(), count.toString());
1104
1275
  }
1105
1276
  }
1106
1277
  }
@@ -1117,11 +1288,13 @@ var LedgerCache = class {
1117
1288
  }
1118
1289
  }
1119
1290
  prefixes.push(timestamp);
1120
- let sum = 0n;
1291
+ const sum = { amount: 0n, count: 0n };
1121
1292
  for (const input of this.statement(
1122
- "SELECT prefix, amount FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1293
+ "SELECT prefix, amount, count FROM cache_prefix WHERE partitionKey = ? AND prefix IN (SELECT value FROM json_each(?))"
1123
1294
  ).iterate(key, JSON.stringify(prefixes))) {
1124
- sum += BigInt(prefixRowSchema.parse(input).amount);
1295
+ const row = prefixRowSchema.parse(input);
1296
+ sum.amount += BigInt(row.amount);
1297
+ sum.count += BigInt(row.count);
1125
1298
  }
1126
1299
  return sum;
1127
1300
  }
@@ -1129,6 +1302,7 @@ var LedgerCache = class {
1129
1302
 
1130
1303
  // src/ledger/store.ts
1131
1304
  var rowSchema = z8.object({ payload: z8.string() });
1305
+ var countRowSchema = z8.object({ count: z8.number().int().nonnegative() });
1132
1306
  var Ledger = class {
1133
1307
  database;
1134
1308
  cache;
@@ -1145,20 +1319,22 @@ var Ledger = class {
1145
1319
  "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'"
1146
1320
  ).get();
1147
1321
  if (!table) this.database.exec(__default);
1148
- const version3 = z8.object({ version: z8.union([z8.literal(1), z8.literal(2)]) }).parse(
1322
+ const version3 = z8.object({ version: z8.union([z8.literal(1), z8.literal(2), z8.literal(3)]) }).parse(
1149
1323
  this.database.prepare("SELECT MAX(version) AS version FROM schema_version").get()
1150
1324
  );
1151
- if (version3.version === 1) {
1325
+ if (version3.version !== 3) {
1152
1326
  if (table && path !== ":memory:") {
1153
1327
  process.stderr.write("Migrating ledger\u2026\n");
1154
- const backup = backupLedger(path);
1328
+ const backup = backupLedger(path, version3.version);
1155
1329
  process.stderr.write(`Ledger backup saved: ${backup}
1156
1330
  `);
1157
1331
  }
1158
- this.database.exec(__default2);
1332
+ if (version3.version === 1) this.database.exec(__default2);
1333
+ this.database.exec(__default3);
1159
1334
  }
1160
1335
  const cache = new LedgerCache(this.database);
1161
- cache.synchronize();
1336
+ if (version3.version === 2) cache.rebuild();
1337
+ else cache.synchronize();
1162
1338
  return cache;
1163
1339
  }).immediate();
1164
1340
  } catch (error) {
@@ -1201,12 +1377,22 @@ var Ledger = class {
1201
1377
  policyState(proposed, budgets, now) {
1202
1378
  return this.transaction(() => {
1203
1379
  this.cache.synchronize();
1380
+ const totals2 = {
1381
+ perTask: budgets.perTask ? this.cache.totals(proposed, "perTask", budgets.perTask.window, now) : { amount: "0", count: "0" },
1382
+ perAgent: budgets.perAgent ? this.cache.totals(proposed, "perAgent", budgets.perAgent.window, now) : { amount: "0", count: "0" },
1383
+ global: budgets.global ? this.cache.totals(proposed, "global", budgets.global.window, now) : { amount: "0", count: "0" }
1384
+ };
1204
1385
  return {
1205
1386
  reserved: this.cache.reservation(proposed.paymentKey),
1206
1387
  spent: {
1207
- perTask: budgets.perTask ? this.cache.spent(proposed, "perTask", budgets.perTask.window, now) : "0",
1208
- perAgent: budgets.perAgent ? this.cache.spent(proposed, "perAgent", budgets.perAgent.window, now) : "0",
1209
- global: budgets.global ? this.cache.spent(proposed, "global", budgets.global.window, now) : "0"
1388
+ perTask: totals2.perTask.amount,
1389
+ perAgent: totals2.perAgent.amount,
1390
+ global: totals2.global.amount
1391
+ },
1392
+ counts: {
1393
+ perTask: totals2.perTask.count,
1394
+ perAgent: totals2.perAgent.count,
1395
+ global: totals2.global.count
1210
1396
  }
1211
1397
  };
1212
1398
  });
@@ -1215,6 +1401,9 @@ var Ledger = class {
1215
1401
  rebuildCache() {
1216
1402
  this.transaction(() => this.cache.rebuild());
1217
1403
  }
1404
+ eventCount() {
1405
+ return countRowSchema.parse(this.database.prepare("SELECT COUNT(*) AS count FROM events").get()).count;
1406
+ }
1218
1407
  events() {
1219
1408
  return this.database.prepare("SELECT payload FROM events ORDER BY ts, id").all().map((row) => paymentEventSchema.parse(JSON.parse(rowSchema.parse(row).payload)));
1220
1409
  }
@@ -1243,7 +1432,7 @@ var Ledger = class {
1243
1432
  };
1244
1433
 
1245
1434
  // src/proxy/index.ts
1246
- import { Console } from "console";
1435
+ import { Console as Console2 } from "console";
1247
1436
  import {
1248
1437
  createServer,
1249
1438
  request as httpRequest,
@@ -1258,7 +1447,7 @@ var incomingHeadersSchema = z9.record(
1258
1447
  );
1259
1448
  var sockets = /* @__PURE__ */ new WeakMap();
1260
1449
  var tlsUnmeteredMessage = "Encrypted CONNECT tunnel forwarded without payment visibility. Use the SDK or explicit upstream mode to meter HTTPS.";
1261
- var notices = new Console({ stdout: process.stdout, stderr: process.stderr, ignoreErrors: true });
1450
+ var notices = new Console2({ stdout: process.stdout, stderr: process.stderr, ignoreErrors: true });
1262
1451
  var tlsNoticeShown = false;
1263
1452
  function normalizedHeaders(input) {
1264
1453
  const result = {};
@@ -1315,7 +1504,7 @@ function json(response, status, body) {
1315
1504
  response.end(JSON.stringify(body));
1316
1505
  }
1317
1506
  function createProxy(options) {
1318
- const config = parseConfig(options.config);
1507
+ const config = configSchema.parse(options.config);
1319
1508
  const meter = new Meter(options.ledger, config);
1320
1509
  const server = createServer({ requestTimeout: 0 }, (request, response) => {
1321
1510
  let target;
@@ -1512,13 +1701,14 @@ async function closeProxy(server) {
1512
1701
  import { z as z10 } from "zod";
1513
1702
 
1514
1703
  // package.json
1515
- var version = "0.2.2";
1704
+ var version = "0.3.0";
1516
1705
 
1517
1706
  // src/version.ts
1518
1707
  var version2 = z10.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/).parse(version);
1519
1708
 
1520
1709
  export {
1521
1710
  integerStringSchema,
1711
+ amountSchema,
1522
1712
  httpUrlSchema,
1523
1713
  labelSchema,
1524
1714
  paymentEventSchema,
@@ -1528,14 +1718,17 @@ export {
1528
1718
  parseConfig,
1529
1719
  expandPath,
1530
1720
  portInputSchema,
1721
+ loadConfigDetails,
1531
1722
  loadConfig,
1723
+ assetMetadata,
1724
+ knownAssets,
1532
1725
  matchesAsset,
1533
1726
  totalSchema,
1534
1727
  deriveEvents,
1535
1728
  countsAsSpend,
1536
1729
  totals,
1537
1730
  formatAmount,
1538
- spentForBudget,
1731
+ totalsForBudget,
1539
1732
  evaluate,
1540
1733
  X402Rail,
1541
1734
  Meter,