@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/acp/protocol.js +1 -13
- package/dist/acp/transformer.js +1 -188
- package/dist/bridge.js +1 -264
- package/dist/config.js +2 -99
- package/dist/enforce.js +10 -81
- package/dist/index.js +4 -64
- package/dist/paths.js +1 -12
- package/dist/router.js +1 -161
- package/dist/rule.js +1 -86
- package/dist/tracker.js +1 -264
- package/dist/util/log.js +2 -46
- package/package.json +5 -4
- package/dist/acp/protocol.js.map +0 -1
- package/dist/acp/transformer.js.map +0 -1
- package/dist/bridge.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/enforce.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/paths.js.map +0 -1
- package/dist/router.js.map +0 -1
- package/dist/rule.js.map +0 -1
- package/dist/tracker.js.map +0 -1
- package/dist/util/log.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,65 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
2
|
-
|
|
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};
|