@hydra-acp/budgeter 0.1.4 → 0.1.5

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/dist/index.js CHANGED
@@ -1,65 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { loadConfig } from "./config.js";
6
- import { BudgeterBridge } from "./bridge.js";
7
- import { stateFilePath } from "./paths.js";
8
- import { DEFAULT_RULE } from "./rule.js";
9
- import { deleteStateFile } from "./tracker.js";
10
- import { logger, setDebug } from "./util/log.js";
11
- const log = logger("main");
12
- function readVersion() {
13
- try {
14
- const here = dirname(fileURLToPath(import.meta.url));
15
- const pkg = JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"));
16
- return pkg.version ?? "unknown";
17
- }
18
- catch {
19
- return "unknown";
20
- }
21
- }
22
- function runReset() {
23
- deleteStateFile(stateFilePath());
24
- process.stdout.write("hydra-acp-budgeter accumulated cost reset\n");
25
- }
26
- async function runTransformer() {
27
- const config = loadConfig();
28
- setDebug(config.debug);
29
- const statePath = stateFilePath();
30
- const bridge = new BudgeterBridge({
31
- daemonWsUrl: config.hydraWsUrl,
32
- token: config.hydraToken,
33
- softLimit: config.softLimit,
34
- hardLimit: config.hardLimit,
35
- currency: config.currency,
36
- rule: DEFAULT_RULE,
37
- statePath,
38
- });
39
- bridge.start();
40
- const shutdown = (sig) => {
41
- log.info(`${sig} received — shutting down`);
42
- bridge.stop();
43
- setTimeout(() => process.exit(0), 200).unref();
44
- };
45
- process.on("SIGINT", () => shutdown("SIGINT"));
46
- process.on("SIGTERM", () => shutdown("SIGTERM"));
47
- log.info(`hydra-acp-budgeter up; daemon=${config.hydraDaemonUrl} soft=${config.softLimit} hard=${config.hardLimit} ${config.currency} state=${statePath}`);
48
- }
49
- async function main() {
50
- const argv = process.argv.slice(2);
51
- if (argv.includes("--version") || argv.includes("-v")) {
52
- process.stdout.write(`hydra-acp-budgeter ${readVersion()}\n`);
53
- return;
54
- }
55
- if (argv[0] === "reset") {
56
- runReset();
57
- return;
58
- }
59
- await runTransformer();
60
- }
61
- main().catch((err) => {
62
- process.stderr.write(`hydra-acp-budgeter: ${err.message}\n`);
63
- process.exit(1);
64
- });
65
- //# sourceMappingURL=index.js.map
2
+ import{readFileSync as a}from"node:fs";import{dirname as c,resolve as d}from"node:path";import{fileURLToPath as m}from"node:url";import{loadConfig as u}from"./config.js";import{BudgeterBridge as p}from"./bridge.js";import{stateFilePath as n}from"./paths.js";import{DEFAULT_RULE as f}from"./rule.js";import{deleteStateFile as g}from"./tracker.js";import{logger as h,setDebug as l}from"./util/log.js";const s=h("main");function y(){try{const r=c(m(import.meta.url));return JSON.parse(a(d(r,"../package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}function v(){g(n()),process.stdout.write(`hydra-acp-budgeter accumulated cost reset
3
+ `)}async function w(){const r=u();l(r.debug);const e=n(),t=new p({daemonWsUrl:r.hydraWsUrl,token:r.hydraToken,softLimit:r.softLimit,hardLimit:r.hardLimit,currency:r.currency,rule:f,statePath:e});t.start();const o=i=>{s.info(`${i} received \u2014 shutting down`),t.stop(),setTimeout(()=>process.exit(0),200).unref()};process.on("SIGINT",()=>o("SIGINT")),process.on("SIGTERM",()=>o("SIGTERM")),s.info(`hydra-acp-budgeter up; daemon=${r.hydraDaemonUrl} soft=${r.softLimit} hard=${r.hardLimit} ${r.currency} state=${e}`)}async function L(){const r=process.argv.slice(2);if(r.includes("--version")||r.includes("-v")){process.stdout.write(`hydra-acp-budgeter ${y()}
4
+ `);return}if(r[0]==="reset"){v();return}await w()}L().catch(r=>{process.stderr.write(`hydra-acp-budgeter: ${r.message}
5
+ `),process.exit(1)});
package/dist/paths.js CHANGED
@@ -1,12 +1 @@
1
- import { homedir } from "node:os";
2
- import { resolve } from "node:path";
3
- // hydra-acp injects HYDRA_ACP_HOME when it spawns transformers; this file
4
- // is invoked both in that role and as a one-shot subcommand from the
5
- // user's shell, so fall back to ~/.hydra-acp when the env isn't set.
6
- function hydraHome() {
7
- return process.env.HYDRA_ACP_HOME ?? resolve(homedir(), ".hydra-acp");
8
- }
9
- export function stateFilePath() {
10
- return resolve(hydraHome(), "budgeter-cost.json");
11
- }
12
- //# sourceMappingURL=paths.js.map
1
+ import{homedir as t}from"node:os";import{resolve as r}from"node:path";function e(){return process.env.HYDRA_ACP_HOME??r(t(),".hydra-acp")}function i(){return r(e(),"budgeter-cost.json")}export{i as stateFilePath};
package/dist/router.js CHANGED
@@ -1,161 +1 @@
1
- // Translates incoming hydra-acp/transformer/message and hydra-acp/transformer/session_event
2
- // envelopes into BudgetEvents, runs them through the rule, and dispatches
3
- // the resulting verdicts via the Enforcer. Unlike the notifier's
4
- // EventRouter (one instance per session), this is a singleton — the
5
- // transformer process holds one connection for every session, so the
6
- // router keeps a sessionId → SessionMeta map.
7
- export class EventRouter {
8
- rule;
9
- tracker;
10
- enforcer;
11
- log;
12
- metas = new Map();
13
- constructor(rule, tracker, enforcer, log) {
14
- this.rule = rule;
15
- this.tracker = tracker;
16
- this.enforcer = enforcer;
17
- this.log = log;
18
- }
19
- setMeta(sessionId, meta) {
20
- this.metas.set(sessionId, meta);
21
- }
22
- forgetSession(sessionId) {
23
- this.metas.delete(sessionId);
24
- }
25
- // Fired by the bridge when an agent→client session/update notification
26
- // is being routed. usage_update is the only kind we care about for cost
27
- // tracking; other kinds may still flow through the rule so a custom
28
- // rule can observe (but the default rule ignores them).
29
- async onResponseUpdate(sessionId, update) {
30
- const kind = typeof update.sessionUpdate === "string" ? update.sessionUpdate : "";
31
- // session_info_update may carry an updated title; mirror the notifier
32
- // and refresh meta so subsequent warning messages reflect the latest.
33
- if (kind === "session_info_update") {
34
- this.applySessionInfoUpdate(sessionId, update);
35
- }
36
- if (kind !== "usage_update") {
37
- return;
38
- }
39
- this.tracker.applyUsageUpdate(sessionId, update);
40
- const transition = this.tracker.consumeStateTransition();
41
- if (transition !== undefined) {
42
- await this.fire({
43
- sessionId,
44
- kind: "threshold_cross",
45
- raw: { to: transition, from: this.tracker.state },
46
- meta: this.metaFor(sessionId),
47
- budget: this.tracker.snapshotFor(sessionId),
48
- });
49
- }
50
- // Also run a plain usage_update event so a custom rule can act on
51
- // every tick (e.g. emit a status notification every $1 spent). The
52
- // default rule ignores this kind.
53
- await this.fire({
54
- sessionId,
55
- kind: "usage_update",
56
- raw: update,
57
- meta: this.metaFor(sessionId),
58
- budget: this.tracker.snapshotFor(sessionId),
59
- });
60
- }
61
- // Fired by the bridge for a request:session/prompt intercept. Returns
62
- // the stop payload to send back to the daemon, or undefined to let the
63
- // prompt continue. The rule decides — the default rule rejects when
64
- // we're in the "hard" state.
65
- async onPromptRequest(sessionId, envelope) {
66
- const verdict = await this.runRule({
67
- sessionId,
68
- kind: "prompt_request",
69
- raw: { envelope },
70
- meta: this.metaFor(sessionId),
71
- budget: this.tracker.snapshotFor(sessionId),
72
- });
73
- if (!verdict) {
74
- return undefined;
75
- }
76
- return this.enforcer.dispatch(sessionId, verdict);
77
- }
78
- // Fired by the bridge for lifecycle:session.opened and session.closed.
79
- async onLifecycle(event, sessionId) {
80
- if (event === "session.opened") {
81
- await this.fire({
82
- sessionId,
83
- kind: "session_opened",
84
- raw: {},
85
- meta: this.metaFor(sessionId),
86
- budget: this.tracker.snapshotFor(sessionId),
87
- });
88
- return;
89
- }
90
- if (event === "session.closed") {
91
- // Note: we deliberately do NOT drop the session's cost from the
92
- // tracker — total spend is sticky across session.closed so the
93
- // budget reflects every dollar this transformer has seen. Reset
94
- // via `hydra-acp-budgeter reset` (deletes the state file) when
95
- // you want to zero it.
96
- await this.fire({
97
- sessionId,
98
- kind: "session_closed",
99
- raw: {},
100
- meta: this.metaFor(sessionId),
101
- budget: this.tracker.snapshotFor(sessionId),
102
- });
103
- this.forgetSession(sessionId);
104
- }
105
- }
106
- async fire(ev) {
107
- const verdict = await this.runRule(ev);
108
- if (!verdict) {
109
- return;
110
- }
111
- // For non-prompt events, only the warn side can fire — reject without
112
- // a prompt to attach it to is a no-op. Log and skip so a misconfigured
113
- // rule doesn't fail silently.
114
- if (verdict.reject && ev.kind !== "prompt_request") {
115
- this.log.warn(`rule returned reject on kind=${ev.kind} for ${ev.sessionId.slice(0, 12)} — only valid on prompt_request; ignored`);
116
- }
117
- if (verdict.warn) {
118
- await this.enforcer.warn(ev.sessionId, verdict.warn);
119
- }
120
- }
121
- async runRule(ev) {
122
- try {
123
- return await this.rule(ev);
124
- }
125
- catch (err) {
126
- this.log.warn(`rule threw on kind=${ev.kind} sessionId=${ev.sessionId.slice(0, 12)}: ${err.message}; skipping`);
127
- return null;
128
- }
129
- }
130
- metaFor(sessionId) {
131
- return this.metas.get(sessionId) ?? {};
132
- }
133
- applySessionInfoUpdate(sessionId, update) {
134
- const cur = { ...this.metaFor(sessionId) };
135
- let changed = false;
136
- if (typeof update.title === "string" && cur.title !== update.title) {
137
- cur.title = update.title;
138
- changed = true;
139
- }
140
- const agentId = readHydraAgentId(update._meta);
141
- if (agentId !== undefined && cur.agentId !== agentId) {
142
- cur.agentId = agentId;
143
- changed = true;
144
- }
145
- if (changed) {
146
- this.metas.set(sessionId, cur);
147
- }
148
- }
149
- }
150
- function readHydraAgentId(meta) {
151
- if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
152
- return undefined;
153
- }
154
- const ns = meta["hydra-acp"];
155
- if (!ns || typeof ns !== "object" || Array.isArray(ns)) {
156
- return undefined;
157
- }
158
- const v = ns.agentId;
159
- return typeof v === "string" ? v : undefined;
160
- }
161
- //# sourceMappingURL=router.js.map
1
+ class a{constructor(t,e,r,n){this.rule=t;this.tracker=e;this.enforcer=r;this.log=n}rule;tracker;enforcer;log;metas=new Map;setMeta(t,e){this.metas.set(t,e)}forgetSession(t){this.metas.delete(t)}async onResponseUpdate(t,e){const r=typeof e.sessionUpdate=="string"?e.sessionUpdate:"";if(r==="session_info_update"&&this.applySessionInfoUpdate(t,e),r!=="usage_update")return;this.tracker.applyUsageUpdate(t,e);const n=this.tracker.consumeStateTransition();n!==void 0&&await this.fire({sessionId:t,kind:"threshold_cross",raw:{to:n,from:this.tracker.state},meta:this.metaFor(t),budget:this.tracker.snapshotFor(t)}),await this.fire({sessionId:t,kind:"usage_update",raw:e,meta:this.metaFor(t),budget:this.tracker.snapshotFor(t)})}async onPromptRequest(t,e){const r=await this.runRule({sessionId:t,kind:"prompt_request",raw:{envelope:e},meta:this.metaFor(t),budget:this.tracker.snapshotFor(t)});if(r)return this.enforcer.dispatch(t,r)}async onLifecycle(t,e){if(t==="session.opened"){await this.fire({sessionId:e,kind:"session_opened",raw:{},meta:this.metaFor(e),budget:this.tracker.snapshotFor(e)});return}t==="session.closed"&&(await this.fire({sessionId:e,kind:"session_closed",raw:{},meta:this.metaFor(e),budget:this.tracker.snapshotFor(e)}),this.forgetSession(e))}async fire(t){const e=await this.runRule(t);e&&(e.reject&&t.kind!=="prompt_request"&&this.log.warn(`rule returned reject on kind=${t.kind} for ${t.sessionId.slice(0,12)} \u2014 only valid on prompt_request; ignored`),e.warn&&await this.enforcer.warn(t.sessionId,e.warn))}async runRule(t){try{return await this.rule(t)}catch(e){return this.log.warn(`rule threw on kind=${t.kind} sessionId=${t.sessionId.slice(0,12)}: ${e.message}; skipping`),null}}metaFor(t){return this.metas.get(t)??{}}applySessionInfoUpdate(t,e){const r={...this.metaFor(t)};let n=!1;typeof e.title=="string"&&r.title!==e.title&&(r.title=e.title,n=!0);const s=o(e._meta);s!==void 0&&r.agentId!==s&&(r.agentId=s,n=!0),n&&this.metas.set(t,r)}}function o(i){if(!i||typeof i!="object"||Array.isArray(i))return;const t=i["hydra-acp"];if(!t||typeof t!="object"||Array.isArray(t))return;const e=t.agentId;return typeof e=="string"?e:void 0}export{a as EventRouter};
package/dist/rule.js CHANGED
@@ -1,86 +1 @@
1
- const CURRENCY_SYMBOLS = {
2
- USD: "$",
3
- EUR: "€",
4
- GBP: "£",
5
- JPY: "¥",
6
- CNY: "¥",
7
- CAD: "C$",
8
- AUD: "A$",
9
- NZD: "NZ$",
10
- CHF: "CHF ",
11
- HKD: "HK$",
12
- SGD: "S$",
13
- INR: "₹",
14
- KRW: "₩",
15
- BRL: "R$",
16
- MXN: "MX$",
17
- };
18
- function fmtMoney(amount, currency) {
19
- const fixed = amount.toFixed(2);
20
- const sym = CURRENCY_SYMBOLS[currency.toUpperCase()];
21
- if (sym) {
22
- return `${sym}${fixed}`;
23
- }
24
- return `${currency} ${fixed}`;
25
- }
26
- // The rule the budgeter runs on every event. Strategy:
27
- // - threshold_cross to "soft": warn (one-shot, the tracker only fires
28
- // crosses once per transition)
29
- // - threshold_cross to "hard": warn (also one-shot)
30
- // - prompt_request in "hard" state: reject with stopReason "refusal"
31
- // and an explanation in the message
32
- // - session_opened in "hard" state: warn the new session so the user
33
- // understands why their next prompt will bounce
34
- // - usage_update and session_closed: do nothing
35
- export const DEFAULT_RULE = (ev) => {
36
- const { budget } = ev;
37
- const totalStr = fmtMoney(budget.total, budget.currency);
38
- const softStr = fmtMoney(budget.soft, budget.currency);
39
- const hardStr = fmtMoney(budget.hard, budget.currency);
40
- if (ev.kind === "threshold_cross") {
41
- const to = (ev.raw.to ?? "ok");
42
- if (to === "hard") {
43
- return {
44
- warn: {
45
- title: `🛑 Budget hard limit hit`,
46
- body: `Spent ${totalStr} ≥ ${hardStr} hard limit. Further prompts will be rejected until the budget is reset.`,
47
- },
48
- };
49
- }
50
- return null;
51
- }
52
- // Warn on every turn that reports a cost while over the soft limit.
53
- if (ev.kind === "usage_update" && budget.state !== "ok" && typeof ev.raw.cost?.amount === "number") {
54
- const label = budget.state === "hard" ? "🛑 Over hard limit" : "💰 Over soft limit";
55
- return {
56
- warn: {
57
- title: `${label} · ${totalStr} spent`,
58
- body: budget.state === "hard"
59
- ? `Hard limit ${hardStr} reached. Prompts will be rejected until budget is reset.`
60
- : `Soft limit ${softStr} reached (hard: ${hardStr}).`,
61
- },
62
- };
63
- }
64
- if (ev.kind === "prompt_request" && budget.state === "hard") {
65
- return {
66
- warn: {
67
- title: `🛑 Prompt blocked — budget exceeded`,
68
- body: `Spent ${totalStr} ≥ ${hardStr} hard limit. Reset the budget or raise HYDRA_ACP_BUDGETER_HARD to continue.`,
69
- },
70
- reject: {
71
- message: `Budget exceeded: spent ${totalStr} ≥ ${hardStr} hard limit. Reset the budget or raise HYDRA_ACP_BUDGETER_HARD to continue.`,
72
- stopReason: "refusal",
73
- },
74
- };
75
- }
76
- if (ev.kind === "session_opened" && budget.state === "hard") {
77
- return {
78
- warn: {
79
- title: `🛑 Session opened over budget`,
80
- body: `Total spend ${totalStr} ≥ ${hardStr}. Prompts on this session will be rejected.`,
81
- },
82
- };
83
- }
84
- return null;
85
- };
86
- //# sourceMappingURL=rule.js.map
1
+ const d={USD:"$",EUR:"\u20AC",GBP:"\xA3",JPY:"\xA5",CNY:"\xA5",CAD:"C$",AUD:"A$",NZD:"NZ$",CHF:"CHF ",HKD:"HK$",SGD:"S$",INR:"\u20B9",KRW:"\u20A9",BRL:"R$",MXN:"MX$"};function o(e,t){const r=e.toFixed(2),s=d[t.toUpperCase()];return s?`${s}${r}`:`${t} ${r}`}const u=e=>{const{budget:t}=e,r=o(t.total,t.currency),s=o(t.soft,t.currency),n=o(t.hard,t.currency);return e.kind==="threshold_cross"?(e.raw.to??"ok")==="hard"?{warn:{title:"\u{1F6D1} Budget hard limit hit",body:`Spent ${r} \u2265 ${n} hard limit. Further prompts will be rejected until the budget is reset.`}}:null:e.kind==="usage_update"&&t.state!=="ok"&&typeof e.raw.cost?.amount=="number"?{warn:{title:`${t.state==="hard"?"\u{1F6D1} Over hard limit":"\u{1F4B0} Over soft limit"} \xB7 ${r} spent`,body:t.state==="hard"?`Hard limit ${n} reached. Prompts will be rejected until budget is reset.`:`Soft limit ${s} reached (hard: ${n}).`}}:e.kind==="prompt_request"&&t.state==="hard"?{warn:{title:"\u{1F6D1} Prompt blocked \u2014 budget exceeded",body:`Spent ${r} \u2265 ${n} hard limit. Reset the budget or raise HYDRA_ACP_BUDGETER_HARD to continue.`},reject:{message:`Budget exceeded: spent ${r} \u2265 ${n} hard limit. Reset the budget or raise HYDRA_ACP_BUDGETER_HARD to continue.`,stopReason:"refusal"}}:e.kind==="session_opened"&&t.state==="hard"?{warn:{title:"\u{1F6D1} Session opened over budget",body:`Total spend ${r} \u2265 ${n}. Prompts on this session will be rejected.`}}:null};export{u as DEFAULT_RULE};
package/dist/tracker.js CHANGED
@@ -1,264 +1 @@
1
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { logger } from "./util/log.js";
4
- const log = logger("tracker");
5
- export class CostTracker {
6
- opts;
7
- sessions = new Map();
8
- currentState = "ok";
9
- // Last JSON we wrote to disk. The watcher uses this to ignore its own
10
- // writes — if the file's content matches, we did it.
11
- lastWrittenJson = "";
12
- constructor(opts) {
13
- this.opts = opts;
14
- if (opts.statePath) {
15
- this.loadFromDisk();
16
- }
17
- }
18
- applyUsageUpdate(sessionId, update) {
19
- log.debug(`usage_update session=${sessionId.slice(0, 12)} raw=${JSON.stringify(update).slice(0, 200)}`);
20
- const cost = readCost(update);
21
- if (cost === undefined) {
22
- log.debug(`readCost returned undefined for session=${sessionId.slice(0, 12)}`);
23
- return this.snapshotFor(sessionId);
24
- }
25
- const prior = this.sessions.get(sessionId) ?? { cost: 0, baseline: 0, currency: undefined };
26
- const next = {
27
- cost: Math.max(prior.cost, cost.amount),
28
- baseline: prior.baseline,
29
- currency: cost.currency ?? prior.currency,
30
- };
31
- this.sessions.set(sessionId, next);
32
- this.persist();
33
- return this.snapshotFor(sessionId);
34
- }
35
- // Public snapshot accessor for the synthetic events (session_opened,
36
- // prompt_request) that don't carry a cost in their own envelope.
37
- snapshotFor(sessionId) {
38
- const per = this.sessions.get(sessionId);
39
- const total = this.totalCost();
40
- return {
41
- total,
42
- perSession: per ? Math.max(0, per.cost - per.baseline) : 0,
43
- currency: per?.currency ?? this.opts.currency,
44
- soft: this.opts.softLimit,
45
- hard: this.opts.hardLimit,
46
- state: deriveState(total, this.opts.softLimit, this.opts.hardLimit),
47
- };
48
- }
49
- // Returns the new state if this call transitioned to a higher tier
50
- // (ok→soft, ok→hard, soft→hard). Returns undefined if the state didn't
51
- // change or if it dropped (e.g. a reset zeroed everything).
52
- consumeStateTransition() {
53
- const next = deriveState(this.totalCost(), this.opts.softLimit, this.opts.hardLimit);
54
- if (rank(next) > rank(this.currentState)) {
55
- this.currentState = next;
56
- return next;
57
- }
58
- if (next !== this.currentState) {
59
- this.currentState = next;
60
- }
61
- return undefined;
62
- }
63
- get state() {
64
- return this.currentState;
65
- }
66
- // Baseline all sessions from their current cost so effective spend
67
- // reads zero without discarding the agent's running total.
68
- reset() {
69
- this.baselineFromCurrent();
70
- this.persist();
71
- }
72
- baselineFromCurrent() {
73
- for (const [id, s] of this.sessions) {
74
- this.sessions.set(id, { ...s, baseline: s.cost });
75
- }
76
- this.currentState = "ok";
77
- }
78
- // Re-read the state file and replace in-memory state if it changed
79
- // from what we last wrote. Called from the fs.watch handler in bridge.
80
- // Returns true when state was adopted (caller can react if it cares).
81
- adoptFromDisk() {
82
- if (!this.opts.statePath) {
83
- return false;
84
- }
85
- let raw;
86
- try {
87
- raw = readFileSync(this.opts.statePath, "utf8");
88
- }
89
- catch (err) {
90
- const e = err;
91
- if (e.code === "ENOENT") {
92
- // File deleted = reset. Baseline all sessions from current cost
93
- // so effective spend reads zero without discarding the agent's
94
- // running total (it will keep reporting the same numbers).
95
- if (this.sessions.size === 0) {
96
- return false;
97
- }
98
- log.info("state file removed externally — baselining from current totals");
99
- this.baselineFromCurrent();
100
- this.lastWrittenJson = "";
101
- return true;
102
- }
103
- log.warn(`read state failed: ${e.message}`);
104
- return false;
105
- }
106
- if (raw === this.lastWrittenJson) {
107
- return false;
108
- }
109
- // Don't advance currentState — leave it at the pre-load value so
110
- // consumeStateTransition detects any upward crossing on the next tick.
111
- const adopted = this.applyPersisted(raw, false);
112
- if (adopted) {
113
- this.lastWrittenJson = raw;
114
- log.info(`adopted state from disk (total=${this.totalCost().toFixed(2)})`);
115
- }
116
- return adopted;
117
- }
118
- loadFromDisk() {
119
- if (!this.opts.statePath) {
120
- return;
121
- }
122
- let raw;
123
- try {
124
- raw = readFileSync(this.opts.statePath, "utf8");
125
- }
126
- catch (err) {
127
- const e = err;
128
- if (e.code !== "ENOENT") {
129
- log.warn(`read state failed: ${e.message}; starting fresh`);
130
- }
131
- return;
132
- }
133
- if (this.applyPersisted(raw)) {
134
- this.lastWrittenJson = raw;
135
- log.info(`loaded state from ${this.opts.statePath} (total=${this.totalCost().toFixed(2)})`);
136
- }
137
- }
138
- applyPersisted(raw, updateState = true) {
139
- let parsed;
140
- try {
141
- parsed = JSON.parse(raw);
142
- }
143
- catch (err) {
144
- log.warn(`state file malformed: ${err.message}; ignoring`);
145
- return false;
146
- }
147
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
148
- log.warn("state file is not an object; ignoring");
149
- return false;
150
- }
151
- const obj = parsed;
152
- const sessions = obj.sessions ?? {};
153
- if (typeof sessions !== "object" || Array.isArray(sessions)) {
154
- log.warn("state.sessions is not an object; ignoring");
155
- return false;
156
- }
157
- this.sessions.clear();
158
- for (const [id, val] of Object.entries(sessions)) {
159
- if (val && typeof val === "object" && typeof val.cost === "number") {
160
- const v = val;
161
- this.sessions.set(id, {
162
- cost: v.cost,
163
- baseline: typeof v.baseline === "number" ? v.baseline : 0,
164
- currency: typeof v.currency === "string" ? v.currency : undefined,
165
- });
166
- }
167
- }
168
- if (updateState) {
169
- this.currentState = deriveState(this.totalCost(), this.opts.softLimit, this.opts.hardLimit);
170
- }
171
- return true;
172
- }
173
- persist() {
174
- if (!this.opts.statePath) {
175
- return;
176
- }
177
- const payload = {
178
- version: 1,
179
- sessions: Object.fromEntries(this.sessions),
180
- };
181
- const json = JSON.stringify(payload, null, 2);
182
- if (json === this.lastWrittenJson) {
183
- return;
184
- }
185
- try {
186
- mkdirSync(dirname(this.opts.statePath), { recursive: true });
187
- const tmp = `${this.opts.statePath}.tmp`;
188
- writeFileSync(tmp, json, { encoding: "utf8", mode: 0o600 });
189
- renameSync(tmp, this.opts.statePath);
190
- this.lastWrittenJson = json;
191
- }
192
- catch (err) {
193
- log.warn(`persist failed: ${err.message}`);
194
- }
195
- }
196
- totalCost() {
197
- let sum = 0;
198
- for (const s of this.sessions.values()) {
199
- sum += Math.max(0, s.cost - s.baseline);
200
- }
201
- return sum;
202
- }
203
- }
204
- // Delete the persisted state file. Used by the `reset` subcommand when
205
- // no live process is running (or as the message the live process picks
206
- // up via fs.watch).
207
- export function deleteStateFile(statePath) {
208
- try {
209
- unlinkSync(statePath);
210
- return true;
211
- }
212
- catch (err) {
213
- const e = err;
214
- if (e.code === "ENOENT") {
215
- return false;
216
- }
217
- throw err;
218
- }
219
- }
220
- function deriveState(total, soft, hard) {
221
- if (total >= hard) {
222
- return "hard";
223
- }
224
- if (total >= soft) {
225
- return "soft";
226
- }
227
- return "ok";
228
- }
229
- function rank(state) {
230
- if (state === "hard")
231
- return 2;
232
- if (state === "soft")
233
- return 1;
234
- return 0;
235
- }
236
- function readCost(update) {
237
- // hydra injects cumulative cost via _meta.hydra-acp.cumulativeCost on
238
- // usage_update for sessions that have lived across multiple agents.
239
- // Prefer that when present so resurrects don't undercount.
240
- const cumulative = readCumulativeFromMeta(update._meta);
241
- const cost = (update.cost ?? undefined);
242
- const amount = cumulative !== undefined
243
- ? cumulative
244
- : typeof cost?.amount === "number"
245
- ? cost.amount
246
- : undefined;
247
- if (amount === undefined) {
248
- return undefined;
249
- }
250
- const currency = typeof cost?.currency === "string" ? cost.currency : undefined;
251
- return { amount, currency };
252
- }
253
- function readCumulativeFromMeta(meta) {
254
- if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
255
- return undefined;
256
- }
257
- const ns = meta["hydra-acp"];
258
- if (!ns || typeof ns !== "object" || Array.isArray(ns)) {
259
- return undefined;
260
- }
261
- const v = ns.cumulativeCost;
262
- return typeof v === "number" ? v : undefined;
263
- }
264
- //# sourceMappingURL=tracker.js.map
1
+ import{mkdirSync as l,readFileSync as d,renameSync as p,unlinkSync as m,writeFileSync as y}from"node:fs";import{dirname as g}from"node:path";import{logger as b}from"./util/log.js";const n=b("tracker");class x{constructor(t){this.opts=t;t.statePath&&this.loadFromDisk()}opts;sessions=new Map;currentState="ok";lastWrittenJson="";applyUsageUpdate(t,e){n.debug(`usage_update session=${t.slice(0,12)} raw=${JSON.stringify(e).slice(0,200)}`);const s=S(e);if(s===void 0)return n.debug(`readCost returned undefined for session=${t.slice(0,12)}`),this.snapshotFor(t);const i=this.sessions.get(t)??{cost:0,baseline:0,currency:void 0},o={cost:Math.max(i.cost,s.amount),baseline:i.baseline,currency:s.currency??i.currency};return this.sessions.set(t,o),this.persist(),this.snapshotFor(t)}snapshotFor(t){const e=this.sessions.get(t),s=this.totalCost();return{total:s,perSession:e?Math.max(0,e.cost-e.baseline):0,currency:e?.currency??this.opts.currency,soft:this.opts.softLimit,hard:this.opts.hardLimit,state:f(s,this.opts.softLimit,this.opts.hardLimit)}}consumeStateTransition(){const t=f(this.totalCost(),this.opts.softLimit,this.opts.hardLimit);if(h(t)>h(this.currentState))return this.currentState=t,t;t!==this.currentState&&(this.currentState=t)}get state(){return this.currentState}reset(){this.baselineFromCurrent(),this.persist()}baselineFromCurrent(){for(const[t,e]of this.sessions)this.sessions.set(t,{...e,baseline:e.cost});this.currentState="ok"}adoptFromDisk(){if(!this.opts.statePath)return!1;let t;try{t=d(this.opts.statePath,"utf8")}catch(s){const i=s;return i.code==="ENOENT"?this.sessions.size===0?!1:(n.info("state file removed externally \u2014 baselining from current totals"),this.baselineFromCurrent(),this.lastWrittenJson="",!0):(n.warn(`read state failed: ${i.message}`),!1)}if(t===this.lastWrittenJson)return!1;const e=this.applyPersisted(t,!1);return e&&(this.lastWrittenJson=t,n.info(`adopted state from disk (total=${this.totalCost().toFixed(2)})`)),e}loadFromDisk(){if(!this.opts.statePath)return;let t;try{t=d(this.opts.statePath,"utf8")}catch(e){const s=e;s.code!=="ENOENT"&&n.warn(`read state failed: ${s.message}; starting fresh`);return}this.applyPersisted(t)&&(this.lastWrittenJson=t,n.info(`loaded state from ${this.opts.statePath} (total=${this.totalCost().toFixed(2)})`))}applyPersisted(t,e=!0){let s;try{s=JSON.parse(t)}catch(c){return n.warn(`state file malformed: ${c.message}; ignoring`),!1}if(!s||typeof s!="object"||Array.isArray(s))return n.warn("state file is not an object; ignoring"),!1;const o=s.sessions??{};if(typeof o!="object"||Array.isArray(o))return n.warn("state.sessions is not an object; ignoring"),!1;this.sessions.clear();for(const[c,u]of Object.entries(o))if(u&&typeof u=="object"&&typeof u.cost=="number"){const a=u;this.sessions.set(c,{cost:a.cost,baseline:typeof a.baseline=="number"?a.baseline:0,currency:typeof a.currency=="string"?a.currency:void 0})}return e&&(this.currentState=f(this.totalCost(),this.opts.softLimit,this.opts.hardLimit)),!0}persist(){if(!this.opts.statePath)return;const t={version:1,sessions:Object.fromEntries(this.sessions)},e=JSON.stringify(t,null,2);if(e!==this.lastWrittenJson)try{l(g(this.opts.statePath),{recursive:!0});const s=`${this.opts.statePath}.tmp`;y(s,e,{encoding:"utf8",mode:384}),p(s,this.opts.statePath),this.lastWrittenJson=e}catch(s){n.warn(`persist failed: ${s.message}`)}}totalCost(){let t=0;for(const e of this.sessions.values())t+=Math.max(0,e.cost-e.baseline);return t}}function E(r){try{return m(r),!0}catch(t){if(t.code==="ENOENT")return!1;throw t}}function f(r,t,e){return r>=e?"hard":r>=t?"soft":"ok"}function h(r){return r==="hard"?2:r==="soft"?1:0}function S(r){const t=k(r._meta),e=r.cost??void 0,s=t!==void 0?t:typeof e?.amount=="number"?e.amount:void 0;if(s===void 0)return;const i=typeof e?.currency=="string"?e.currency:void 0;return{amount:s,currency:i}}function k(r){if(!r||typeof r!="object"||Array.isArray(r))return;const t=r["hydra-acp"];if(!t||typeof t!="object"||Array.isArray(t))return;const e=t.cumulativeCost;return typeof e=="number"?e:void 0}export{x as CostTracker,E as deleteStateFile};
package/dist/util/log.js CHANGED
@@ -1,46 +1,2 @@
1
- let debugEnabled = false;
2
- export function setDebug(on) {
3
- debugEnabled = on;
4
- }
5
- function emit(level, scope, args) {
6
- const ts = new Date().toISOString();
7
- const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
8
- stream.write(`[${ts}] ${level} [${scope}] ${formatArgs(args)}\n`);
9
- }
10
- function formatArgs(args) {
11
- return args
12
- .map((a) => {
13
- if (typeof a === "string") {
14
- return a;
15
- }
16
- if (a instanceof Error) {
17
- return a.stack ?? a.message;
18
- }
19
- try {
20
- return JSON.stringify(a);
21
- }
22
- catch {
23
- return String(a);
24
- }
25
- })
26
- .join(" ");
27
- }
28
- export function logger(scope) {
29
- return {
30
- debug(...args) {
31
- if (debugEnabled) {
32
- emit("debug", scope, args);
33
- }
34
- },
35
- info(...args) {
36
- emit("info", scope, args);
37
- },
38
- warn(...args) {
39
- emit("warn", scope, args);
40
- },
41
- error(...args) {
42
- emit("error", scope, args);
43
- },
44
- };
45
- }
46
- //# sourceMappingURL=log.js.map
1
+ let t=!1;function s(r){t=r}function o(r,n,e){const i=new Date().toISOString();(r==="error"||r==="warn"?process.stderr:process.stdout).write(`[${i}] ${r} [${n}] ${u(e)}
2
+ `)}function u(r){return r.map(n=>{if(typeof n=="string")return n;if(n instanceof Error)return n.stack??n.message;try{return JSON.stringify(n)}catch{return String(n)}}).join(" ")}function d(r){return{debug(...n){t&&o("debug",r,n)},info(...n){o("info",r,n)},warn(...n){o("warn",r,n)},error(...n){o("error",r,n)}}}export{d as logger,s as setDebug};