@hydra-acp/budgeter 0.1.3 → 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/enforce.js CHANGED
@@ -1,81 +1,10 @@
1
- // Builds outbound emit_message payloads and stop responses for the
2
- // router. Centralises the "what does a warning/rejection look like on
3
- // the wire" decisions so the router only deals with verdicts.
4
- export class Enforcer {
5
- client;
6
- log;
7
- trace;
8
- constructor(client, log, trace) {
9
- this.client = client;
10
- this.log = log;
11
- this.trace = trace;
12
- }
13
- // Emit a warning to every client attached to the session. Sent as a
14
- // session/update with sessionUpdate="agent_message_chunk" so it
15
- // surfaces in the conversation log on every well-behaved client. A
16
- // _meta.hydra-acp.budgeter marker lets renderer-aware clients style it
17
- // differently (or filter it out, if they prefer their own UI).
18
- async warn(sessionId, warn) {
19
- const text = warn.body ? `\n\n${warn.title}\n${warn.body}\n\n` : `\n\n${warn.title}\n\n`;
20
- const envelope = {
21
- sessionId,
22
- update: {
23
- sessionUpdate: "agent_message_chunk",
24
- content: { type: "text", text },
25
- _meta: {
26
- "hydra-acp": {
27
- budgeter: {
28
- title: warn.title,
29
- ...(warn.body !== undefined ? { body: warn.body } : {}),
30
- },
31
- },
32
- },
33
- },
34
- };
35
- if (this.trace) {
36
- this.trace.emits.push({
37
- sessionId,
38
- method: "session/update",
39
- envelope,
40
- });
41
- }
42
- try {
43
- await this.client.request("hydra-acp/emit_message", {
44
- sessionId,
45
- method: "session/update",
46
- envelope,
47
- route: "chain",
48
- });
49
- }
50
- catch (err) {
51
- this.log.warn(`emit_message warn for ${sessionId} failed: ${err.message}`);
52
- }
53
- }
54
- // Build the payload that the router returns as the stop action's
55
- // payload. Becomes the response delivered to the originator of the
56
- // session/prompt — clients render the stopReason and the _meta.
57
- buildRejectPayload(reject) {
58
- return {
59
- stopReason: reject.stopReason ?? "refusal",
60
- _meta: {
61
- "hydra-acp": {
62
- budgeter: { message: reject.message },
63
- },
64
- },
65
- };
66
- }
67
- // Convenience: combine a warn + reject into the appropriate side
68
- // effects. Called by the router on a prompt_request verdict that
69
- // includes both — the warn fires asynchronously so the stop payload
70
- // can be returned to the daemon without blocking on emit_message.
71
- async dispatch(sessionId, verdict) {
72
- if (verdict.warn) {
73
- void this.warn(sessionId, verdict.warn);
74
- }
75
- if (verdict.reject) {
76
- return this.buildRejectPayload(verdict.reject);
77
- }
78
- return undefined;
79
- }
80
- }
81
- //# sourceMappingURL=enforce.js.map
1
+ class i{constructor(t,e,r){this.client=t;this.log=e;this.trace=r}client;log;trace;async warn(t,e){const r=e.body?`
2
+
3
+ ${e.title}
4
+ ${e.body}
5
+
6
+ `:`
7
+
8
+ ${e.title}
9
+
10
+ `,n={sessionId:t,update:{sessionUpdate:"agent_message_chunk",content:{type:"text",text:r},_meta:{"hydra-acp":{budgeter:{title:e.title,...e.body!==void 0?{body:e.body}:{}}}}}};this.trace&&this.trace.emits.push({sessionId:t,method:"session/update",envelope:n});try{await this.client.request("hydra-acp/message/emit",{sessionId:t,method:"session/update",envelope:n,route:"chain"})}catch(s){this.log.warn(`emit_message warn for ${t} failed: ${s.message}`)}}buildRejectPayload(t){return{stopReason:t.stopReason??"refusal",_meta:{"hydra-acp":{budgeter:{message:t.message}}}}}async dispatch(t,e){if(e.warn&&this.warn(t,e.warn),e.reject)return this.buildRejectPayload(e.reject)}}export{i as Enforcer};
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 transformer/message and 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};