@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/README.md +1 -1
- 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/README.md
CHANGED
|
@@ -138,7 +138,7 @@ The file is optional — all keys have defaults and the transformer works withou
|
|
|
138
138
|
- `lifecycle:session.opened` — warn brand-new sessions that are already over budget
|
|
139
139
|
- `lifecycle:session.closed` — fires session_closed rule event (cost stays sticky)
|
|
140
140
|
- For every `transformer/message` the daemon dispatches, the budgeter responds with `{ action: "continue" }` (observe-only on response side, allow on request side when under budget) or `{ action: "stop", payload: { stopReason: "refusal", _meta: ... } }` (when over hard limit).
|
|
141
|
-
- Warnings are emitted via `hydra-acp/
|
|
141
|
+
- Warnings are emitted via `hydra-acp/message/emit` with `route: "chain"` and `method: "session/update"` so they flow back through the daemon's broadcast machinery and reach every attached client.
|
|
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).
|
package/dist/acp/protocol.js
CHANGED
|
@@ -1,13 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// truth for the initialize handshake; never a literal at the callsite.
|
|
3
|
-
export const ACP_PROTOCOL_VERSION = 1;
|
|
4
|
-
export function isRequest(m) {
|
|
5
|
-
return "method" in m && "id" in m;
|
|
6
|
-
}
|
|
7
|
-
export function isNotification(m) {
|
|
8
|
-
return "method" in m && !("id" in m);
|
|
9
|
-
}
|
|
10
|
-
export function isResponse(m) {
|
|
11
|
-
return !("method" in m) && "id" in m;
|
|
12
|
-
}
|
|
13
|
-
//# sourceMappingURL=protocol.js.map
|
|
1
|
+
const e=1;function s(n){return"method"in n&&"id"in n}function o(n){return"method"in n&&!("id"in n)}function t(n){return!("method"in n)&&"id"in n}export{e as ACP_PROTOCOL_VERSION,o as isNotification,s as isRequest,t as isResponse};
|
package/dist/acp/transformer.js
CHANGED
|
@@ -1,188 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { WebSocket } from "ws";
|
|
4
|
-
import { logger } from "../util/log.js";
|
|
5
|
-
import { ACP_PROTOCOL_VERSION, isNotification, isRequest, isResponse, } from "./protocol.js";
|
|
6
|
-
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
7
|
-
const log = logger("acp");
|
|
8
|
-
// One WebSocket to the daemon for the entire transformer process. Unlike
|
|
9
|
-
// the notifier's AcpAttach (one WS per session), the daemon multiplexes
|
|
10
|
-
// every session's transformer/message + transformer/session_event over
|
|
11
|
-
// this single connection, keyed by sessionId in the params.
|
|
12
|
-
export class TransformerClient extends EventEmitter {
|
|
13
|
-
opts;
|
|
14
|
-
ws;
|
|
15
|
-
nextId = 1;
|
|
16
|
-
pending = new Map();
|
|
17
|
-
connected = false;
|
|
18
|
-
constructor(opts) {
|
|
19
|
-
super();
|
|
20
|
-
this.opts = opts;
|
|
21
|
-
}
|
|
22
|
-
get isConnected() {
|
|
23
|
-
return this.connected;
|
|
24
|
-
}
|
|
25
|
-
start() {
|
|
26
|
-
log.debug(`connecting ${this.opts.daemonWsUrl}`);
|
|
27
|
-
const subprotocols = ["acp.v1", `hydra-acp-token.${this.opts.token}`];
|
|
28
|
-
let ws;
|
|
29
|
-
try {
|
|
30
|
-
ws = new WebSocket(this.opts.daemonWsUrl, subprotocols);
|
|
31
|
-
}
|
|
32
|
-
catch (err) {
|
|
33
|
-
this.emit("error", err);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
this.ws = ws;
|
|
37
|
-
ws.on("open", () => {
|
|
38
|
-
this.connected = true;
|
|
39
|
-
log.info(`ws open`);
|
|
40
|
-
void this.handshake()
|
|
41
|
-
.then(() => {
|
|
42
|
-
this.emit("open");
|
|
43
|
-
})
|
|
44
|
-
.catch((err) => {
|
|
45
|
-
this.emit("error", err);
|
|
46
|
-
try {
|
|
47
|
-
this.ws?.close();
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
void 0;
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
ws.on("message", (data, isBinary) => {
|
|
55
|
-
if (isBinary) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
const text = data.toString("utf8");
|
|
59
|
-
try {
|
|
60
|
-
const parsed = JSON.parse(text);
|
|
61
|
-
this.onMessage(parsed);
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
log.warn(`parse error: ${err.message}; raw=${text.slice(0, 200)}`);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
ws.on("error", (err) => {
|
|
68
|
-
log.warn(`ws error: ${err.message}`);
|
|
69
|
-
this.emit("error", err);
|
|
70
|
-
});
|
|
71
|
-
ws.on("close", (code, reason) => {
|
|
72
|
-
const hadError = code >= 4000 || code === 1006 || code === 1011;
|
|
73
|
-
const reasonText = reason.toString("utf8");
|
|
74
|
-
this.connected = false;
|
|
75
|
-
log.info(`ws closed code=${code}${reasonText ? ` reason=${reasonText}` : ""}`);
|
|
76
|
-
for (const [, p] of this.pending) {
|
|
77
|
-
p.reject(new Error("ws closed"));
|
|
78
|
-
}
|
|
79
|
-
this.pending.clear();
|
|
80
|
-
this.emit("close", { hadError });
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
stop() {
|
|
84
|
-
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
|
85
|
-
try {
|
|
86
|
-
this.ws.close();
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
void 0;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
async request(method, params) {
|
|
94
|
-
const id = this.nextId++;
|
|
95
|
-
const msg = {
|
|
96
|
-
jsonrpc: "2.0",
|
|
97
|
-
id,
|
|
98
|
-
method,
|
|
99
|
-
...(params !== undefined ? { params } : {}),
|
|
100
|
-
};
|
|
101
|
-
this.write(msg);
|
|
102
|
-
return new Promise((resolve, reject) => {
|
|
103
|
-
this.pending.set(id, {
|
|
104
|
-
resolve: (resp) => {
|
|
105
|
-
if (resp.error) {
|
|
106
|
-
reject(new Error(`${resp.error.code}: ${resp.error.message}`));
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
resolve(resp.result);
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
reject,
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
notify(method, params) {
|
|
117
|
-
const msg = {
|
|
118
|
-
jsonrpc: "2.0",
|
|
119
|
-
method,
|
|
120
|
-
...(params !== undefined ? { params } : {}),
|
|
121
|
-
};
|
|
122
|
-
this.write(msg);
|
|
123
|
-
}
|
|
124
|
-
reply(id, result) {
|
|
125
|
-
const msg = { jsonrpc: "2.0", id, result };
|
|
126
|
-
this.write(msg);
|
|
127
|
-
}
|
|
128
|
-
replyError(id, code, message) {
|
|
129
|
-
const msg = {
|
|
130
|
-
jsonrpc: "2.0",
|
|
131
|
-
id,
|
|
132
|
-
error: { code, message },
|
|
133
|
-
};
|
|
134
|
-
this.write(msg);
|
|
135
|
-
}
|
|
136
|
-
async handshake() {
|
|
137
|
-
try {
|
|
138
|
-
await this.request("initialize", {
|
|
139
|
-
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
140
|
-
clientCapabilities: {
|
|
141
|
-
fs: { readTextFile: false, writeTextFile: false },
|
|
142
|
-
terminal: false,
|
|
143
|
-
},
|
|
144
|
-
clientInfo: { name: "hydra-acp-budgeter", version: pkg.version },
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
log.warn(`initialize failed: ${err.message}`);
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
await this.request("transformer/initialize", {
|
|
152
|
-
intercepts: this.opts.intercepts,
|
|
153
|
-
});
|
|
154
|
-
log.info(`transformer/initialize ok intercepts=${this.opts.intercepts.join(",")}`);
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
log.warn(`transformer/initialize failed: ${err.message}`);
|
|
158
|
-
throw err;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
write(msg) {
|
|
162
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
163
|
-
log.warn(`drop write to closed ws: ${JSON.stringify(msg)}`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
this.ws.send(JSON.stringify(msg));
|
|
167
|
-
}
|
|
168
|
-
onMessage(m) {
|
|
169
|
-
if (isResponse(m)) {
|
|
170
|
-
const p = this.pending.get(m.id);
|
|
171
|
-
if (p) {
|
|
172
|
-
this.pending.delete(m.id);
|
|
173
|
-
p.resolve(m);
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
log.debug(`unmatched response id=${String(m.id)}`);
|
|
177
|
-
}
|
|
178
|
-
this.emit("response", m);
|
|
179
|
-
}
|
|
180
|
-
else if (isRequest(m)) {
|
|
181
|
-
this.emit("request", m);
|
|
182
|
-
}
|
|
183
|
-
else if (isNotification(m)) {
|
|
184
|
-
this.emit("notification", m);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
//# sourceMappingURL=transformer.js.map
|
|
1
|
+
import{EventEmitter as p}from"node:events";import{readFileSync as d}from"node:fs";import{WebSocket as c}from"ws";import{logger as h}from"../util/log.js";import{ACP_PROTOCOL_VERSION as l,isNotification as f,isRequest as g,isResponse as w}from"./protocol.js";const u=JSON.parse(d(new URL("../../package.json",import.meta.url),"utf8")),r=h("acp");class S extends p{constructor(e){super();this.opts=e}opts;ws;nextId=1;pending=new Map;connected=!1;get isConnected(){return this.connected}start(){r.debug(`connecting ${this.opts.daemonWsUrl}`);const e=["acp.v1",`hydra-acp-token.${this.opts.token}`];let t;try{t=new c(this.opts.daemonWsUrl,e)}catch(s){this.emit("error",s);return}this.ws=t,t.on("open",()=>{this.connected=!0,r.info("ws open"),this.handshake().then(()=>{this.emit("open")}).catch(s=>{this.emit("error",s);try{this.ws?.close()}catch{}})}),t.on("message",(s,o)=>{if(o)return;const i=s.toString("utf8");try{const n=JSON.parse(i);this.onMessage(n)}catch(n){r.warn(`parse error: ${n.message}; raw=${i.slice(0,200)}`)}}),t.on("error",s=>{r.warn(`ws error: ${s.message}`),this.emit("error",s)}),t.on("close",(s,o)=>{const i=s>=4e3||s===1006||s===1011,n=o.toString("utf8");this.connected=!1,r.info(`ws closed code=${s}${n?` reason=${n}`:""}`);for(const[,a]of this.pending)a.reject(new Error("ws closed"));this.pending.clear(),this.emit("close",{hadError:i})})}stop(){if(this.ws&&this.ws.readyState!==c.CLOSED)try{this.ws.close()}catch{}}async request(e,t){const s=this.nextId++,o={jsonrpc:"2.0",id:s,method:e,...t!==void 0?{params:t}:{}};return this.write(o),new Promise((i,n)=>{this.pending.set(s,{resolve:a=>{a.error?n(new Error(`${a.error.code}: ${a.error.message}`)):i(a.result)},reject:n})})}notify(e,t){const s={jsonrpc:"2.0",method:e,...t!==void 0?{params:t}:{}};this.write(s)}reply(e,t){const s={jsonrpc:"2.0",id:e,result:t};this.write(s)}replyError(e,t,s){const o={jsonrpc:"2.0",id:e,error:{code:t,message:s}};this.write(o)}async handshake(){try{await this.request("initialize",{protocolVersion:l,clientCapabilities:{fs:{readTextFile:!1,writeTextFile:!1},terminal:!1},clientInfo:{name:"hydra-acp-budgeter",version:u.version}})}catch(e){r.warn(`initialize failed: ${e.message}`)}try{await this.request("hydra-acp/transformer/initialize",{intercepts:this.opts.intercepts}),r.info(`hydra-acp/transformer/initialize ok intercepts=${this.opts.intercepts.join(",")}`)}catch(e){throw r.warn(`hydra-acp/transformer/initialize failed: ${e.message}`),e}}write(e){if(!this.ws||this.ws.readyState!==c.OPEN){r.warn(`drop write to closed ws: ${JSON.stringify(e)}`);return}this.ws.send(JSON.stringify(e))}onMessage(e){if(w(e)){const t=this.pending.get(e.id);t?(this.pending.delete(e.id),t.resolve(e)):r.debug(`unmatched response id=${String(e.id)}`),this.emit("response",e)}else g(e)?this.emit("request",e):f(e)&&this.emit("notification",e)}}export{S as TransformerClient};
|
package/dist/bridge.js
CHANGED
|
@@ -1,264 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { basename, dirname } from "node:path";
|
|
3
|
-
import { TransformerClient } from "./acp/transformer.js";
|
|
4
|
-
import { Enforcer } from "./enforce.js";
|
|
5
|
-
import { EventRouter } from "./router.js";
|
|
6
|
-
import { CostTracker } from "./tracker.js";
|
|
7
|
-
import { logger } from "./util/log.js";
|
|
8
|
-
const log = logger("bridge");
|
|
9
|
-
// The set of intercepts the budgeter declares to the daemon. Kept in one
|
|
10
|
-
// place so the rule, the router, and the README stay in agreement.
|
|
11
|
-
//
|
|
12
|
-
// response:session/update — observe usage_update for cost tracking
|
|
13
|
-
// request:session/prompt — reject when over hard limit
|
|
14
|
-
// lifecycle:session.opened — warn on new sessions while over budget
|
|
15
|
-
// lifecycle:session.closed — drop per-session cost state
|
|
16
|
-
const BUDGETER_INTERCEPTS = [
|
|
17
|
-
"response:session/update",
|
|
18
|
-
"request:session/prompt",
|
|
19
|
-
"lifecycle:session.opened",
|
|
20
|
-
"lifecycle:session.closed",
|
|
21
|
-
];
|
|
22
|
-
// One bridge per budgeter process. Owns the WS connection, the cost
|
|
23
|
-
// tracker, the enforcer, and the router. The mirror of NotifierBridge,
|
|
24
|
-
// except scoped to the whole process rather than to a single session.
|
|
25
|
-
export class BudgeterBridge {
|
|
26
|
-
opts;
|
|
27
|
-
client;
|
|
28
|
-
tracker;
|
|
29
|
-
enforcer;
|
|
30
|
-
router;
|
|
31
|
-
watcher;
|
|
32
|
-
watchTimer;
|
|
33
|
-
stopped = false;
|
|
34
|
-
constructor(opts) {
|
|
35
|
-
this.opts = opts;
|
|
36
|
-
this.client = new TransformerClient({
|
|
37
|
-
daemonWsUrl: opts.daemonWsUrl,
|
|
38
|
-
token: opts.token,
|
|
39
|
-
intercepts: BUDGETER_INTERCEPTS,
|
|
40
|
-
});
|
|
41
|
-
this.tracker = new CostTracker({
|
|
42
|
-
softLimit: opts.softLimit,
|
|
43
|
-
hardLimit: opts.hardLimit,
|
|
44
|
-
currency: opts.currency,
|
|
45
|
-
statePath: opts.statePath,
|
|
46
|
-
});
|
|
47
|
-
this.enforcer = new Enforcer(this.client, log);
|
|
48
|
-
this.router = new EventRouter(opts.rule, this.tracker, this.enforcer, log);
|
|
49
|
-
}
|
|
50
|
-
start() {
|
|
51
|
-
this.client.on("request", (r) => this.onRequest(r));
|
|
52
|
-
this.client.on("notification", (n) => this.onNotification(n));
|
|
53
|
-
this.client.on("error", (err) => {
|
|
54
|
-
log.warn(`client error: ${err.message}`);
|
|
55
|
-
});
|
|
56
|
-
this.client.on("open", () => {
|
|
57
|
-
void this.registerSlashCommands();
|
|
58
|
-
});
|
|
59
|
-
this.client.start();
|
|
60
|
-
this.startWatcher();
|
|
61
|
-
}
|
|
62
|
-
async registerSlashCommands() {
|
|
63
|
-
try {
|
|
64
|
-
await this.client.request("hydra-acp/register_commands", {
|
|
65
|
-
commands: [
|
|
66
|
-
{
|
|
67
|
-
verb: "reset",
|
|
68
|
-
description: "Reset accumulated cost baseline to current totals",
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
verb: "status",
|
|
72
|
-
description: "Show current spend vs. soft/hard limits",
|
|
73
|
-
},
|
|
74
|
-
],
|
|
75
|
-
});
|
|
76
|
-
log.info("registered /hydra hydra-acp-budgeter {reset,status}");
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
log.warn(`register_commands failed: ${err.message}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
stop() {
|
|
83
|
-
if (this.stopped) {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
this.stopped = true;
|
|
87
|
-
if (this.watcher) {
|
|
88
|
-
this.watcher.close();
|
|
89
|
-
this.watcher = undefined;
|
|
90
|
-
}
|
|
91
|
-
if (this.watchTimer) {
|
|
92
|
-
clearTimeout(this.watchTimer);
|
|
93
|
-
this.watchTimer = undefined;
|
|
94
|
-
}
|
|
95
|
-
this.client.stop();
|
|
96
|
-
}
|
|
97
|
-
// Watch the state file's parent directory so we still see events when
|
|
98
|
-
// the file is created later (e.g. first usage_update of a fresh run)
|
|
99
|
-
// or deleted entirely (the reset subcommand). fs.watch on a missing
|
|
100
|
-
// file throws on some platforms, but the parent dir is created by
|
|
101
|
-
// CostTracker.persist before any write happens, and the daemon writes
|
|
102
|
-
// the .pid file there even sooner — so the dir reliably exists by
|
|
103
|
-
// the time start() runs. We still try/catch in case it doesn't.
|
|
104
|
-
startWatcher() {
|
|
105
|
-
if (!this.opts.statePath) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const dir = dirname(this.opts.statePath);
|
|
109
|
-
const file = basename(this.opts.statePath);
|
|
110
|
-
try {
|
|
111
|
-
this.watcher = watch(dir, (eventType, filename) => {
|
|
112
|
-
if (filename && filename !== file) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
// fs.watch can fire 1–N times per logical change (especially
|
|
116
|
-
// when our own atomic-rename hits it). Debounce briefly so we
|
|
117
|
-
// do at most one re-read per burst.
|
|
118
|
-
if (this.watchTimer) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
this.watchTimer = setTimeout(() => {
|
|
122
|
-
this.watchTimer = undefined;
|
|
123
|
-
try {
|
|
124
|
-
this.tracker.adoptFromDisk();
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
log.warn(`adoptFromDisk failed: ${err.message}`);
|
|
128
|
-
}
|
|
129
|
-
}, 50);
|
|
130
|
-
});
|
|
131
|
-
log.debug(`watching ${this.opts.statePath}`);
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
log.warn(`fs.watch failed for ${dir}: ${err.message}`);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
onRequest(r) {
|
|
138
|
-
if (r.method === "hydra-acp/extension_command") {
|
|
139
|
-
this.handleExtensionCommand(r);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (r.method !== "transformer/message") {
|
|
143
|
-
// The daemon only sends transformer/message requests to us. Anything
|
|
144
|
-
// else is an error on the daemon side or a future protocol kind we
|
|
145
|
-
// don't yet understand. Continue rather than guessing.
|
|
146
|
-
this.client.reply(r.id, { action: "continue" });
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const params = (r.params ?? {});
|
|
150
|
-
const sessionId = typeof params.sessionId === "string" ? params.sessionId : "";
|
|
151
|
-
const phase = params.phase;
|
|
152
|
-
const method = params.method;
|
|
153
|
-
if (!sessionId || !phase || !method) {
|
|
154
|
-
this.client.reply(r.id, { action: "continue" });
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
if (phase === "response" && method === "session/update") {
|
|
158
|
-
// Response side is observe-only — we always continue and run the
|
|
159
|
-
// tracker update asynchronously. The daemon proceeds with the
|
|
160
|
-
// original envelope; any warning we want to emit goes out via a
|
|
161
|
-
// separate emit_message call.
|
|
162
|
-
this.client.reply(r.id, { action: "continue" });
|
|
163
|
-
const envelope = params.envelope;
|
|
164
|
-
const update = envelope && typeof envelope === "object" && !Array.isArray(envelope)
|
|
165
|
-
? envelope.update
|
|
166
|
-
: undefined;
|
|
167
|
-
if (update) {
|
|
168
|
-
void this.router
|
|
169
|
-
.onResponseUpdate(sessionId, update)
|
|
170
|
-
.catch((err) => log.warn(`response update error: ${err.message}`));
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
if (phase === "request" && method === "session/prompt") {
|
|
175
|
-
// Request side: ask the rule. If it returns a reject payload, stop;
|
|
176
|
-
// otherwise let the prompt continue. The reply waits on the rule —
|
|
177
|
-
// the daemon's forwardRequest is awaiting our response, so a slow
|
|
178
|
-
// rule pauses the prompt, which is acceptable for the rare reject
|
|
179
|
-
// path.
|
|
180
|
-
void this.router
|
|
181
|
-
.onPromptRequest(sessionId, params.envelope)
|
|
182
|
-
.then((rejectPayload) => {
|
|
183
|
-
if (rejectPayload) {
|
|
184
|
-
this.client.reply(r.id, { action: "stop", payload: rejectPayload });
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
this.client.reply(r.id, { action: "continue" });
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
.catch((err) => {
|
|
191
|
-
log.warn(`prompt request error: ${err.message}`);
|
|
192
|
-
this.client.reply(r.id, { action: "continue" });
|
|
193
|
-
});
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// Unknown phase/method — declare nothing and let the daemon proceed.
|
|
197
|
-
this.client.reply(r.id, { action: "continue" });
|
|
198
|
-
}
|
|
199
|
-
// "/hydra hydra-acp-budgeter <verb> [args]" routes to here. The daemon
|
|
200
|
-
// validates the verb against what we registered before forwarding, so
|
|
201
|
-
// an unknown verb here means the registry and our switch fell out of
|
|
202
|
-
// sync. Replies are returned as { text } and surface in the session
|
|
203
|
-
// transcript as a synthetic agent message.
|
|
204
|
-
handleExtensionCommand(r) {
|
|
205
|
-
const params = (r.params ?? {});
|
|
206
|
-
const verb = typeof params.verb === "string" ? params.verb : "";
|
|
207
|
-
const sessionId = typeof params.sessionId === "string" ? params.sessionId : "";
|
|
208
|
-
const outcome = runBudgeterCommand(this.tracker, this.opts.currency, {
|
|
209
|
-
verb,
|
|
210
|
-
sessionId,
|
|
211
|
-
});
|
|
212
|
-
if (outcome.kind === "ok") {
|
|
213
|
-
this.client.reply(r.id, { text: outcome.text });
|
|
214
|
-
}
|
|
215
|
-
else {
|
|
216
|
-
this.client.replyError(r.id, -32601, outcome.message);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
onNotification(n) {
|
|
220
|
-
if (n.method !== "transformer/session_event") {
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
const params = (n.params ?? {});
|
|
224
|
-
const event = typeof params.event === "string" ? params.event : "";
|
|
225
|
-
const sessionId = typeof params.sessionId === "string" ? params.sessionId : "";
|
|
226
|
-
if (!event || !sessionId) {
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
if (event === "session.opened") {
|
|
230
|
-
void this.router
|
|
231
|
-
.onLifecycle("session.opened", sessionId)
|
|
232
|
-
.catch((err) => log.warn(`session.opened error: ${err.message}`));
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
if (event === "session.closed") {
|
|
236
|
-
void this.router
|
|
237
|
-
.onLifecycle("session.closed", sessionId)
|
|
238
|
-
.catch((err) => log.warn(`session.closed error: ${err.message}`));
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
export function runBudgeterCommand(tracker, configuredCurrency, input) {
|
|
244
|
-
if (input.verb === "reset") {
|
|
245
|
-
tracker.reset();
|
|
246
|
-
const snap = input.sessionId
|
|
247
|
-
? tracker.snapshotFor(input.sessionId)
|
|
248
|
-
: { total: 0, currency: configuredCurrency };
|
|
249
|
-
return {
|
|
250
|
-
kind: "ok",
|
|
251
|
-
text: `hydra-acp-budgeter: spend reset (total now ${snap.total.toFixed(2)} ${snap.currency})`,
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
if (input.verb === "status") {
|
|
255
|
-
const snap = tracker.snapshotFor(input.sessionId);
|
|
256
|
-
return {
|
|
257
|
-
kind: "ok",
|
|
258
|
-
text: `hydra-acp-budgeter: total ${snap.total.toFixed(2)} ${snap.currency} ` +
|
|
259
|
-
`(this session ${snap.perSession.toFixed(2)}, soft ${snap.soft}, hard ${snap.hard}, state ${snap.state})`,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
return { kind: "error", message: `unknown verb: ${input.verb}` };
|
|
263
|
-
}
|
|
264
|
-
//# sourceMappingURL=bridge.js.map
|
|
1
|
+
import{watch as h}from"node:fs";import{basename as p,dirname as m}from"node:path";import{TransformerClient as u}from"./acp/transformer.js";import{Enforcer as l}from"./enforce.js";import{EventRouter as f}from"./router.js";import{CostTracker as g}from"./tracker.js";import{logger as v}from"./util/log.js";const o=v("bridge"),y=["response:session/update","request:session/prompt","lifecycle:session.opened","lifecycle:session.closed"];class x{constructor(e){this.opts=e;this.client=new u({daemonWsUrl:e.daemonWsUrl,token:e.token,intercepts:y}),this.tracker=new g({softLimit:e.softLimit,hardLimit:e.hardLimit,currency:e.currency,statePath:e.statePath}),this.enforcer=new l(this.client,o),this.router=new f(e.rule,this.tracker,this.enforcer,o)}opts;client;tracker;enforcer;router;watcher;watchTimer;stopped=!1;start(){this.client.on("request",e=>this.onRequest(e)),this.client.on("notification",e=>this.onNotification(e)),this.client.on("error",e=>{o.warn(`client error: ${e.message}`)}),this.client.on("open",()=>{this.registerSlashCommands()}),this.client.start(),this.startWatcher()}async registerSlashCommands(){try{await this.client.request("hydra-acp/commands/register",{commands:[{verb:"reset",description:"Reset accumulated cost baseline to current totals"},{verb:"status",description:"Show current spend vs. soft/hard limits"}]}),o.info("registered /hydra hydra-acp-budgeter {reset,status}")}catch(e){o.warn(`register_commands failed: ${e.message}`)}}stop(){this.stopped||(this.stopped=!0,this.watcher&&(this.watcher.close(),this.watcher=void 0),this.watchTimer&&(clearTimeout(this.watchTimer),this.watchTimer=void 0),this.client.stop())}startWatcher(){if(!this.opts.statePath)return;const e=m(this.opts.statePath),t=p(this.opts.statePath);try{this.watcher=h(e,(s,n)=>{n&&n!==t||this.watchTimer||(this.watchTimer=setTimeout(()=>{this.watchTimer=void 0;try{this.tracker.adoptFromDisk()}catch(r){o.warn(`adoptFromDisk failed: ${r.message}`)}},50))}),o.debug(`watching ${this.opts.statePath}`)}catch(s){o.warn(`fs.watch failed for ${e}: ${s.message}`)}}onRequest(e){if(e.method==="hydra-acp/commands/invoke"){this.handleExtensionCommand(e);return}if(e.method!=="hydra-acp/transformer/message"){this.client.reply(e.id,{action:"continue"});return}const t=e.params??{},s=typeof t.sessionId=="string"?t.sessionId:"",n=t.phase,r=t.method;if(!s||!n||!r){this.client.reply(e.id,{action:"continue"});return}if(n==="response"&&r==="session/update"){this.client.reply(e.id,{action:"continue"});const i=t.envelope,c=i&&typeof i=="object"&&!Array.isArray(i)?i.update:void 0;c&&this.router.onResponseUpdate(s,c).catch(d=>o.warn(`response update error: ${d.message}`));return}if(n==="request"&&r==="session/prompt"){this.router.onPromptRequest(s,t.envelope).then(i=>{i?this.client.reply(e.id,{action:"stop",payload:i}):this.client.reply(e.id,{action:"continue"})}).catch(i=>{o.warn(`prompt request error: ${i.message}`),this.client.reply(e.id,{action:"continue"})});return}this.client.reply(e.id,{action:"continue"})}handleExtensionCommand(e){const t=e.params??{},s=typeof t.verb=="string"?t.verb:"",n=typeof t.sessionId=="string"?t.sessionId:"",r=w(this.tracker,this.opts.currency,{verb:s,sessionId:n});r.kind==="ok"?this.client.reply(e.id,{text:r.text}):this.client.replyError(e.id,-32601,r.message)}onNotification(e){if(e.method!=="hydra-acp/transformer/session_event")return;const t=e.params??{},s=typeof t.event=="string"?t.event:"",n=typeof t.sessionId=="string"?t.sessionId:"";if(!(!s||!n)){if(s==="session.opened"){this.router.onLifecycle("session.opened",n).catch(r=>o.warn(`session.opened error: ${r.message}`));return}if(s==="session.closed"){this.router.onLifecycle("session.closed",n).catch(r=>o.warn(`session.closed error: ${r.message}`));return}}}}function w(a,e,t){if(t.verb==="reset"){a.reset();const s=t.sessionId?a.snapshotFor(t.sessionId):{total:0,currency:e};return{kind:"ok",text:`hydra-acp-budgeter: spend reset (total now ${s.total.toFixed(2)} ${s.currency})`}}if(t.verb==="status"){const s=a.snapshotFor(t.sessionId);return{kind:"ok",text:`hydra-acp-budgeter: total ${s.total.toFixed(2)} ${s.currency} (this session ${s.perSession.toFixed(2)}, soft ${s.soft}, hard ${s.hard}, state ${s.state})`}}return{kind:"error",message:`unknown verb: ${t.verb}`}}export{x as BudgeterBridge,w as runBudgeterCommand};
|
package/dist/config.js
CHANGED
|
@@ -1,99 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
const PRIMARY_CONF_PATH = resolve(homedir(), ".hydra-acp", "budgeter.conf");
|
|
5
|
-
export function configPath() {
|
|
6
|
-
const override = process.env.HYDRA_ACP_BUDGETER_CONF;
|
|
7
|
-
if (override) {
|
|
8
|
-
return override;
|
|
9
|
-
}
|
|
10
|
-
return PRIMARY_CONF_PATH;
|
|
11
|
-
}
|
|
12
|
-
function parseEnvFile(text) {
|
|
13
|
-
const out = new Map();
|
|
14
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
15
|
-
const line = rawLine.trim();
|
|
16
|
-
if (!line || line.startsWith("#")) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
const eq = line.indexOf("=");
|
|
20
|
-
if (eq === -1) {
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
const key = line.slice(0, eq).trim();
|
|
24
|
-
let val = line.slice(eq + 1).trim();
|
|
25
|
-
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
26
|
-
(val.startsWith("'") && val.endsWith("'"))) {
|
|
27
|
-
val = val.slice(1, -1);
|
|
28
|
-
}
|
|
29
|
-
out.set(key, val);
|
|
30
|
-
}
|
|
31
|
-
return out;
|
|
32
|
-
}
|
|
33
|
-
function deriveWsUrl(httpUrl) {
|
|
34
|
-
if (httpUrl.startsWith("https://")) {
|
|
35
|
-
return "wss://" + httpUrl.slice("https://".length).replace(/\/$/, "") + "/acp";
|
|
36
|
-
}
|
|
37
|
-
if (httpUrl.startsWith("http://")) {
|
|
38
|
-
return "ws://" + httpUrl.slice("http://".length).replace(/\/$/, "") + "/acp";
|
|
39
|
-
}
|
|
40
|
-
throw new Error(`hydraDaemonUrl must start with http:// or https://: ${httpUrl}`);
|
|
41
|
-
}
|
|
42
|
-
function floatVal(map, envName, key, fallback) {
|
|
43
|
-
const v = process.env[envName] ?? map.get(key);
|
|
44
|
-
if (v === undefined || v === "") {
|
|
45
|
-
return fallback;
|
|
46
|
-
}
|
|
47
|
-
const n = Number.parseFloat(v);
|
|
48
|
-
return Number.isFinite(n) ? n : fallback;
|
|
49
|
-
}
|
|
50
|
-
const TRUTHY = new Set(["1", "true", "yes", "on", "t"]);
|
|
51
|
-
function boolVal(map, envName, key, fallback) {
|
|
52
|
-
const v = process.env[envName] ?? map.get(key);
|
|
53
|
-
if (v === undefined) {
|
|
54
|
-
return fallback;
|
|
55
|
-
}
|
|
56
|
-
return TRUTHY.has(v.toLowerCase());
|
|
57
|
-
}
|
|
58
|
-
function strVal(map, envName, key, fallback) {
|
|
59
|
-
return process.env[envName] ?? map.get(key) ?? fallback;
|
|
60
|
-
}
|
|
61
|
-
export function loadConfig(path = configPath()) {
|
|
62
|
-
let map = new Map();
|
|
63
|
-
if (existsSync(path)) {
|
|
64
|
-
try {
|
|
65
|
-
map = parseEnvFile(readFileSync(path, "utf8"));
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
// Non-fatal — log and continue with env vars / defaults.
|
|
69
|
-
process.stderr.write(`hydra-acp-budgeter: warning: could not read ${path}: ${err.message}\n`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// Hydra-injected connection vars: env always wins, conf file is fallback.
|
|
73
|
-
const hydraDaemonUrl = process.env.HYDRA_ACP_DAEMON_URL ??
|
|
74
|
-
map.get("HYDRA_DAEMON_URL") ??
|
|
75
|
-
"http://127.0.0.1:8765";
|
|
76
|
-
const hydraToken = process.env.HYDRA_ACP_TOKEN ?? map.get("HYDRA_TOKEN") ?? "";
|
|
77
|
-
if (!hydraToken) {
|
|
78
|
-
throw new Error("Missing HYDRA_ACP_TOKEN env var (or HYDRA_TOKEN in budgeter.conf). " +
|
|
79
|
-
"When run as a hydra transformer, hydra injects this automatically.");
|
|
80
|
-
}
|
|
81
|
-
const hydraWsUrl = process.env.HYDRA_ACP_WS_URL ??
|
|
82
|
-
map.get("HYDRA_WS_URL") ??
|
|
83
|
-
deriveWsUrl(hydraDaemonUrl);
|
|
84
|
-
const softLimit = floatVal(map, "HYDRA_ACP_BUDGETER_SOFT", "SOFT", 5);
|
|
85
|
-
const hardLimit = floatVal(map, "HYDRA_ACP_BUDGETER_HARD", "HARD", 10);
|
|
86
|
-
if (hardLimit < softLimit) {
|
|
87
|
-
throw new Error(`HARD (${hardLimit}) must be >= SOFT (${softLimit})`);
|
|
88
|
-
}
|
|
89
|
-
return {
|
|
90
|
-
hydraDaemonUrl,
|
|
91
|
-
hydraWsUrl,
|
|
92
|
-
hydraToken,
|
|
93
|
-
softLimit,
|
|
94
|
-
hardLimit,
|
|
95
|
-
currency: strVal(map, "HYDRA_ACP_BUDGETER_CURRENCY", "CURRENCY", "USD"),
|
|
96
|
-
debug: boolVal(map, "DEBUG", "DEBUG", false),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
//# sourceMappingURL=config.js.map
|
|
1
|
+
import{existsSync as g,readFileSync as u}from"node:fs";import{homedir as f}from"node:os";import{resolve as d}from"node:path";const h=d(f(),".hydra-acp","budgeter.conf");function l(){const r=process.env.HYDRA_ACP_BUDGETER_CONF;return r||h}function p(r){const t=new Map;for(const i of r.split(/\r?\n/)){const n=i.trim();if(!n||n.startsWith("#"))continue;const e=n.indexOf("=");if(e===-1)continue;const o=n.slice(0,e).trim();let s=n.slice(e+1).trim();(s.startsWith('"')&&s.endsWith('"')||s.startsWith("'")&&s.endsWith("'"))&&(s=s.slice(1,-1)),t.set(o,s)}return t}function R(r){if(r.startsWith("https://"))return"wss://"+r.slice(8).replace(/\/$/,"")+"/acp";if(r.startsWith("http://"))return"ws://"+r.slice(7).replace(/\/$/,"")+"/acp";throw new Error(`hydraDaemonUrl must start with http:// or https://: ${r}`)}function a(r,t,i,n){const e=process.env[t]??r.get(i);if(e===void 0||e==="")return n;const o=Number.parseFloat(e);return Number.isFinite(o)?o:n}const _=new Set(["1","true","yes","on","t"]);function m(r,t,i,n){const e=process.env[t]??r.get(i);return e===void 0?n:_.has(e.toLowerCase())}function A(r,t,i,n){return process.env[t]??r.get(i)??n}function C(r=l()){let t=new Map;if(g(r))try{t=p(u(r,"utf8"))}catch(c){process.stderr.write(`hydra-acp-budgeter: warning: could not read ${r}: ${c.message}
|
|
2
|
+
`)}const i=process.env.HYDRA_ACP_DAEMON_URL??t.get("HYDRA_DAEMON_URL")??"http://127.0.0.1:8765",n=process.env.HYDRA_ACP_TOKEN??t.get("HYDRA_TOKEN")??"";if(!n)throw new Error("Missing HYDRA_ACP_TOKEN env var (or HYDRA_TOKEN in budgeter.conf). When run as a hydra transformer, hydra injects this automatically.");const e=process.env.HYDRA_ACP_WS_URL??t.get("HYDRA_WS_URL")??R(i),o=a(t,"HYDRA_ACP_BUDGETER_SOFT","SOFT",5),s=a(t,"HYDRA_ACP_BUDGETER_HARD","HARD",10);if(s<o)throw new Error(`HARD (${s}) must be >= SOFT (${o})`);return{hydraDaemonUrl:i,hydraWsUrl:e,hydraToken:n,softLimit:o,hardLimit:s,currency:A(t,"HYDRA_ACP_BUDGETER_CURRENCY","CURRENCY","USD"),debug:m(t,"DEBUG","DEBUG",!1)}}export{l as configPath,C as loadConfig};
|