@hydra-acp/budgeter 0.1.5 → 0.1.7
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/README.md +114 -3
- package/dist/cost/aggregate.js +1 -0
- package/dist/cost/daemon-client.js +2 -0
- package/dist/cost/format.js +16 -0
- package/dist/cost/history-stream.js +1 -0
- package/dist/cost/session-store.js +1 -0
- package/dist/index.js +30 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@ Runs as a daemon-managed *transformer* (not a client extension): it connects onc
|
|
|
9
9
|
From npm (recommended once published):
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install -g @hydra-acp/budgeter
|
|
12
|
+
npm install -g @hydra-acp/cli @hydra-acp/budgeter
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
This drops a `hydra-acp-budgeter` binary on your PATH.
|
|
15
|
+
This drops the `hydra-acp` (and `hydra`) CLI plus a `hydra-acp-budgeter` binary on your PATH. The CLI dispatches `hydra-acp <name>` to any `hydra-acp-<name>` binary on PATH, so the budgeter is also reachable as `hydra-acp budgeter`.
|
|
16
16
|
|
|
17
17
|
Or from source:
|
|
18
18
|
|
|
@@ -101,7 +101,7 @@ Spend is sticky across `session.closed`: a closed session's cost stays in the to
|
|
|
101
101
|
To zero the budget:
|
|
102
102
|
|
|
103
103
|
```sh
|
|
104
|
-
hydra-acp
|
|
104
|
+
hydra-acp budgeter reset
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
That deletes the state file. If the transformer is running, its watcher adopts the deletion and the in-memory total drops to zero on the next tick (≤50ms). If it isn't running, the file is just gone and the next start begins at zero.
|
|
@@ -142,3 +142,114 @@ The file is optional — all keys have defaults and the transformer works withou
|
|
|
142
142
|
- All cost state is in-memory; restart the transformer to reset.
|
|
143
143
|
|
|
144
144
|
For a working example of the transformer protocol the budgeter speaks, see [`hydra-acp/cli/examples/transformer-observe.mjs`](https://github.com/smagnuso/hydra-acp/blob/main/cli/examples/transformer-observe.mjs).
|
|
145
|
+
|
|
146
|
+
## Reporting historical cost
|
|
147
|
+
|
|
148
|
+
The `cost` subcommand reads session metadata from `~/.hydra-acp/sessions/<id>/meta.json` and optionally streams `history.jsonl` for time-bucketed or token-level queries. It is a pure reader — no new files are written.
|
|
149
|
+
|
|
150
|
+
### Quick start
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
# All-time total cost across all sessions
|
|
154
|
+
hydra budgeter usage
|
|
155
|
+
|
|
156
|
+
# JSON output (machine-readable)
|
|
157
|
+
hydra budgeter usage --json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Time-based queries
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
# Last 7 days
|
|
164
|
+
hydra budgeter usage --since 7d
|
|
165
|
+
|
|
166
|
+
# Last 30 days, grouped by day buckets
|
|
167
|
+
hydra budgeter usage --since 30d --bucket day
|
|
168
|
+
|
|
169
|
+
# Last 6 months, grouped by week
|
|
170
|
+
hydra budgeter usage --since 180d --bucket week
|
|
171
|
+
|
|
172
|
+
# Calendar-month buckets over the last 2 years
|
|
173
|
+
hydra budgeter usage --bucket month
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Grouping
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
# By directory (depth-1 below $HOME)
|
|
180
|
+
hydra budgeter usage --by dir
|
|
181
|
+
|
|
182
|
+
# By session ID
|
|
183
|
+
hydra budgeter usage --by session
|
|
184
|
+
|
|
185
|
+
# By model
|
|
186
|
+
hydra budgeter usage --by model
|
|
187
|
+
|
|
188
|
+
# By agent
|
|
189
|
+
hydra budgeter usage --by agent
|
|
190
|
+
|
|
191
|
+
# Directory with custom depth
|
|
192
|
+
hydra budgeter usage --by dir --depth 2
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Filtering
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
# Only interactive sessions
|
|
199
|
+
hydra budgeter usage --interactive
|
|
200
|
+
|
|
201
|
+
# Only non-interactive (background) sessions
|
|
202
|
+
hydra budgeter usage --no-interactive
|
|
203
|
+
|
|
204
|
+
# Only sessions under a specific directory prefix
|
|
205
|
+
hydra budgeter usage --dir ~/dev/hydra-acp
|
|
206
|
+
|
|
207
|
+
# Combine filters
|
|
208
|
+
hydra budgeter usage --since 7d --dir ~/dev/hydra-acp --by session
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Token-level queries
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
# Total tokens across all sessions
|
|
215
|
+
hydra budgeter usage --metric tokens
|
|
216
|
+
|
|
217
|
+
# Tokens with histogram
|
|
218
|
+
hydra budgeter usage --metric tokens --histogram
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Output formats
|
|
222
|
+
|
|
223
|
+
**Text (default):**
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
Total: $12.34 across 5 session(s)
|
|
227
|
+
──────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
Label Cost Tokens
|
|
229
|
+
myapp $7.50 142k
|
|
230
|
+
other $4.84 89k
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**JSON (`--json`):**
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"kind": "grouped",
|
|
238
|
+
"currency": "USD",
|
|
239
|
+
"groups": [
|
|
240
|
+
{
|
|
241
|
+
"label": "myapp",
|
|
242
|
+
"items": [{ "label": "myapp", "costAmount": 7.50, "deltaCost": 2.30 }]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"label": "other",
|
|
246
|
+
"items": [{ "label": "other", "costAmount": 4.84, "deltaCost": 1.10 }]
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Fast path vs slow path
|
|
253
|
+
|
|
254
|
+
- **Fast path** — `--by dir/session/model/agent` without `--since`, `--dir`, or `--interactive`: reads only `meta.json` across all sessions, returns instantly even with ~1000 sessions.
|
|
255
|
+
- **Slow path** — any query with `--since`, `--bucket`, `--metric tokens`, `--dir`, or `--interactive`: also streams `history.jsonl` for delta-cost and token computation, pre-filtering sessions by `updatedAt`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{relative as G,resolve as w}from"node:path";import{homedir as W}from"node:os";import{realpathSync as E}from"node:fs";function P(c){const d=c.match(/^(\d+)\s*([dhmwy])$/i);if(d!==null){const a=d[1],h=d[2];if(a===void 0||h===void 0)throw new Error(`invalid since spec: ${c}`);const k=parseInt(a,10),r=h.toLowerCase(),u=new Date;return r==="d"?u.setDate(u.getDate()-k):r==="h"?u.setHours(u.getHours()-k):r==="w"?u.setDate(u.getDate()-k*7):r==="m"?u.setMonth(u.getMonth()-k):r==="y"&&u.setFullYear(u.getFullYear()-k),u}const n=new Date(c);if(Number.isNaN(n.getTime()))throw new Error(`invalid since spec: ${c}`);return n}const v=new Map;function C(c){if(v.has(c))return v.get(c);try{const d=E(c);return v.set(c,d),d}catch{return v.set(c,c),c}}function F(c,d){let n=c;if(d.since!==void 0){const r=d.since.getTime(),u=[];for(const f of n)new Date(f.updatedAt).getTime()<r||u.push(f);n=u}if(d.dir!==void 0){const r=C(w(d.dir)),u=[];for(const f of n)f.cwd===void 0||f.cwd===""||(f.cwd.startsWith(r+"/")||f.cwd===r)&&u.push(f);n=u}if(d.interactive!==void 0){const r=d.interactive,u=[];for(const f of n)f.interactive===r&&u.push(f);n=u}const a=d.min??0,h=d.minMetric==="tokens",k=[];for(const r of n)(h?r.contextTokens:r.costAmount)>a&&k.push(r);return k}function O(c){return c.dir!==void 0?w(c.dir):w(W())}function L(c,d,n){if(c===void 0||c==="")return"<unknown>";const a=C(w(c));if(d===void 0){const p=C(w(W()));return a===p?"~":a.startsWith(p+"/")?"~/"+a.slice(p.length+1):a}const h=C(w(n));let k=G(h,a);if(k===""||k===".")return"<root>";if(k.startsWith(".."))return"<unknown>";const r=k.split("/");if(r.length===0)return"<root>";const u=Math.min(d,r.length),f=r.slice(0,u);return f.length===0?"<root>":f.join("/")}function Y(c,d,n={}){let a=n.since;if(a===void 0&&n.bucket!==void 0){const e=new Date;n.bucket==="hour"?(e.setHours(e.getHours()-24),a=e):n.bucket==="day"?(e.setDate(e.getDate()-30),a=e):n.bucket==="week"?(e.setMonth(e.getMonth()-6),a=e):n.bucket==="month"&&(e.setFullYear(e.getFullYear()-2),a=e)}if(!(a!==void 0||n.bucket!==void 0||n.tokens===!0||n.by!==void 0||n.dir!==void 0||n.interactive!==void 0)){let e=0;for(const i of c)e+=i.costAmount;let s="";for(const i of c){s=i.costCurrency;break}return{kind:"total",row:{label:"All sessions",costAmount:e,sessionCount:c.length},currency:s}}const k={since:a,dir:n.dir,interactive:n.interactive,min:n.min,minMetric:n.tokens===!0?"tokens":"cost"},r=F(c,k),u=new Map;if(d!==void 0)for(const e of d){const s=u.get(e.sessionId);s===void 0?u.set(e.sessionId,[e]):s.push(e)}let f="";if(d!==void 0&&d.length>0)for(const e of d){f=e.currency;break}else if(r.length>0)for(const e of r){f=e.costCurrency;break}const p=e=>n.by==="dir"?L(e.cwd,n.depth,O(n)):n.by==="session"?e.sessionId.startsWith("hydra_session_")?e.sessionId.slice(14):e.sessionId:n.by==="model"?e.model===""?"<unknown>":e.model:n.by==="agent"?e.agentId===""?"<unknown>":e.agentId:"<all>",R=e=>{const s=new Date(e);if(Number.isNaN(s.getTime()))return"<invalid>";if(n.bucket==="hour"){const i=s.toLocaleDateString(void 0,{month:"2-digit",day:"2-digit"}),o=String(s.getHours()).padStart(2,"0");return`${i} ${o}:00`}if(n.bucket==="day")return s.toLocaleDateString(void 0,{year:"numeric",month:"2-digit",day:"2-digit"});if(n.bucket==="week"){const i=new Date(s),o=i.getDay(),t=i.getDate()-o+(o===0?-6:1);return i.setDate(t),i.toLocaleDateString(void 0,{year:"numeric",month:"2-digit",day:"2-digit"})}return n.bucket==="month"?s.toLocaleDateString(void 0,{year:"numeric",month:"2-digit"}):s.toLocaleDateString(void 0,{year:"numeric",month:"2-digit",day:"2-digit"})};if(n.by===void 0&&n.bucket===void 0){let e=0,s=0,i=0,o=0,t=0,l=0;for(const b of r){e+=b.costAmount;const T=u.get(b.sessionId);if(T!==void 0)for(const m of T)s+=m.deltaCost,n.tokens===!0&&(m.inputTokens!==void 0&&(i+=m.inputTokens),m.outputTokens!==void 0&&(o+=m.outputTokens),m.cacheReadTokens!==void 0&&(t+=m.cacheReadTokens),m.cacheWriteTokens!==void 0&&(l+=m.cacheWriteTokens))}const g={label:"All sessions",costAmount:e,deltaCost:s,sessionCount:r.length};return n.tokens===!0&&(g.inputTokens=i,g.outputTokens=o,g.cacheReadTokens=t,g.cacheWriteTokens=l),{kind:"total",row:g,currency:f}}if(n.by!==void 0&&n.bucket===void 0){const e=new Map;for(const i of r){const o=p(i);let t=e.get(o);t===void 0&&(t={label:o,rows:{label:o,costAmount:0,deltaCost:0,sessionCount:0}},e.set(o,t)),t.rows.costAmount+=i.costAmount,t.rows.sessionCount=(t.rows.sessionCount??0)+1,n.tokens===!0&&(t.rows.inputTokens===void 0&&(t.rows.inputTokens=0),t.rows.inputTokens+=i.contextTokens);const l=u.get(i.sessionId);if(l!==void 0)for(const g of l)t.rows.deltaCost+=g.deltaCost,n.tokens===!0&&(t.rows.inputTokens===void 0&&(t.rows.inputTokens=0),t.rows.outputTokens===void 0&&(t.rows.outputTokens=0),t.rows.cacheReadTokens===void 0&&(t.rows.cacheReadTokens=0),t.rows.cacheWriteTokens===void 0&&(t.rows.cacheWriteTokens=0),g.inputTokens!==void 0&&(t.rows.inputTokens+=g.inputTokens),g.outputTokens!==void 0&&(t.rows.outputTokens+=g.outputTokens),g.cacheReadTokens!==void 0&&(t.rows.cacheReadTokens+=g.cacheReadTokens),g.cacheWriteTokens!==void 0&&(t.rows.cacheWriteTokens+=g.cacheWriteTokens))}const s=[];for(const i of e.values())s.push({label:i.label,items:[i.rows]});return{kind:"grouped",groups:s,currency:f}}const _=(e,s)=>{n.tokens===!0&&(e.inputTokens===void 0&&(e.inputTokens=0),e.inputTokens+=s.contextTokens)},M=new Map;for(const e of r)M.set(e.sessionId,e);const y=new Map;if(d!==void 0){for(const e of d){if(!M.has(e.sessionId))continue;const s=y.get(e.sessionId);s===void 0?y.set(e.sessionId,[e]):s.push(e)}for(const e of y.values())e.sort((s,i)=>s.ts.localeCompare(i.ts))}const D=(e,s)=>{e._sessions===void 0&&(e._sessions=new Set),e._sessions.add(s),e.sessionCount=e._sessions.size},B=e=>{delete e._sessions},I=(e,s,i)=>{const o=R(s.ts);let t=e.get(o);t===void 0&&(t={bucket:o,costAmount:0,deltaCost:0,sessionCount:0},e.set(o,t)),t.costAmount+=i,t.deltaCost+=i,D(t,s.sessionId),n.tokens===!0&&(t.inputTokens===void 0||(s.inputTokens??0)>t.inputTokens)&&(t.inputTokens=s.inputTokens??t.inputTokens??0)},x=(e,s)=>{const i=R(s.updatedAt);let o=e.get(i);o===void 0&&(o={bucket:i,costAmount:0,deltaCost:0,sessionCount:0},e.set(i,o)),o.costAmount+=s.costAmount,o.deltaCost+=s.costAmount,D(o,s.sessionId),_(o,s)};if(n.by!==void 0&&n.bucket!==void 0){const e=new Map,s=o=>{const t=p(o);let l=e.get(t);return l===void 0&&(l={label:t,buckets:new Map},e.set(t,l)),l.buckets};for(const o of r){const t=s(o),l=y.get(o.sessionId);if(l!==void 0&&l.length>0){let g=l[0].cumulativeCost;for(let b=1;b<l.length;b++){const T=l[b],m=Math.max(0,T.cumulativeCost-g);g=T.cumulativeCost,!(a!==void 0&&new Date(T.ts)<a)&&I(t,T,m)}}else x(t,o)}const i=[];for(const o of e.values()){const t=Array.from(o.buckets.values());for(const l of t)B(l);t.sort((l,g)=>l.bucket.localeCompare(g.bucket)),t.length>0&&i.push({label:o.label,items:t})}return{kind:"timeSeriesGrouped",groups:i,currency:f}}const S=new Map;for(const e of r){const s=y.get(e.sessionId);if(s!==void 0&&s.length>0){let i=s[0].cumulativeCost;for(let o=1;o<s.length;o++){const t=s[o],l=Math.max(0,t.cumulativeCost-i);i=t.cumulativeCost,!(a!==void 0&&new Date(t.ts)<a)&&I(S,t,l)}}else x(S,e)}const A=Array.from(S.values());for(const e of A)B(e);return A.sort((e,s)=>e.bucket.localeCompare(s.bucket)),{kind:"timeSeries",timeSeries:A,currency:f}}export{Y as aggregate,F as applyFilters,P as parseSince};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{readFileSync as m}from"node:fs";import{homedir as y}from"node:os";import{resolve as g}from"node:path";import{logger as E}from"../util/log.js";const i=E("cost/daemon-client");function b(){if(process.env.HYDRA_ACP_TOKEN)return process.env.HYDRA_ACP_TOKEN;const e=g(process.env.HYDRA_ACP_HOME??g(y(),".hydra-acp"),"auth-token");try{const t=m(e,"utf8").trim();if(t.length>0)return t}catch(t){const n=t;n.code!=="ENOENT"&&i.debug(`auth-token read failed: ${n.message}`)}}function R(){if(process.env.HYDRA_ACP_DAEMON_URL)return process.env.HYDRA_ACP_DAEMON_URL;const e=g(process.env.HYDRA_ACP_HOME??g(y(),".hydra-acp"),"daemon.pid");try{const t=m(e,"utf8"),n=JSON.parse(t);if(typeof n.host=="string"&&typeof n.port=="number")return`http://${n.host}:${n.port}`}catch(t){const n=t;n.code!=="ENOENT"&&i.debug(`daemon.pid read failed: ${n.message}`)}return"http://127.0.0.1:8765"}function l(){const e=b();if(e!==void 0)return{daemonUrl:R(),token:e}}function C(e){const t=typeof e.sessionId=="string"?e.sessionId:void 0;if(t===void 0)return;const n=typeof e.updatedAt=="string"?e.updatedAt:"",s=(e._meta??{})["hydra-acp"]??{},d=typeof e.cwd=="string"?e.cwd:typeof s.cwd=="string"?s.cwd:void 0,o=typeof e.agentId=="string"?e.agentId:typeof s.agentId=="string"?s.agentId:"",u=typeof e.currentModel=="string"?e.currentModel:typeof s.currentModel=="string"?s.currentModel:"",f=typeof e.title=="string"?e.title:"",c=e.interactive===!0||s.interactive===!0,p=e.currentUsage,h=s.currentUsage,a=p??h,k=typeof a?.costAmount=="number"?a.costAmount:0,A=typeof a?.costCurrency=="string"?a.costCurrency:"",v=typeof a?.used=="number"?a.used:0;return{sessionId:t,cwd:d,agentId:o,model:u,interactive:c,costAmount:k,costCurrency:A,contextTokens:v,title:f,createdAt:"",updatedAt:n}}async function T(){const e=l();if(e===void 0){i.debug("no daemon token resolved; skipping daemon fetch");return}const t=`${e.daemonUrl.replace(/\/$/,"")}/v1/sessions?includeNonInteractive=1`;let n;try{n=await fetch(t,{headers:{Authorization:`Bearer ${e.token}`}})}catch(o){i.debug(`daemon fetch failed (${t}): ${o.message}`);return}if(!n.ok){i.debug(`daemon returned HTTP ${n.status} for ${t}`);return}let r;try{r=await n.json()}catch(o){i.debug(`daemon response not JSON: ${o.message}`);return}const s=r;if(!Array.isArray(s.sessions)){i.debug("daemon response missing sessions[]");return}const d=[];for(const o of s.sessions)if(o&&typeof o=="object"&&!Array.isArray(o)){const u=C(o);u!==void 0&&d.push(u)}return d}function $(e){const t=typeof e.sessionId=="string"?e.sessionId:void 0,n=typeof e.ts=="string"?e.ts:void 0;if(t===void 0||n===void 0)return;const r=e.update??void 0,s=typeof r?.cost?.amount=="number"?r.cost.amount:0,d=typeof r?.cost?.currency=="string"?r.cost.currency:"USD",o=typeof r?.used=="number"?r.used:0;return{sessionId:t,ts:n,costCumulative:s,costCurrency:d,contextTokens:o}}async function I(e){const t=l();if(t===void 0)return;const n=new URLSearchParams({kinds:"usage_update"});e!==void 0&&n.set("since",e.toISOString());const r=`${t.daemonUrl.replace(/\/$/,"")}/v1/sessions/events?${n.toString()}`;let s;try{s=await fetch(r,{headers:{Authorization:`Bearer ${t.token}`}})}catch(u){i.debug(`events fetch failed: ${u.message}`);return}if(!s.ok){i.debug(`events endpoint HTTP ${s.status}`);return}const d=await s.text(),o=[];for(const u of d.split(`
|
|
2
|
+
`)){const f=u.trim();if(f.length===0)continue;let c;try{c=JSON.parse(f)}catch{continue}if(c&&typeof c=="object"&&!Array.isArray(c)){const p=$(c);p!==void 0&&o.push(p)}}return o}export{I as fetchUsageEventsFromDaemon,T as listSessionsFromDaemon};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function h(e){return"label"in e?e.label:e.bucket}function R(e){let t=0,u=!1;for(const c of e){if(c===void 0)continue;const o=String(c);o.length>t&&(t=o.length),c!==1&&(u=!0)}const s=u?8:7,i=e.map(c=>{if(c===void 0)return"";const o=String(c).padStart(t),n=(c===1?"session":"sessions").padEnd(s);return`${o} ${n}`}),r=t+1+s;return{strs:i,width:r}}function g(e){if(e<1e3)return String(e);let t,u;return e<1e6?(t=e/1e3,u="k"):e<1e9?(t=e/1e6,u="M"):(t=e/1e9,u="B"),(t%1===0?t.toFixed(0):t.toFixed(2)).replace(/\.?0+$/,"")+u}function _(e,t){let u=0;for(const r of e)r>u&&(u=r);if(u<=0||t<=0)return e.map(()=>"");const s=Math.max(1,t),i=[];for(const r of e){const c=r/u,o=Math.round(c*s),n=s-o;i.push("\u2588".repeat(o)+"\u2591".repeat(n))}return i}function v(e,t={}){const u=process.stdout.columns??80,s=t.histogram===!0,i=t.tokens===!0;let r="";const c=o=>`${o} session${o===1?"":"s"}`;if(e.kind==="total"){const o=m(e.row.costAmount),n=e.row.sessionCount,l=n!==void 0?c(n):e.row.label??"all sessions";r+=`Total: ${o} across ${l}
|
|
2
|
+
`}else if(e.kind==="grouped"){const o=x(e.groups),n=m(o);let l=0;for(const a of e.groups)for(const d of a.items)l+=d.sessionCount??0;r+=`Total: ${n} across ${c(l)}
|
|
3
|
+
`}else if(e.kind==="timeSeries"){const o=e.timeSeries.reduce((a,d)=>a+d.costAmount,0),n=m(o),l=e.timeSeries.reduce((a,d)=>a+(d.sessionCount??0),0);r+=`Total: ${n} across ${c(l)}
|
|
4
|
+
`}else if(e.kind==="timeSeriesGrouped"){const o=x(e.groups),n=m(o);let l=0;for(const a of e.groups)for(const d of a.items)l+=d.sessionCount??0;r+=`Total: ${n} across ${c(l)}
|
|
5
|
+
`}if(e.kind==="grouped")if(e.groups.every(n=>n.items.length===1)){const n=[];for(const l of e.groups){const a=l.items[0];a!==void 0&&n.push({...a,label:l.label})}n.sort((l,a)=>{const d=i?p(l):l.costAmount;return(i?p(a):a.costAmount)-d}),s?r+=T(n,u,i):r+=w(n,i)}else for(const n of e.groups){r+=`
|
|
6
|
+
${n.label}:
|
|
7
|
+
`;const l=n.items,a=l.some(d=>d.inputTokens!==void 0||d.outputTokens!==void 0);s?r+=T(l,u,i):r+=S(l,a,i)}else if(e.kind==="timeSeries"){const o=e.timeSeries.some(n=>n.inputTokens!==void 0||n.outputTokens!==void 0);s?r+=T(e.timeSeries,u,i):r+=S(e.timeSeries,o,i)}else if(e.kind==="timeSeriesGrouped")for(const o of e.groups){r+=`
|
|
8
|
+
${o.label}:
|
|
9
|
+
`;const n=o.items.some(l=>l.inputTokens!==void 0||l.outputTokens!==void 0);s?r+=T(o.items,u,i):r+=S(o.items,n,i)}return r}function w(e,t){if(e.length===0)return` (no data)
|
|
10
|
+
`;let u=0;for(const n of e)n.label.length>u&&(u=n.label.length);const s=e.map(n=>t?g(p(n)):m(n.costAmount));let i=0;for(const n of s)n.length>i&&(i=n.length);const{strs:r,width:c}=R(e.map(n=>n.sessionCount)),o=[];for(let n=0;n<e.length;n++){const l=e[n];if(l===void 0)continue;let a=` ${l.label.padEnd(u)} ${s[n].padStart(i)}`;c>0&&(a+=` ${r[n]}`),o.push(a+`
|
|
11
|
+
`)}return o.join("")}function T(e,t,u){if(e.length===0)return` (no data)
|
|
12
|
+
`;let s=0;for(const f of e){const k=h(f);k.length>s&&(s=k.length)}const i=e.map(f=>u?p(f):f.costAmount),r=i.map(f=>u?g(f):m(f));let c=0;for(const f of r)f.length>c&&(c=f.length);const{strs:o,width:n}=R(e.map(f=>f.sessionCount)),l=n>0?n+2:0;let a=t-s-c-l-8;a<1&&(a=e.length>1?1:0);const d=_(i,a),b=[];for(let f=0;f<e.length;f++){const k=e[f];if(k===void 0)continue;const L=h(k).padEnd(s),W=r[f].padStart(c);let C=` ${L} ${W} ${d[f]}`;n>0&&(C+=` ${o[f]}`),b.push(C+`
|
|
13
|
+
`)}return b.join("")}function S(e,t,u){if(e.length===0)return` (no data)
|
|
14
|
+
`;let s=28;for(const r of e){const c=h(r);c.length>s&&(s=c.length)}let i=" ";i+=B("Label",s),i+=" ",u?i+=" Tokens":(i+=" Cost",t&&(i+=" Tokens")),i+=`
|
|
15
|
+
`;for(const r of e){const c=h(r).padEnd(s);let o=" ";if(o+=c,o+=" ",u){const n=p(r);o+=g(n).padStart(12)}else{const n=r.costAmount??0;if(o+=m(n).padStart(10),t){const l=p(r);o+=" ".padStart(2),o+=g(l).padStart(12)}}i+=o+`
|
|
16
|
+
`}return i}function G(e){const t={kind:e.kind,currency:e.currency};if(e.kind==="total")t.row=A(e.row);else if(e.kind==="grouped"){const u=e.groups.slice().sort((s,i)=>s.label.localeCompare(i.label));t.groups=u.map(s=>({label:s.label,items:s.items.map(A)}))}else if(e.kind==="timeSeries")t.timeSeries=e.timeSeries.map($);else if(e.kind==="timeSeriesGrouped"){const u=e.groups.slice().sort((s,i)=>s.label.localeCompare(i.label));t.groups=u.map(s=>({label:s.label,items:s.items.map($)}))}return JSON.stringify(t,null,2)}function A(e){const t={label:e.label,costAmount:e.costAmount};return e.deltaCost!==void 0&&(t.deltaCost=e.deltaCost),e.inputTokens!==void 0&&(t.inputTokens=e.inputTokens),e.outputTokens!==void 0&&(t.outputTokens=e.outputTokens),e.cacheReadTokens!==void 0&&(t.cacheReadTokens=e.cacheReadTokens),e.cacheWriteTokens!==void 0&&(t.cacheWriteTokens=e.cacheWriteTokens),t}function $(e){const t={bucket:e.bucket,costAmount:e.costAmount,deltaCost:e.deltaCost};return e.inputTokens!==void 0&&(t.inputTokens=e.inputTokens),e.outputTokens!==void 0&&(t.outputTokens=e.outputTokens),e.cacheReadTokens!==void 0&&(t.cacheReadTokens=e.cacheReadTokens),e.cacheWriteTokens!==void 0&&(t.cacheWriteTokens=e.cacheWriteTokens),t}function m(e){return`$${e.toFixed(2)}`}function p(e){let t=0;return e.inputTokens!==void 0&&(t+=e.inputTokens),e.outputTokens!==void 0&&(t+=e.outputTokens),t}function B(e,t){return e.length>=t?e:e+" ".repeat(t-e.length)}function x(e){let t=0;for(const u of e)for(const s of u.items)t+=s.costAmount;return t}export{G as renderJson,v as renderText};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createInterface as j}from"node:readline";import{createReadStream as E,statSync as N}from"node:fs";import{resolve as x}from"node:path";import{logger as O}from"../util/log.js";import{sessionsDir as $}from"./session-store.js";const b=O("cost/history-stream");function D(t){if(!t||typeof t!="object"||Array.isArray(t))return;const n=t["hydra-acp"];if(!n||typeof n!="object"||Array.isArray(n))return;const e=n.cumulativeCost;return typeof e=="number"?e:void 0}function J(t){const n=D(t._meta),e=t.cost??void 0,s=n!==void 0?n:typeof e?.amount=="number"?e.amount:void 0;if(s===void 0)return;const u=typeof e?.currency=="string"?e.currency:void 0;return{amount:s,currency:u}}function M(t){const n=t.recordedAt;return typeof n=="number"?new Date(n).toISOString():typeof n=="string"?n:""}async function*L(t){const n=Array.isArray(t)?t:[t];for(const e of n){const s=x($(),e.sessionId,"history.jsonl");try{N(s)}catch(c){const o=c;if(o.code==="ENOENT"){b.debug(`no history for ${e.sessionId}`);continue}b.debug(`stat failed for ${s}: ${o.message}`);continue}const u=E(s,{encoding:"utf8"}),k=j({input:u,crlfDelay:1/0}),h=new Map;try{for await(const c of k){if(c.length===0)continue;let o;try{o=JSON.parse(c)}catch{b.debug(`malformed JSON in history.jsonl for ${e.sessionId}`);continue}if(!o||typeof o!="object"||Array.isArray(o))continue;const a=o;if(a.method!=="session/update")continue;const d=a.params??void 0;if(!d||typeof d!="object"||Array.isArray(d))continue;const i=d.update??void 0;if(!i||typeof i!="object"||Array.isArray(i)||i.sessionUpdate!=="usage_update")continue;const I=M(a),f=J(i);if(f===void 0)continue;const T=f.currency??"",m=f.amount,C=h.get(e.sessionId)??0,S=Math.max(0,m-C);h.set(e.sessionId,m);const r=i.usage??void 0;let y,p,l,g;if(r&&typeof r=="object"&&!Array.isArray(r)){const w=r.inputTokens;typeof w=="number"&&(y=w);const A=r.outputTokens;typeof A=="number"&&(p=A);const R=r.cacheReadInputTokens;typeof R=="number"&&(l=R);const v=r.cacheCreationInputTokens;typeof v=="number"&&(g=v)}yield{sessionId:e.sessionId,ts:I,deltaCost:S,cumulativeCost:m,currency:T,...y!==void 0&&{inputTokens:y},...p!==void 0&&{outputTokens:p},...l!==void 0&&{cacheReadTokens:l},...g!==void 0&&{cacheWriteTokens:g}}}}finally{k.close(),u.destroy()}}}export{L as streamHistoryEvents};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readdirSync as C,readFileSync as I,realpathSync as R,statSync as h}from"node:fs";import{isAbsolute as j,resolve as a}from"node:path";import{homedir as x}from"node:os";import{logger as E}from"../util/log.js";const d=E("cost/session-store"),f=new Map;function $(n){if(f.has(n))return f.get(n);try{const s=R(n);return f.set(n,s),s}catch{f.set(n,void 0);return}}function N(){const n=process.env.HYDRA_ACP_HOME??a(x(),".hydra-acp");return a(n,"sessions")}function J(n){const s=a(n,"meta.json");let i;try{i=I(s,"utf8")}catch(g){const p=g;if(p.code==="ENOENT")return;d.debug(`skipping ${n}: read meta.json failed: ${p.message}`);return}let t;try{t=JSON.parse(i)}catch{d.debug(`skipping ${n}: meta.json is not valid JSON`);return}if(!t||typeof t!="object"||Array.isArray(t)){d.debug(`skipping ${n}: meta.json is not an object`);return}const e=t,c=typeof e.sessionId=="string"?e.sessionId:void 0;if(c===void 0){d.debug(`skipping ${n}: missing sessionId`);return}let r;const u=typeof e.cwd=="string"?e.cwd:void 0;u!==void 0&&(j(u)?r=$(u):r=u);const y=typeof e.agentId=="string"?e.agentId:"",m=typeof e.currentModel=="string"?e.currentModel:"",l=e.interactive===!0,o=e.currentUsage??void 0,A=typeof o?.costAmount=="number"?o.costAmount:0,w=typeof o?.costCurrency=="string"?o.costCurrency:"",b=typeof o?.used=="number"?o.used:0,S=typeof e.title=="string"?e.title:"",k=typeof e.createdAt=="string"?e.createdAt:"",v=typeof e.updatedAt=="string"?e.updatedAt:"";return{sessionId:c,cwd:r,agentId:y,model:m,interactive:l,costAmount:A,costCurrency:w,contextTokens:b,title:S,createdAt:k,updatedAt:v}}function U(){const n=N();let s;try{s=C(n)}catch(t){const e=t;return d.debug(`session store not found at ${n}: ${e.message}`),[]}const i=[];for(const t of s){const e=a(n,t);let c;try{c=h(e)}catch{continue}if(!c.isDirectory())continue;const r=J(e);r!==void 0&&i.push(r)}return i}export{U as scanSessions,N as sessionsDir};
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import{readFileSync as
|
|
3
|
-
`)}
|
|
4
|
-
|
|
2
|
+
import{readFileSync as C}from"node:fs";import{dirname as F,resolve as R}from"node:path";import{fileURLToPath as U}from"node:url";import{loadConfig as $}from"./config.js";import{BudgeterBridge as L}from"./bridge.js";import{stateFilePath as v}from"./paths.js";import{DEFAULT_RULE as P}from"./rule.js";import{deleteStateFile as N}from"./tracker.js";import{logger as x,setDebug as G}from"./util/log.js";import{scanSessions as M}from"./cost/session-store.js";import{listSessionsFromDaemon as j,fetchUsageEventsFromDaemon as H}from"./cost/daemon-client.js";import{aggregate as A,applyFilters as _,parseSince as J}from"./cost/aggregate.js";import{renderText as W,renderJson as Y}from"./cost/format.js";const k=x("main");function S(){try{const e=F(U(import.meta.url));return JSON.parse(C(R(e,"../package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}function B(){N(v()),process.stdout.write(`hydra-acp-budgeter accumulated cost reset
|
|
3
|
+
`)}const V=`Usage: hydra budgeter usage [OPTIONS]
|
|
4
|
+
|
|
5
|
+
Options:
|
|
6
|
+
--since <date|duration> Only include sessions updated after this date (e.g. 7d, 2024-01-01)
|
|
7
|
+
--bucket <hour|day|week|month> Group results into time buckets (implies --since 24h/30d/6m/2y)
|
|
8
|
+
--by <dir|session|model|agent> Group by dimension
|
|
9
|
+
--depth <N> Depth for --by dir grouping (default: 1)
|
|
10
|
+
--dir <path> Only include sessions under this directory prefix
|
|
11
|
+
--interactive Only include interactive sessions (default: include both)
|
|
12
|
+
--min <N> Drop sessions whose active-metric value is <= N (default: 0)
|
|
13
|
+
--histogram Show an ASCII histogram bar next to each row (implies --bucket week if no bucket given)
|
|
14
|
+
--metric <cost|tokens> Display metric (default: cost)
|
|
15
|
+
--json Output as JSON
|
|
16
|
+
--help Show this help message`;async function E(e){if(e.includes("--help")){process.stdout.write(V+`
|
|
17
|
+
`);return}let r,s,d,a,c,l=!1,f,u=!1,o,m=!1;for(let t=0;t<e.length;t++){const n=e[t];if(n!==void 0){if(n==="--since")t+=1,r=e[t];else if(n==="--bucket")t+=1,s=e[t];else if(n==="--by")t+=1,d=e[t];else if(n==="--depth")t+=1,a=e[t];else if(n==="--dir")t+=1,c=e[t];else if(n==="--interactive")l=!0;else if(n==="--min")t+=1,f=e[t];else if(n==="--histogram")u=!0;else if(n==="--metric")t+=1,o=e[t];else if(n==="--json")m=!0;else if(n.startsWith("--"))throw new Error(`Unknown option: ${n}
|
|
18
|
+
Run "hydra budgeter usage --help" for usage.`)}}if(o!==void 0&&o!=="cost"&&o!=="tokens")throw new Error(`Invalid --metric value. Must be 'cost' or 'tokens'.
|
|
19
|
+
Run "hydra budgeter usage --help" for usage.`);let p;if(r!==void 0)try{p=J(r)}catch(t){const n=t;throw new Error(`Invalid --since value: ${n.message}
|
|
20
|
+
Run "hydra budgeter usage --help" for usage.`)}e.length===0&&(s="hour",u=!0),u&&s===void 0&&(s="week");let i=p;if(i===void 0&&s!==void 0){const t=new Date;s==="hour"?(t.setHours(t.getHours()-24),i=t):s==="day"?(t.setDate(t.getDate()-30),i=t):s==="week"?(t.setMonth(t.getMonth()-6),i=t):s==="month"&&(t.setFullYear(t.getFullYear()-2),i=t)}const h=l?!0:void 0,g=o==="tokens",y=f!==void 0?parseFloat(f):void 0,T=await j()??M(),D=_(T,{since:i,dir:c,interactive:h,min:y,minMetric:g?"tokens":"cost"});let w;if(s!==void 0){const t=await H();t!==void 0&&(w=t.map(n=>({sessionId:n.sessionId,ts:n.ts,deltaCost:0,cumulativeCost:n.costCumulative,currency:n.costCurrency,inputTokens:n.contextTokens})))}const I=a!==void 0?parseInt(a,10):void 0,b=A(D,w,{by:d,depth:I,bucket:s,since:i,interactive:h,dir:c,tokens:g,min:y});if(m)process.stdout.write(Y(b)+`
|
|
21
|
+
`);else{const t=W(b,{histogram:u,tokens:o==="tokens"});process.stdout.write(t)}}async function K(){const e=$();G(e.debug);const r=v(),s=new L({daemonWsUrl:e.hydraWsUrl,token:e.hydraToken,softLimit:e.softLimit,hardLimit:e.hardLimit,currency:e.currency,rule:P,statePath:r});s.start();const d=a=>{k.info(`${a} received \u2014 shutting down`),s.stop(),setTimeout(()=>process.exit(0),200).unref()};process.on("SIGINT",()=>d("SIGINT")),process.on("SIGTERM",()=>d("SIGTERM")),k.info(`hydra-acp-budgeter up; daemon=${e.hydraDaemonUrl} soft=${e.softLimit} hard=${e.hardLimit} ${e.currency} state=${r}`)}function Z(){process.stdout.write(`hydra-acp-budgeter ${S()}
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
hydra budgeter [usage] <flags> Report historical cost/usage across sessions
|
|
25
|
+
hydra budgeter reset Zero the accumulated-cost baseline
|
|
26
|
+
|
|
27
|
+
Flags:
|
|
28
|
+
-v, --version Print version and exit
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
`)}async function q(){const e=process.argv.slice(2);if(e.includes("--version")||e.includes("-v")){process.stdout.write(`hydra-acp-budgeter ${S()}
|
|
31
|
+
`);return}if((e[0]==="help"||e.includes("--help")||e.includes("-h"))&&e[0]!=="usage"&&e[0]!=="cost"){Z();return}if(e[0]==="reset"){B();return}if(e[0]==="usage"||e[0]==="cost"){await E(e.slice(1));return}if(e[0]==="run"||process.env.HYDRA_ACP_TOKEN){await K();return}await E(e)}q().catch(e=>{process.stderr.write(`hydra-acp-budgeter: ${e.message}
|
|
5
32
|
`),process.exit(1)});
|
package/package.json
CHANGED