taximeter 0.1.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.
@@ -0,0 +1 @@
1
+ :root{color-scheme:light;--background: #f6f8f5;--surface: #ffffff;--surface-muted: #edf1ed;--text: #182b25;--muted: #60736a;--faint: #798b81;--border: #dce4dd;--border-strong: #becdc1;--accent: #237d5c;--accent-soft: #d9ece1;--warning: #8b6819;--warning-soft: #f7efd9;--danger: #a74335;--danger-soft: #fff0eb;--chart-grid: #e8ede8;--font-ui: "Inter", "Aptos", "Segoe UI", Arial, sans-serif;--font-mono: "JetBrains Mono", "Cascadia Code", "SFMono-Regular", Consolas, monospace;font-family:var(--font-ui);font-size:14px;color:var(--text);background:var(--background);font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--background: #101b16;--surface: #16231c;--surface-muted: #1c2c23;--text: #e4ede5;--muted: #a0b4a6;--faint: #82998a;--border: #2d4033;--border-strong: #49604f;--accent: #74c6a0;--accent-soft: #243f32;--warning: #dfbe73;--warning-soft: #38301c;--danger: #f29782;--danger-soft: #35241f;--chart-grid: #24372b}}*{box-sizing:border-box}body{margin:0;background:var(--background)}button,input,select{font:inherit}button,select{color:var(--text)}button,a,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}a{color:inherit;text-decoration:none}a:hover{color:var(--accent)}button:focus-visible,a:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:4px}button,select{border-radius:3px}h1,h2,h3,p{margin:0}h1{font-size:24px;font-weight:550;letter-spacing:-.9px;line-height:1.2}h2{font-size:14px;font-weight:600;letter-spacing:-.15px}h3{font-size:14px;font-weight:550}strong{font-weight:600}code,.mono,.numeric{font-family:var(--font-mono);font-variant-numeric:tabular-nums}.mono{letter-spacing:-.4px}.numeric{text-align:right;white-space:nowrap}.muted,.secondary-label{color:var(--muted);font-weight:400}.eyebrow{display:block;font-size:10px;font-weight:650;letter-spacing:1.25px;color:var(--muted);line-height:1.5}.masthead{min-height:66px;padding:0 36px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:var(--surface);border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:24px;min-width:0}.wordmark{display:inline-flex;align-items:center;gap:10px;font-size:22px;font-weight:650;letter-spacing:-1px;line-height:1;color:var(--text)}.mark{display:flex;align-items:flex-end;width:22px;height:21px;gap:3px}.mark i{display:block;width:5px;background:var(--accent)}.mark i:nth-child(1){height:9px}.mark i:nth-child(2){height:15px}.mark i:nth-child(3){height:21px}.brand-description{color:var(--muted);font-size:12px;padding-left:24px;border-left:1px solid var(--border)}.connection-state{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:11px;white-space:nowrap}.live-dot{width:6px;height:6px;border-radius:50%;background:var(--accent)}.connection-offline .live-dot{background:var(--danger)}.connection-connecting .live-dot{background:var(--warning)}.navigation{display:flex;align-items:stretch;gap:26px;padding:0 36px;min-height:45px;border-bottom:1px solid var(--border);background:var(--surface);overflow-x:auto}.navigation button{border:0;border-radius:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);padding:0 1px;font-size:12px;white-space:nowrap;flex-shrink:0}.navigation button:hover{color:var(--text)}.navigation button[aria-current=page]{border-bottom-color:var(--accent);color:var(--accent);font-weight:600}.nav-date{margin-left:auto;align-self:center;padding-left:18px;font-size:11px;color:var(--muted);white-space:nowrap}main{max-width:1600px;padding:23px 36px 0;margin:0 auto}.page-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:22px}.page-heading .eyebrow{margin-bottom:6px}.asset-selector{display:flex;align-items:center;gap:10px;flex-shrink:0}.asset-selector label{font-size:11px;color:var(--muted)}.asset-selector select{padding:7px 28px 7px 10px;border:1px solid var(--border-strong);background:var(--surface);font-size:12px;max-width:340px}.connection-warning{color:var(--danger);background:var(--danger-soft);border-left:3px solid var(--danger);padding:11px 14px;margin-bottom:18px;font-size:12px}.budget-hero{display:grid;grid-template-columns:minmax(0,1.22fr) minmax(280px,.78fr);align-items:center;gap:55px;padding:0 0 19px;border-bottom:1px solid var(--border-strong)}.hero-value{min-width:0}.hero-number{font-size:clamp(27px,3.2vw,44px);line-height:1.2;letter-spacing:-2px;color:var(--accent);margin:7px 0 8px;overflow-x:auto;white-space:nowrap;padding-bottom:2px}.hero-unit{color:var(--muted);font-size:14px;letter-spacing:0;margin-left:13px}.hero-value p{font-size:10px;color:var(--muted);overflow-wrap:anywhere}.budget-detail{padding-top:10px}.budget-caption{display:flex;justify-content:space-between;align-items:baseline;gap:15px;font-size:11px}.budget-caption .mono{overflow-wrap:anywhere}.budget-track{height:7px;background:var(--surface-muted);border:1px solid var(--border);margin:12px 0 10px;overflow:hidden}.budget-track>span{display:block;height:100%;background:var(--accent)}.budget-caption.below{color:var(--muted);font-size:10px}:where(.budget-caption.below) strong{color:var(--text);font-weight:500}.scope-note{color:var(--faint);font-size:10px;margin-top:10px}.totals-strip{display:flex;align-items:center;gap:25px;font-size:10px;color:var(--muted);padding:10px 0 12px;border-bottom:1px solid var(--border);flex-wrap:wrap}.totals-strip>span{display:inline-flex;gap:8px;align-items:baseline}.totals-strip strong{color:var(--text);font-size:11px;font-weight:500}.totals-strip .blocked-count{margin-left:auto}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:16px 0 12px}.table-note{font-size:10px;color:var(--muted);white-space:nowrap}.table-scroll{overflow-x:auto;width:100%}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;font-size:10px;color:var(--muted);font-weight:550;border-top:1px solid var(--border-strong);border-bottom:1px solid var(--border-strong);padding:8px 10px;background:var(--surface-muted);white-space:nowrap}td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:middle}th:first-child,td:first-child{padding-left:13px}th:last-child,td:last-child{padding-right:13px}.event-table{table-layout:fixed;min-width:850px;font-size:11px}.event-table th:nth-child(1){width:82px}.event-table th:nth-child(2){width:22%}.event-table th:nth-child(3){width:23%}.event-table th:nth-child(4){width:110px}.event-table th:nth-child(5){width:145px}.event-table th:nth-child(6){width:70px}.event-table th:nth-child(7){width:85px}.event-table td{padding-top:2px;padding-bottom:2px;line-height:16px;height:20px}.event-table tbody tr:hover{background:var(--surface)}.event-table .resource-cell,.event-table .attribution-cell,.event-table .asset-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-table .resource-cell a:hover{text-decoration:underline;text-underline-offset:3px}.network-cell,.time-cell{color:var(--muted)}.event-table .numeric{overflow:hidden;text-overflow:ellipsis}.status-cell{text-align:right}.event-status{font-size:10px;white-space:nowrap}.status-confirmed{color:var(--muted)}.status-unknown{color:var(--warning)}.status-failed{color:var(--muted)}.status-blocked{color:var(--danger);font-weight:600}.event-table .blocked-row{background:var(--danger-soft)}.blocked-row td:first-child{border-left:3px solid var(--danger);padding-left:10px}.ledger-footnote{font-size:10px;color:var(--muted);line-height:1.7;padding-top:11px}.empty-state{background:var(--surface);border:1px solid var(--border);margin:25px 0 22px;padding:34px 32px 0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,.95fr);column-gap:58px}.empty-heading h2{font-size:26px;font-weight:500;letter-spacing:-.8px;max-width:320px;margin:10px 0 13px}.empty-heading p{font-size:12px;color:var(--muted);line-height:1.8;max-width:430px}.connection-instructions{padding-left:28px;border-left:1px solid var(--border)}.connection-instructions>code{display:block;font-size:18px;color:var(--accent);margin:12px 0 16px;letter-spacing:-.5px;overflow-wrap:anywhere}.connection-instructions p{font-size:11px;line-height:1.8;color:var(--muted)}.connection-instructions strong{font-weight:500;overflow-wrap:anywhere}.connection-instructions .connection-limit{margin-top:12px}.empty-footer{grid-column:1 / -1;border-top:1px solid var(--border);margin-top:30px;padding:14px 0;display:flex;align-items:center;justify-content:space-between;gap:20px;color:var(--muted);font-size:10px}.group-section,.timeline-section,.export-section{border-top:1px solid var(--border-strong)}.group-table{min-width:650px}.group-table th button{border:0;padding:0;background:transparent;font:inherit;color:inherit}.group-table td{padding-top:17px;padding-bottom:17px}.group-name{max-width:420px;overflow-wrap:anywhere;font-size:13px}.group-bar-cell{width:28%}.group-bar{height:5px;background:var(--surface-muted);min-width:90px}.group-bar span{display:block;height:100%;background:var(--accent)}.quiet-empty{background:var(--surface);border:1px solid var(--border);padding:36px 25px;margin:14px 0}.quiet-empty h3{margin-bottom:8px;font-size:18px}.quiet-empty p{font-size:12px;color:var(--muted);line-height:1.7}.chart-heading{display:flex;align-items:baseline;justify-content:space-between;gap:20px;margin:18px 0 5px}.chart-heading h3{font-size:12px;color:var(--muted)}.chart-heading .mono{color:var(--accent);font-size:14px}.chart-scroll{overflow-x:auto}.chart{display:block;width:100%;min-width:660px}.chart text{fill:var(--muted);font:11px var(--font-mono);font-variant-numeric:tabular-nums}.chart-rule{stroke:var(--border-strong);stroke-width:1}.chart-grid{stroke:var(--chart-grid);stroke-width:1;stroke-dasharray:3 5}.sparkline{fill:none;stroke:var(--accent);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}.chart-dot,.hour-bar{fill:var(--accent)}.hourly-heading{border-top:1px solid var(--border);padding-top:20px;margin-top:8px;font-size:11px}.hourly-chart{margin-top:8px}.section-intro{color:var(--muted);font-size:12px;line-height:1.8;max-width:700px;margin:2px 0 23px}.export-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;margin-bottom:30px}.export-option{display:flex;flex-direction:column;gap:14px;padding:22px;border:1px solid var(--border-strong);background:var(--surface);min-height:192px}.export-option:hover{border-color:var(--accent);color:var(--text)}.export-option>strong{font-size:24px;letter-spacing:-.5px;font-weight:500;display:flex;justify-content:space-between}.download-arrow{color:var(--accent);font-size:23px}.export-option>span:not(.eyebrow){font-size:11px;color:var(--muted);line-height:1.6}.export-option>span.download-label{margin-top:auto;color:var(--accent);font-weight:600;padding-top:7px}.statement-preview{background:var(--surface);border:1px solid var(--border);padding:26px}.preview-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.preview-heading>span{font-size:10px;color:var(--muted);letter-spacing:1px}.preview-summary{display:flex;gap:24px;flex-wrap:wrap;color:var(--muted);font-size:11px;margin-bottom:22px}.preview-summary strong{margin-left:8px;color:var(--text)}.statement-preview table{min-width:700px}.contract-address{display:block;font-family:var(--font-mono);color:var(--faint);font-size:9px;margin-top:5px}.diagnostics{border-top:1px solid var(--border);margin-top:22px;font-size:11px}.diagnostics summary{cursor:pointer;padding:13px 0}.diagnostics .mono{font-size:10px;margin-left:7px}.diagnostics ul{list-style:none;padding:0;margin:0 0 15px}.diagnostics li{display:grid;grid-template-columns:75px 145px 1fr;gap:14px;padding:8px 12px;border-bottom:1px solid var(--border);line-height:1.7}.diagnostics li>span{color:var(--muted)}.diagnostics li strong{font-weight:500;color:var(--warning)}.page-footer{display:flex;align-items:center;gap:15px;flex-wrap:wrap;margin-top:25px;padding:15px 0 18px;border-top:1px solid var(--border);color:var(--faint);font-size:9px;line-height:1.7}.page-footer>span:nth-child(2){margin-left:auto}.page-footer>.mono{margin-left:6px}.loading-shell{max-width:580px;margin:15vh auto 0;padding:30px}.loading-shell>.wordmark{margin-bottom:50px}.loading-shell h1{margin:10px 0 16px;font-size:30px}.loading-shell p{color:var(--muted);line-height:1.8;font-size:13px}.loading-shell>code{display:block;color:var(--accent);border:1px solid var(--border);padding:15px;margin-top:28px;background:var(--surface)}@media(min-width:1600px){.masthead,.navigation{padding-left:max(36px,calc((100vw - 1528px)/2));padding-right:max(36px,calc((100vw - 1528px)/2))}}@media(max-width:900px){.masthead,.navigation{padding-left:24px;padding-right:24px}main{padding-left:24px;padding-right:24px}.brand-description{display:none}.budget-hero{gap:28px;grid-template-columns:minmax(0,1fr) minmax(260px,1fr)}.totals-strip{gap:10px 20px}.empty-state{column-gap:26px;padding-left:24px;padding-right:24px}.empty-heading h2{font-size:22px}.empty-footer{align-items:flex-start}.export-option{padding:18px}}@media(max-width:600px){.masthead{padding:0 19px;min-height:61px}.wordmark{font-size:21px}.connection-state{font-size:10px}.navigation{gap:23px;padding:0 19px;min-height:43px}.navigation button{font-size:12px}.nav-date{display:none}main{padding:22px 19px 0}.page-heading{align-items:flex-start;flex-direction:column;gap:17px;margin-bottom:23px}h1{font-size:23px;letter-spacing:-.75px}.asset-selector{justify-content:space-between;width:100%}.asset-selector select{max-width:265px;flex:1}.budget-hero{grid-template-columns:minmax(0,1fr);gap:18px;padding-bottom:18px}.hero-number{font-size:34px;letter-spacing:-1.6px}.hero-unit{font-size:12px;margin-left:9px}.hero-value p{font-size:9px}.budget-detail{padding-top:0}.budget-caption{font-size:10px}.scope-note{margin-top:8px}.totals-strip{display:grid;grid-template-columns:1fr 1fr;gap:12px 18px;padding:16px 0}.totals-strip>span{flex-direction:column;gap:3px;min-width:0}.totals-strip strong{overflow-wrap:anywhere}.totals-strip .blocked-count{margin-left:0}.section-heading{gap:10px;padding-top:20px}.table-note{font-size:9px}.event-table{min-width:780px}.event-table td{padding-top:5px;padding-bottom:5px}.ledger-footnote{font-size:10px;line-height:1.8}.empty-state{grid-template-columns:minmax(0,1fr);padding:26px 22px 0;margin-top:23px}.empty-heading h2{font-size:24px;max-width:260px}.empty-heading p{font-size:12px;line-height:1.8}.connection-instructions{margin-top:24px;padding:23px 0 0;border-left:0;border-top:1px solid var(--border)}.connection-instructions>code{font-size:16px}.empty-footer{flex-direction:column;gap:6px;margin-top:25px;font-size:9px}.chart-heading{flex-direction:column;gap:8px}.chart{min-width:630px}.export-options{grid-template-columns:minmax(0,1fr);gap:12px}.export-option{min-height:176px;padding:21px}.section-intro{font-size:12px}.statement-preview{padding:19px}.preview-heading{flex-direction:column;align-items:flex-start}.preview-summary{gap:12px;flex-direction:column}.diagnostics li{grid-template-columns:66px 1fr;gap:6px 10px;padding-left:0;padding-right:0}.diagnostics li>span:last-child{grid-column:1 / -1}.page-footer{align-items:flex-start;gap:6px;flex-direction:column;margin-top:24px}.page-footer>span:nth-child(2),.page-footer>.mono{margin-left:0}}
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <meta name="description" content="A taximeter for your AI agents. Your local payment ledger." />
8
+ <title>Taximeter — Local ledger</title>
9
+ <script type="module" crossorigin src="/assets/index-DOE5bD9P.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-De4zKRDL.css">
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
@@ -0,0 +1,75 @@
1
+ # Record the 20-second demo
2
+
3
+ `demo.gif` is a 20-second recording of the included simulation running on Windows,
4
+ captured on 2026-09-08. The simulation runs entirely against a local fixture and
5
+ an in-memory ledger. Its signatures and settlement reports are synthetic.
6
+
7
+ From a source checkout, prepare the built package:
8
+
9
+ ```sh
10
+ npm ci
11
+ npm run build
12
+ node docs/demo.mjs
13
+ ```
14
+
15
+ The simulation prints 20 allowed payments, the documented 402 block for payment
16
+ 21, and an exact total of `140` atomic units. It creates no wallet, key, or persistent
17
+ ledger and closes its listeners when finished.
18
+
19
+ ## Captured Windows recording
20
+
21
+ The shipped GIF captures an actual `cmd.exe` session through Windows ConPTY using
22
+ [node-pty 1.1.0](https://github.com/microsoft/node-pty). The recorder types
23
+ `node docs/demo.mjs`, stores the terminal's output with its original timing in an
24
+ asciicast v2 file, and waits until 20 seconds have elapsed. It checks the block and
25
+ exact ledger total in the captured output and requires the command shell to exit
26
+ successfully. The terminal is 104 columns by 32 rows.
27
+
28
+ [agg 1.9.0](https://github.com/asciinema/agg/releases/tag/v1.9.0) renders that capture
29
+ using Consolas at 18 px, line height 1.1, the GitHub dark theme, and a 20 fps cap.
30
+ The final frame is held long enough for the complete GIF to last 20 seconds.
31
+ The resulting GIF is 1049×653 and 96,519 bytes. The final block response and ledger
32
+ total remain visible together. No demo output is fabricated or replaced.
33
+
34
+ The source capture and encoding tools are local development artifacts and are
35
+ excluded from the repository and npm package. FFmpeg converts the GIF to an H.264
36
+ MP4 with one padding row and column for even dimensions. The following recipes
37
+ can reproduce the demonstration from a source checkout.
38
+
39
+ ## VHS
40
+
41
+ Install VHS and its ffmpeg/ttyd prerequisites from the
42
+ [official VHS instructions](https://github.com/charmbracelet/vhs#installation).
43
+ Run from the repository root on a system supported by VHS (WSL is suitable on Windows):
44
+
45
+ ```sh
46
+ vhs docs/demo.tape
47
+ ```
48
+
49
+ The tape types exactly `node docs/demo.mjs`, then leaves the resulting block and
50
+ total visible. It writes `docs/demo.gif` at 960×660. Review the output before
51
+ committing the replacement; it must fit within the package's 2 MB tarball limit.
52
+
53
+ ## asciinema alternative
54
+
55
+ With asciinema and agg installed from their official projects, record the same
56
+ script and convert it:
57
+
58
+ ```sh
59
+ mkdir -p tmp
60
+ asciinema rec --cols 104 --rows 32 --command "node docs/demo.mjs" tmp/demo.cast
61
+ agg tmp/demo.cast docs/demo.gif
62
+ ```
63
+
64
+ This quick alternative uses agg's default final pause and produces a shorter GIF.
65
+ For a 20-second result, increase `--last-frame-duration` by the difference between
66
+ 20 seconds and the measured GIF duration, then render again. The verified Windows
67
+ rendering helper calculates that pause automatically.
68
+
69
+ Run these commands in a POSIX shell or WSL. Do not enter account data,
70
+ wallet material, or real payment authorizations while recording. The expected
71
+ terminal script is only the local demo command above.
72
+
73
+ The Windows ConPTY/agg recording above was executed and visually checked. The VHS
74
+ and asciinema alternatives remain documented recipes, not verified Windows runs.
75
+ Recording tools are optional and are not package dependencies.
package/docs/SDK.md ADDED
@@ -0,0 +1,192 @@
1
+ # Taximeter SDK
2
+
3
+ `withMeter` wraps a Node.js fetch transport with the same parser, SQLite ledger,
4
+ and policy engine used by the proxy. It supports Node 20 or later and ESM. It
5
+ does not start a proxy or dashboard, create a payment, or configure a wallet.
6
+
7
+ This project has not published its first npm release yet. From a source
8
+ checkout, run `npm ci` and `npm run build`, then save the examples at the
9
+ repository root. Their `import "taximeter"` statements resolve the package's own
10
+ built exports. To try it in another project, run `npm pack` after building,
11
+ copy the resulting tarball there, and use
12
+ `npm install ./taximeter-0.1.0.tgz`. After the first publication,
13
+ `npm install taximeter` will install the registry release.
14
+
15
+ ## Try it locally
16
+
17
+ After choosing an installation method above, save this complete example as
18
+ `sdk-local.mjs`, then run `node sdk-local.mjs`.
19
+ It starts a temporary HTTP server on loopback, sends one ordinary request, and
20
+ closes both the server and an in-memory ledger. It makes no external requests.
21
+
22
+ ```javascript
23
+ import { once } from "node:events";
24
+ import { createServer } from "node:http";
25
+ import { withMeter } from "taximeter";
26
+
27
+ const upstream = createServer((request, response) => {
28
+ response.writeHead(200, { "Content-Type": "application/json" });
29
+ response.end(JSON.stringify({
30
+ task: request.headers["taximeter-task"],
31
+ agent: request.headers["taximeter-agent"],
32
+ }));
33
+ });
34
+ upstream.listen(0, "127.0.0.1");
35
+ await once(upstream, "listening");
36
+ const address = upstream.address();
37
+ if (!address || typeof address === "string") {
38
+ throw new Error("Expected a local TCP listener");
39
+ }
40
+
41
+ const metered = withMeter(fetch, {
42
+ db: ":memory:",
43
+ taskId: "research",
44
+ agentId: "agent",
45
+ });
46
+ try {
47
+ const response = await metered(`http://127.0.0.1:${address.port}/example`, {
48
+ redirect: "error",
49
+ });
50
+ console.log(await response.text());
51
+ console.log(`Payments recorded: ${metered.ledger.events().length}`);
52
+ } finally {
53
+ metered.close();
54
+ upstream.closeAllConnections();
55
+ await new Promise((resolve, reject) => {
56
+ upstream.close((error) => error ? reject(error) : resolve());
57
+ });
58
+ }
59
+ ```
60
+
61
+ Expected output:
62
+
63
+ ```text
64
+ {"task":"research","agent":"agent"}
65
+ Payments recorded: 0
66
+ ```
67
+
68
+ An ordinary request is not a payment. Attribution headers travel with the
69
+ request, but a ledger payment appears only when the transport sees a recognized
70
+ x402 authorization. A 402 challenge alone also contributes no spend.
71
+
72
+ ## Place the meter inside the payment wrapper
73
+
74
+ Use `wrapFetchWithPayment(withMeter(fetch, options), client)`. This placement
75
+ lets Taximeter inspect the initial challenge and gate each signed replay before
76
+ the supplied transport forwards it. Wrapping a payment-enabled fetch on the
77
+ outside hides that wrapper's internal replay.
78
+
79
+ The following ESM factory accepts your application's existing, configured
80
+ `x402Client` or `x402HTTPClient`. It neither creates nor receives a private key.
81
+ Install the optional integration dependency with
82
+ `npm install @x402/fetch@2.25.0`, then save this module as `metered-payment.mjs`.
83
+ Taximeter itself does not depend on the official payment client.
84
+
85
+ ```javascript
86
+ import { wrapFetchWithPayment } from "@x402/fetch";
87
+ import { withMeter } from "taximeter";
88
+
89
+ /**
90
+ * @param {import("@x402/fetch").x402Client | import("@x402/fetch").x402HTTPClient} client
91
+ * @param {import("taximeter").MeterOptions} [options]
92
+ */
93
+ export function createMeteredPaymentFetch(client, options = {}) {
94
+ const transport = withMeter(fetch, options);
95
+ return {
96
+ fetch: wrapFetchWithPayment(transport, client),
97
+ ledger: transport.ledger,
98
+ close: () => transport.close(),
99
+ };
100
+ }
101
+ ```
102
+
103
+ Pass your existing client and attribution options to
104
+ `createMeteredPaymentFetch(client, { taskId: "research", agentId: "agent" })`.
105
+ Use the returned `fetch` in place of your existing paid fetch, and call `close`
106
+ after all requests have finished. Client setup and authorization remain your
107
+ application's responsibility. Configuring the payment client to support a rail
108
+ does not add that rail to Taximeter's supported subset.
109
+
110
+ The factory's parameter types and composition match the published
111
+ [@x402/fetch 2.25.0 declarations](https://unpkg.com/@x402/fetch@2.25.0/dist/esm/index.d.mts)
112
+ and [transport implementation](https://unpkg.com/@x402/fetch@2.25.0/dist/esm/index.mjs).
113
+ The local example and factory were exercised with ordinary loopback traffic;
114
+ the integration tests use synthetic x402 authorizations. No wallet or genuine
115
+ payment was used to validate these examples.
116
+
117
+ ## Options and lifecycle
118
+
119
+ `withMeter(fetchImpl, options)` returns a fetch-compatible function with a
120
+ `ledger` property and an idempotent `close()` method.
121
+
122
+ | Option | Default | Meaning |
123
+ | --- | --- | --- |
124
+ | `db` | Resolved configuration; normally `~/.taximeter/ledger.db` | SQLite path. Use `:memory:` for temporary state. Overrides `config.db` when the wrapper creates the ledger. |
125
+ | `config` | Resolved configuration | A partial Taximeter configuration, including budgets and policy. Amounts must be decimal integer strings in atomic units. |
126
+ | `ledger` | A new `Ledger` | An existing Taximeter ledger owned by the caller. Its storage path is used instead of `db`. |
127
+ | `taskId` | Existing `Taximeter-Task` header, otherwise unattributed | Overrides the task header on each request. Use an HTTP-header-compatible label of 1–256 characters. |
128
+ | `agentId` | Existing `Taximeter-Agent` header, otherwise unattributed | Overrides the agent header on each request. Use an HTTP-header-compatible label of 1–256 characters. |
129
+
130
+ Without an injected ledger, configuration resolves from home/cwd files and
131
+ environment, with explicit SDK options taking precedence. See the
132
+ [configuration reference](../README.md#configuration). With an injected
133
+ `ledger`, the SDK uses built-in defaults plus `config`; it does not load config
134
+ files or environment settings. Keep budgets consistent across writers sharing
135
+ the same database.
136
+
137
+ The wrapper closes only a ledger it created. For an injected ledger, close all
138
+ wrappers after their requests finish, then let the owner close the ledger.
139
+ Reusing a closed wrapper passes requests directly to the original transport
140
+ without metering. Do not use a closed wrapper for later payments.
141
+
142
+ ## Fetch behavior and limits
143
+
144
+ - Use a Node fetch implementation that accepts standard `string`, `URL`, or
145
+ `Request` inputs. Header objects, header tuples, and `Headers` are supported.
146
+ Request bodies, abort signals, and transport options are preserved.
147
+ - The wrapper returns the original upstream `Response`. It does not consume
148
+ its body. It inspects a clone only for legacy 402 challenges, bounded to
149
+ 64 KiB and 100 ms. Slow, oversized, malformed, or unsupported challenges pass
150
+ through with a diagnostic.
151
+ - Native fetch or a custom transport can follow redirects internally. Those
152
+ hops are invisible to Taximeter's preflight policy checks. A redirected
153
+ response produces a diagnostic, but its destination may already have received
154
+ a payment header. Use `redirect: "error"` on host-sensitive fetch calls, or a
155
+ transport that exposes each hop separately. Taximeter preserves your redirect
156
+ setting. Redirected v1 challenges are not cached against the initial URL.
157
+ - Internal retries hidden inside a custom transport have the same visibility
158
+ limit. Put payment retries outside the metered transport, as in the official
159
+ wrapper composition above.
160
+ - Recognized authorizations reserve capacity before forwarding. A policy denial
161
+ returns a 402 JSON response with `error: "blocked_by_taximeter"`, `reason`,
162
+ `budget`, `spent`, and `remaining`, and records a blocked event. The enclosing
163
+ payment client can apply its own response handling or throw its own error.
164
+ - If the transport throws after forwarding an authorization, the original
165
+ error is rethrown and the authorization remains reserved with unknown
166
+ settlement. Missing settlement evidence has the same conservative treatment.
167
+ Upstream-reported settlement is not independent on-chain verification.
168
+ - Storage failures pass traffic through and emit a local stderr warning.
169
+ Enforcement requires working storage. Unsupported payment formats also pass
170
+ through with a diagnostic; this is a cooperative meter rather than an
171
+ isolation boundary.
172
+
173
+ The initial adapter meters exact EVM EIP-3009 payments: legacy v1 on Base and
174
+ Base Sepolia, and v2 with EVM CAIP-2 networks. Permit2, ERC-7710, other schemes,
175
+ and other rails remain unmetered. Known Base/Base Sepolia USDC uses six decimal
176
+ places; unknown tokens remain exact atomic units. Budgets and totals are
177
+ separate for every network and asset. See [SPEC-NOTES](../SPEC-NOTES.md) for
178
+ wire formats, challenge correlation, idempotency, and settlement rules.
179
+
180
+ ## Read the same ledger from the CLI
181
+
182
+ For persistent state, use `db: "./agent.db"` in your SDK options. Run
183
+ `npx taximeter report --db ./agent.db --json` to inspect exact totals, or
184
+ `npx taximeter export --db ./agent.db --csv ./agent.csv` to create a statement.
185
+ Export files must not already exist. Monetary CSV `amount` values are counted
186
+ contributions; blocked and known failed rows contribute zero. Sum them
187
+ separately by network and asset.
188
+
189
+ Run `npx taximeter start --db ./agent.db` to view that ledger in the local
190
+ dashboard. The SDK continues to call its own supplied transport; the CLI proxy
191
+ and `config.upstream` do not reroute SDK requests. Stop all SDK writers before
192
+ using `npx taximeter reset --db ./agent.db --yes`, which archives the ledger.
package/docs/demo.gif ADDED
Binary file
package/docs/demo.mjs ADDED
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer, request } from "node:http";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { z } from "zod";
5
+ import { closeProxy, createProxy, Ledger, parseConfig, totals } from "../dist/index.js";
6
+
7
+ // Synthetic envelopes only: no signing, wallet, facilitator, or real payment.
8
+ const asset = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
9
+ const payTo = "0x209693bc6afc0c5328ba36faf03c514ef312287c";
10
+ const payer = "0x857b06519e91e3a54538791bdbb0e22373e36b66";
11
+ const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64");
12
+ const accepted = {
13
+ scheme: "exact",
14
+ network: "eip155:8453",
15
+ asset,
16
+ payTo,
17
+ amount: "7",
18
+ maxTimeoutSeconds: 60,
19
+ extra: { name: "USD Coin", version: "2" },
20
+ };
21
+ const upstream = createServer((req, res) => {
22
+ const header = z.string().optional().parse(req.headers["payment-signature"]);
23
+ if (!header) {
24
+ const resource = z
25
+ .url()
26
+ .parse(`http://${z.string().parse(req.headers.host)}${z.string().parse(req.url)}`);
27
+ res.writeHead(402, {
28
+ "PAYMENT-REQUIRED": encode({
29
+ x402Version: 2,
30
+ resource: { url: resource },
31
+ accepts: [accepted],
32
+ }),
33
+ });
34
+ res.end("Synthetic offer");
35
+ return;
36
+ }
37
+ const payment = z
38
+ .object({ payload: z.object({ authorization: z.object({ nonce: z.string() }) }) })
39
+ .parse(JSON.parse(Buffer.from(header, "base64").toString("utf8")));
40
+ res.writeHead(200, {
41
+ "PAYMENT-RESPONSE": encode({
42
+ success: true,
43
+ network: "eip155:8453",
44
+ payer,
45
+ transaction: payment.payload.authorization.nonce,
46
+ }),
47
+ });
48
+ res.end("Synthetic settlement reported");
49
+ });
50
+ async function listen(server) {
51
+ await new Promise((resolve, reject) => {
52
+ server.once("error", reject);
53
+ server.listen(0, "127.0.0.1", () => {
54
+ server.off("error", reject);
55
+ resolve();
56
+ });
57
+ });
58
+ return z.object({ port: z.number() }).parse(server.address()).port;
59
+ }
60
+ const ledger = new Ledger(":memory:");
61
+ const proxy = createProxy({
62
+ ledger,
63
+ config: parseConfig({ budgets: { perTask: null, perAgent: null, global: { amount: "140" } } }),
64
+ });
65
+ try {
66
+ const upstreamPort = await listen(upstream);
67
+ const proxyPort = await listen(proxy);
68
+ const resource = `http://127.0.0.1:${upstreamPort}/data`;
69
+ const offer = await fetch(resource);
70
+ assert.equal(offer.status, 402);
71
+ const challenge = z
72
+ .object({
73
+ x402Version: z.literal(2),
74
+ resource: z.object({ url: z.url() }),
75
+ accepts: z.array(z.object({ amount: z.literal("7") })),
76
+ })
77
+ .parse(
78
+ JSON.parse(
79
+ Buffer.from(z.string().parse(offer.headers.get("payment-required")), "base64").toString(
80
+ "utf8",
81
+ ),
82
+ ),
83
+ );
84
+ assert.equal(challenge.resource.url, resource);
85
+ await offer.arrayBuffer();
86
+ console.log("Taximeter | local simulation | no money moves");
87
+ console.log("Budget: 140 atomic units. Each payment: 7 atomic units.");
88
+ for (let index = 1; index <= 21; index++) {
89
+ const payload = {
90
+ signature: `0x${"12".repeat(65)}`,
91
+ authorization: {
92
+ from: payer,
93
+ to: payTo,
94
+ value: "7",
95
+ validAfter: "0",
96
+ validBefore: "9999999999",
97
+ nonce: `0x${index.toString(16).padStart(64, "0")}`,
98
+ },
99
+ };
100
+ const result = await new Promise((resolve, reject) => {
101
+ const req = request(
102
+ {
103
+ hostname: "127.0.0.1",
104
+ port: proxyPort,
105
+ path: resource,
106
+ headers: {
107
+ "PAYMENT-SIGNATURE": encode({
108
+ x402Version: 2,
109
+ accepted,
110
+ resource: { url: resource },
111
+ payload,
112
+ }),
113
+ "Taximeter-Task": "demo",
114
+ "Taximeter-Agent": "local-fixture",
115
+ },
116
+ },
117
+ (res) => {
118
+ let body = "";
119
+ res.setEncoding("utf8");
120
+ res.on("data", (chunk) => {
121
+ body += z.string().parse(chunk);
122
+ });
123
+ res.on("error", reject);
124
+ res.on("end", () => resolve({ status: res.statusCode, body }));
125
+ },
126
+ );
127
+ req.on("error", reject);
128
+ req.end();
129
+ });
130
+ assert.equal(result.status, index <= 20 ? 200 : 402);
131
+ console.log(
132
+ `Payment ${index.toString().padStart(2)} | ${index <= 20 ? "allowed" : "BLOCKED"} | total ${totals(ledger.view())[0]?.amount} atomic units`,
133
+ );
134
+ if (index === 21) console.log(result.body);
135
+ await delay(80);
136
+ }
137
+ assert.equal(totals(ledger.view())[0]?.amount, "140");
138
+ assert.equal(ledger.events().filter((row) => row.status === "blocked").length, 1);
139
+ console.log("Exact ledger total: 140 atomic units. One blocked event recorded.");
140
+ } finally {
141
+ if (proxy.listening) await closeProxy(proxy);
142
+ upstream.closeAllConnections();
143
+ if (upstream.listening) await new Promise((resolve) => upstream.close(resolve));
144
+ ledger.close();
145
+ }
package/docs/demo.tape ADDED
@@ -0,0 +1,15 @@
1
+ Output docs/demo.gif
2
+ Set Shell "bash"
3
+ Set FontSize 18
4
+ Set Width 960
5
+ Set Height 660
6
+ Set TypingSpeed 50ms
7
+ Set Padding 20
8
+ Hide
9
+ Type "clear"
10
+ Enter
11
+ Sleep 500ms
12
+ Show
13
+ Type "node docs/demo.mjs"
14
+ Enter
15
+ Sleep 18s
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "taximeter",
3
+ "version": "0.1.0",
4
+ "description": "A taximeter for your AI agents.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Ding808/taximeter.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=20"
13
+ },
14
+ "bin": {
15
+ "taximeter": "dist/cli/index.js",
16
+ "txm": "dist/cli/index.js"
17
+ },
18
+ "main": "dist/index.js",
19
+ "types": "dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE",
30
+ "SECURITY.md",
31
+ "CONTRIBUTING.md",
32
+ "CODE_OF_CONDUCT.md",
33
+ "CHANGELOG.md",
34
+ "DECISIONS.md",
35
+ "VERIFICATION.md",
36
+ "SPEC.md",
37
+ "SPEC-NOTES.md",
38
+ "taximeter.config.example.json",
39
+ "docs"
40
+ ],
41
+ "scripts": {
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "biome check --error-on-warnings .",
44
+ "format": "biome check --write .",
45
+ "test": "vitest run --coverage",
46
+ "build": "tsup && vite build",
47
+ "check:package": "node scripts/check-package.mjs",
48
+ "smoke:package": "node scripts/smoke-package.mjs",
49
+ "changeset": "changeset",
50
+ "version-packages": "changeset version",
51
+ "release": "npm run build && changeset publish"
52
+ },
53
+ "dependencies": {
54
+ "better-sqlite3": "12.8.0",
55
+ "commander": "13.1.0",
56
+ "uuid": "11.1.1",
57
+ "zod": "4.5.4"
58
+ },
59
+ "overrides": {
60
+ "tsup": {
61
+ "esbuild": "0.28.1"
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@biomejs/biome": "2.5.12",
66
+ "@changesets/cli": "2.29.8",
67
+ "@types/better-sqlite3": "9.6.0",
68
+ "@types/node": "22.20.1",
69
+ "@types/react": "19.2.18",
70
+ "@types/react-dom": "19.2.7",
71
+ "@vitest/coverage-v8": "3.2.7",
72
+ "react": "19.2.8",
73
+ "react-dom": "19.2.8",
74
+ "tsup": "8.5.1",
75
+ "typescript": "5.9.3",
76
+ "vite": "6.4.3",
77
+ "vitest": "3.2.7"
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "budgets": {
3
+ "perTask": { "amount": "5000000", "asset": "USDC" },
4
+ "perAgent": { "amount": "50000000", "asset": "USDC", "window": "24h" },
5
+ "global": { "amount": "100000000", "asset": "USDC", "window": "24h" }
6
+ },
7
+ "policy": {
8
+ "allowHosts": [],
9
+ "denyHosts": [],
10
+ "allowPayTo": [],
11
+ "maxSinglePayment": "1000000",
12
+ "maxSingleAsset": "USDC"
13
+ },
14
+ "ports": { "proxy": 8402, "dashboard": 8403 },
15
+ "db": "~/.taximeter/ledger.db"
16
+ }