@adhd/agent-plugin-budget 0.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,65 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@adhd/agent-mcp-budget`. Format based on
4
+ [Keep a Changelog](https://keepachangelog.com/); this project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ---
8
+
9
+ ## [0.0.2] — 2026-06-17
10
+
11
+ ### Changed
12
+
13
+ - **Package relocated from `packages/node-tools/` to `packages/ai/`** via
14
+ `nx g @nx/workspace:move`. Import path `@adhd/agent-mcp-budget` and all
15
+ runtime behaviour are unchanged. The move aligns this package with the rest of
16
+ the `@adhd/agent-mcp-*` plugin family under `packages/ai/`.
17
+ - **Import of `HookRegistry` changed from `@adhd/agent-mcp` to `@adhd/agent-mcp-types`**
18
+ in tests. The class was relocated to `agent-mcp-types` to eliminate a circular
19
+ Nx build dependency (`agent-mcp:build → agent-mcp-budget:build → agent-mcp:build`).
20
+ No change to production code — plugins depend on `@adhd/agent-mcp-types` as a peer.
21
+
22
+ ### Fixed
23
+
24
+ - **`vite.config.ts` now sets `emptyOutDir: true`.** Without this, Vite does not clear
25
+ `dist/` between builds — the old `dist/package.json` (containing the previous version
26
+ number) would survive a version bump and the wrong version would be published.
27
+
28
+ ---
29
+
30
+ ## [0.0.1] — 2026-06-16
31
+
32
+ ### Added
33
+
34
+ - Initial release. Budget enforcement plugin for `@adhd/agent-mcp`.
35
+ - Registers a `pre:model_request` **enforcement** handler via
36
+ `IHookRegistry.registerEnforcement()` — throws `IEnforcementError` when any
37
+ configured limit is breached, aborting the LLM call before it is made.
38
+ - Registers a `post:model_response` **observational** handler that accumulates
39
+ model call count, token totals, and elapsed time.
40
+ - Configurable limits (all optional; omit to leave unbounded):
41
+ - `maxModelCalls` — maximum number of LLM calls per task
42
+ - `maxTotalTokens` — combined input + output token cap
43
+ - `maxInputTokens` — input token cap
44
+ - `maxOutputTokens` — output token cap
45
+ - `maxWallClockMs` — wall-clock duration cap (from task start)
46
+ - `maxModelMs` — cumulative model latency cap
47
+ - `maxCostUSD` — cost cap (requires `inputPricePerMToken` + `outputPricePerMToken`
48
+ in config)
49
+ - Exports `configSchema` (Zod `z.object(...)`) — the server validates the plugin's
50
+ `config` block in `agent-mcp.config.json` against this schema before calling the
51
+ factory. Validation failure skips the plugin and logs a structured error; the server
52
+ continues without it.
53
+ - Exports `createPlugin` as both default and named export (factory signature:
54
+ `(ctx: PluginContext) => Plugin`).
55
+ - Activated by adding the plugin to `agent-mcp.config.json`:
56
+ ```json
57
+ {
58
+ "plugins": [
59
+ {
60
+ "module": "/abs/path/to/dist/packages/ai/agent-mcp-budget/index.js",
61
+ "config": { "maxModelCalls": 5, "maxTotalTokens": 50000 }
62
+ }
63
+ ]
64
+ }
65
+ ```
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # @adhd/agent-plugin-budget
2
+
3
+ Enforcement plugin for `@adhd/agent-mcp`. Caps token spend, model calls, wall-clock time, and tool usage per task, session, agent, provider, or globally — with configurable time windows and warning/block modes.
4
+
5
+ ## Config
6
+
7
+ Place at `.adhd/agent-mcp/config.json` (auto-discovered by the plugin loader):
8
+
9
+ ```json
10
+ {
11
+ "plugins": [
12
+ {
13
+ "module": "@adhd/agent-plugin-budget",
14
+ "config": {
15
+ "defaults": {
16
+ "caps": [
17
+ { "field": "tokens", "maximum": 50000 },
18
+ { "field": "calls", "maximum": 8 },
19
+ { "field": "wallClock", "maximum": 120000 }
20
+ ]
21
+ }
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ ```
27
+
28
+ ## Caps
29
+
30
+ Each cap defines one limit. Fields:
31
+
32
+ | Field | Unit | Scope | Description |
33
+ | -------------- | ----- | --------- | ----------------------------------------------------------------------------------------------------------------------- |
34
+ | `tokens` | count | all | Sum of input + output + cache tokens |
35
+ | `inputTokens` | count | all | Input tokens only |
36
+ | `outputTokens` | count | all | Output tokens only |
37
+ | `calls` | count | all | Number of LLM model calls |
38
+ | `wallClock` | ms | task only | Wall-clock time from task start |
39
+ | `modelMs` | ms | task only | Cumulative model response time |
40
+ | `cost` | USD | all | Estimated cost (requires `costPerInputToken` / `costPerOutputToken`) |
41
+ | `toolCalls` | count | tool only | Per-tool call count |
42
+ | `responseSize` | chars | tool only | Raw response character length. Enforced at `transform:tool_result` — truncates (warning) or replaces with error (block) |
43
+
44
+ ### Custom message
45
+
46
+ Each cap supports an optional `message` that overrides the auto-generated enforcement message. Use it to give the agent actionable guidance:
47
+
48
+ ```json
49
+ { "field": "toolCalls", "maximum": 5, "mode": "block", "message": "Search is expensive. Use the index: query__search first" }
50
+ ```
51
+
52
+ ### Scopes
53
+
54
+ | Scope | Coverage | DB query |
55
+ | --------- | ----------------------------------------------------- | -------------------------------------------- |
56
+ | `task` | Current task in-memory only | None |
57
+ | `session` | Current task + all historical tasks in session | `task_usage` JOIN `tasks` WHERE `session_id` |
58
+ | `agent` | Current task + all historical tasks for this agent | `task_usage` WHERE `agent_name` |
59
+ | `global` | Current task + ALL historical tasks across all agents | `task_usage` all rows |
60
+
61
+ Scope is inherited from the dimension's `scope` field if not set on the individual cap:
62
+
63
+ ```json
64
+ {
65
+ "defaults": {
66
+ "scope": "agent",
67
+ "caps": [
68
+ { "field": "tokens", "maximum": 50000 },
69
+ { "field": "calls", "maximum": 8 }
70
+ ]
71
+ }
72
+ }
73
+ ```
74
+
75
+ ### Time windows
76
+
77
+ Use ISO8601 durations for rolling window caps:
78
+
79
+ ```json
80
+ { "field": "tokens", "maximum": 200000, "window": "PT24H", "scope": "agent" }
81
+ ```
82
+
83
+ Supported formats: `PT24H`, `PT1H30M`, `P1DT6H`, `PT30M`, etc.
84
+
85
+ When a cap has both `scope` and `window`, the window query provides the historical total (scope-filtered, within the window) — no double-counting.
86
+
87
+ ### Tool-level caps
88
+
89
+ Tool caps enforce at `pre:tool_call` (toolCalls) or `transform:tool_result` (responseSize):
90
+
91
+ ```json
92
+ {
93
+ "tool": {
94
+ "default": { "caps": [{ "field": "toolCalls", "maximum": 100 }], "mode": "warning" },
95
+ "overrides": {
96
+ "web_search": {
97
+ "caps": [{ "field": "toolCalls", "maximum": 20 }],
98
+ "mode": "block"
99
+ },
100
+ "filesystem__read_text_file": {
101
+ "caps": [
102
+ { "field": "responseSize", "maximum": 500, "mode": "warning", "message": "File too large. Use read_text_file with head:100 to preview, search_files for pattern matching, or read_multiple_files to batch-read small files" },
103
+ { "field": "toolCalls", "maximum": 100, "mode": "warning", "message": "Too many file reads. Use search_files or get_file_info first" }
104
+ ]
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ - `toolCalls` field — enforced at `pre:tool_call`
112
+ - `warning` — tool call is blocked, agent receives a diagnostic, task continues
113
+ - `block` — tool call is blocked, task fails with `BUDGET_EXCEEDED`
114
+ - `responseSize` field — enforced at `transform:tool_result` (after the tool runs)
115
+ - `warning` — tool result is **truncated** to `maximum` chars, task continues
116
+ - `block` — tool result is replaced with an error message (task continues, agent sees the error)
117
+
118
+ ## Dimensions
119
+
120
+ Caps are merged from three dimensions in this order:
121
+ `defaults ← agent ← provider`
122
+
123
+ ```json
124
+ {
125
+ "defaults": {
126
+ "caps": [{ "field": "calls", "maximum": 8 }]
127
+ },
128
+ "agent": {
129
+ "default": { "caps": [{ "field": "calls", "maximum": 5 }] },
130
+ "overrides": {
131
+ "cheap-agent": {
132
+ "caps": [{ "field": "calls", "maximum": 1, "message": "Cheap agents get 1 call max" }]
133
+ }
134
+ }
135
+ },
136
+ "provider": {
137
+ "default": { "caps": [{ "field": "cost", "maximum": 0.1 }] },
138
+ "overrides": {
139
+ "anthropic": { "caps": [{ "field": "cost", "maximum": 0.5 }] }
140
+ }
141
+ },
142
+ "tool": {
143
+ "default": { "caps": [{ "field": "toolCalls", "maximum": 100 }], "mode": "warning" },
144
+ "overrides": {
145
+ "web_search": {
146
+ "caps": [{ "field": "toolCalls", "maximum": 20, "message": "Consider caching search results" }],
147
+ "mode": "block"
148
+ },
149
+ "filesystem__read_file": {
150
+ "caps": [{ "field": "responseSize", "maximum": 500, "mode": "warning", "message": "Raw file content is too large. Use shell tools instead: head -n 100, wc -l, grep for patterns" }]
151
+ }
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ Caps are **additive** across dimensions — multiple caps targeting the same field all apply. For example, `defaults` and `agent.overrides` can both cap `calls`; the stricter bound wins by throwing first.
158
+
159
+ ## Cost estimation
160
+
161
+ For the `cost` field, set token prices on the defaults dimension:
162
+
163
+ ```json
164
+ {
165
+ "defaults": {
166
+ "costPerInputToken": 0.000003,
167
+ "costPerOutputToken": 0.000015,
168
+ "caps": [{ "field": "cost", "maximum": 0.1 }]
169
+ }
170
+ }
171
+ ```
172
+
173
+ Cost = `inputTokens × costPerInputToken + outputTokens × costPerOutputToken`.
174
+
175
+ ## Backward compat (flat format)
176
+
177
+ The old flat field format is supported via automatic conversion:
178
+
179
+ ```json
180
+ {
181
+ "config": {
182
+ "maxTotalTokens": 50000,
183
+ "maxModelCalls": 8,
184
+ "maxWallClockMs": 120000,
185
+ "scope": "agent"
186
+ }
187
+ }
188
+ ```
189
+
190
+ Converts internally to:
191
+
192
+ ```json
193
+ {
194
+ "caps": [
195
+ { "field": "tokens", "maximum": 50000 },
196
+ { "field": "calls", "maximum": 8 },
197
+ { "field": "wallClock", "maximum": 120000 }
198
+ ],
199
+ "scope": "agent"
200
+ }
201
+ ```
202
+
203
+ ### Custom messages in flat format
204
+
205
+ The `message` key is also passed through in flat format — it applies to all caps in the dimension:
206
+
207
+ ```json
208
+ {
209
+ "config": {
210
+ "maxTotalTokens": 50000,
211
+ "maxModelCalls": 8,
212
+ "mode": "warning",
213
+ "message": "Budget limit hit"
214
+ }
215
+ }
216
+ ```
217
+
218
+ ## Performance
219
+
220
+ Enforcement makes exactly `U + W` DB queries per event where:
221
+
222
+ - `U` = number of unique non-task scopes across all caps (0..3)
223
+ - `W` = number of unique (scope, window) pairs across all caps
224
+
225
+ Zero DB queries when no caps are configured. No per-cap scaling.
226
+
227
+ ## Testing
228
+
229
+ ```bash
230
+ nx test agent-plugin-budget
231
+ ```
package/index.cjs ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const p=require("zod");function E(f){const e=/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/,t=f.match(e);if(!t)throw new Error(`invalid ISO 8601 duration: ${f}`);const[,o,s,i,n,a,c]=t;let r=0;return o&&(r+=parseInt(o)*365.25*864e5),s&&(r+=parseInt(s)*30.44*864e5),i&&(r+=parseInt(i)*864e5),n&&(r+=parseInt(n)*36e5),a&&(r+=parseInt(a)*6e4),c&&(r+=parseFloat(c)*1e3),Math.round(r)}const w=["tokens","inputTokens","outputTokens","calls","wallClock","modelMs","cost","toolCalls","responseSize"],b=p.z.object({field:p.z.enum(w),maximum:p.z.number().min(0),window:p.z.string().optional(),scope:p.z.enum(["task","session","agent","global"]).optional(),mode:p.z.enum(["warning","block"]).optional(),message:p.z.string().optional()}),g=p.z.object({caps:p.z.array(b).optional(),mode:p.z.enum(["warning","block"]).optional(),costPerInputToken:p.z.number().min(0).optional(),costPerOutputToken:p.z.number().min(0).optional(),scope:p.z.enum(["task","session","agent","global"]).optional()}),T=p.z.object({defaults:g.optional(),agent:p.z.object({default:g.optional(),overrides:p.z.record(p.z.string(),g.partial()).optional().default({})}).optional(),provider:p.z.object({default:g.optional(),overrides:p.z.record(p.z.string(),g.partial()).optional().default({})}).optional(),tool:p.z.object({default:g.optional(),overrides:p.z.record(p.z.string(),g.partial()).optional().default({})}).optional()}),x=p.z.object({}).passthrough(),_={maxInputTokens:{field:"inputTokens"},maxOutputTokens:{field:"outputTokens"},maxTotalTokens:{field:"tokens"},maxModelCalls:{field:"calls"},maxWallClockMs:{field:"wallClock"},maxModelMs:{field:"modelMs"},maxCostUSD:{field:"cost"},maxTokensPer24h:{field:"tokens",window:"PT24H"},maxCalls:{field:"toolCalls"}};function v(f){const e=[],t={};for(const[o,s]of Object.entries(f)){const i=_[o];if(i&&typeof s=="number"){const n={field:i.field,maximum:s};i.window&&(n.window=i.window),e.push(n)}else(o==="scope"||o==="mode"||o==="costPerInputToken"||o==="costPerOutputToken"||o==="message")&&(t[o]=s)}return e.length>0&&(t.caps=e),g.parse(t)}function O(f){const e=f;if(e.defaults!==void 0||e.agent!==void 0||e.provider!==void 0||e.tool!==void 0){const t=T.parse(f);return{defaults:t.defaults??g.parse({}),agent:t.agent??{overrides:{}},provider:t.provider??{overrides:{}},tool:t.tool??{overrides:{}}}}return{defaults:v(e),agent:{overrides:{}},provider:{overrides:{}},tool:{overrides:{}}}}function S(f,e,t,o){return{isEnforcementError:!0,code:"BUDGET_EXCEEDED",message:o??`${f} limit is ${e}, current value is ${Math.round(t)}`}}function A(f,e,t){return{isToolWarning:!0,toolName:f,callId:e,message:t}}class y{constructor(e,t,o=0,s=0){this.db=e,this.cfg=t,this.costPerInput=o,this.costPerOutput=s,this.name="agent-mcp-budget",this.accumulators=new Map}install(e){e.register("task:start",t=>{try{this.onTaskStart(t)}catch{}}),e.register("pre:model_request",t=>{try{this.onPreModelRequest(t)}catch{}}),e.register("post:model_response",t=>{try{this.onPostModelResponse(t)}catch{}}),e.register("task:completed",t=>{try{this.onTerminal(t.executionContext.taskId)}catch{}}),e.register("task:failed",t=>{try{this.onTerminal(t.executionContext.taskId)}catch{}}),e.register("task:cancelled",t=>{try{this.onTerminal(t.executionContext.taskId)}catch{}}),e.registerEnforcement("pre:model_request",t=>this.enforcePreModel(t)),e.registerEnforcement("pre:tool_call",t=>this.enforcePreTool(t)),e.register("transform:tool_result",t=>{try{this.enforceResponseSize(t)}catch{}})}onTaskStart(e){var n,a;const{taskId:t,sessionId:o,agentName:s}=e.executionContext,i=((a=(n=e.executionContext.agentDefinition)==null?void 0:n.provider)==null?void 0:a.type)??"unknown";this.accumulators.set(t,{taskId:t,sessionId:o??void 0,agentName:s,providerType:i,startedAtMs:Date.now(),inputTokens:0,outputTokens:0,modelCalls:0,totalModelMs:0,toolCalls:new Map})}onPreModelRequest(e){const t=this.accumulators.get(e.executionContext.taskId);t&&(t.modelCallStartMs=Date.now())}onPostModelResponse(e){const t=this.accumulators.get(e.executionContext.taskId);if(!t)return;const o=e.tokenUsage;o&&(t.inputTokens+=o.inputTokens??0,t.outputTokens+=o.outputTokens??0),t.modelCalls+=1,t.modelCallStartMs!==void 0&&(t.totalModelMs+=Date.now()-t.modelCallStartMs,t.modelCallStartMs=void 0)}onTerminal(e){this.accumulators.delete(e)}mergeDim(e){let t={caps:[]};for(const o of e)o&&(t={caps:[...t.caps??[],...o.caps??[]],mode:o.mode??t.mode,costPerInputToken:o.costPerInputToken??t.costPerInputToken,costPerOutputToken:o.costPerOutputToken??t.costPerOutputToken,scope:o.scope??t.scope});return t}resolveCaps(e,t,o){var u,m,d;const s=this.cfg.defaults,i=this.cfg.agent,n=this.cfg.provider,a=this.cfg.tool;if(o){const k=(u=a==null?void 0:a.overrides)==null?void 0:u[o],h=this.mergeDim([s,a==null?void 0:a.default,k]);return{caps:h.caps??[],mode:h.mode,scope:h.scope}}const c=(m=i==null?void 0:i.overrides)==null?void 0:m[e],r=(d=n==null?void 0:n.overrides)==null?void 0:d[t],l=this.mergeDim([s,i==null?void 0:i.default,c,n==null?void 0:n.default,r]);return{caps:l.caps??[],mode:l.mode,scope:l.scope}}queryScopeTotals(e,t,o,s){const i=this.accumulators.get(e),n=i?{inputTokens:i.inputTokens,outputTokens:i.outputTokens,modelCalls:i.modelCalls}:{inputTokens:0,outputTokens:0,modelCalls:0};if(s==="task"||!this.db)return n;try{const a=this.db;let c;if(s==="session"&&t?c=a.prepare(`SELECT
2
+ COALESCE(SUM(tu.input_tokens), 0) AS input,
3
+ COALESCE(SUM(tu.output_tokens), 0) AS output,
4
+ COALESCE(SUM(tu.model_calls), 0) AS calls
5
+ FROM task_usage tu
6
+ JOIN tasks t ON tu.task_id = t.id
7
+ WHERE t.session_id = ? AND tu.task_id != ?`).get(t,e):s==="agent"?c=a.prepare(`SELECT
8
+ COALESCE(SUM(input_tokens), 0) AS input,
9
+ COALESCE(SUM(output_tokens), 0) AS output,
10
+ COALESCE(SUM(model_calls), 0) AS calls
11
+ FROM task_usage
12
+ WHERE agent_name = ? AND task_id != ?`).get(o,e):s==="global"&&(c=a.prepare(`SELECT
13
+ COALESCE(SUM(input_tokens), 0) AS input,
14
+ COALESCE(SUM(output_tokens), 0) AS output,
15
+ COALESCE(SUM(model_calls), 0) AS calls
16
+ FROM task_usage
17
+ WHERE task_id != ?`).get(e)),c)return{inputTokens:(c.input??0)+n.inputTokens,outputTokens:(c.output??0)+n.outputTokens,modelCalls:(c.calls??0)+n.modelCalls}}catch{}return n}queryWindowTokens(e,t,o,s){if(!this.db)return 0;try{const i=this.db,n=new Date(Date.now()-o).toISOString();let a;if(e==="session"){const c=s?" AND tu.task_id != ?":"",r=[t,n];s&&r.push(s),a=i.prepare(`SELECT COALESCE(SUM(tu.input_tokens + tu.output_tokens), 0) AS total
18
+ FROM task_usage tu
19
+ JOIN tasks t ON tu.task_id = t.id
20
+ WHERE t.session_id = ? AND tu.created_at >= ?${c}`).get(...r)}else if(e==="agent"){const c=s?" AND task_id != ?":"",r=[t,n];s&&r.push(s),a=i.prepare(`SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
21
+ FROM task_usage
22
+ WHERE agent_name = ? AND created_at >= ?${c}`).get(...r)}else if(e==="global"){const c=s?" AND task_id != ?":"",r=[n];s&&r.push(s),a=i.prepare(`SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
23
+ FROM task_usage
24
+ WHERE created_at >= ?${c}`).get(...r)}return(a==null?void 0:a.total)??0}catch{return 0}}buildSnapshot(e,t,o,s,i,n){const a={};a.inputTokens=t.inputTokens,a.outputTokens=t.outputTokens,a.calls=t.modelCalls,a.wallClock=Date.now()-t.startedAtMs,a.modelMs=t.totalModelMs,a.cost=t.inputTokens*this.costPerInput+t.outputTokens*this.costPerOutput;const c=new Set,r=new Map;for(const l of e){const u=l.scope??n??"task";if(u!=="task"&&c.add(u),l.window){const m=`${u}:${l.window}`;r.has(m)||r.set(m,{scope:u,windowMs:E(l.window)})}}for(const l of c){const u=this.queryScopeTotals(o,s,i,l);a[`${l}:inputTokens`]=u.inputTokens,a[`${l}:outputTokens`]=u.outputTokens,a[`${l}:calls`]=u.modelCalls}for(const[l,{scope:u,windowMs:m}]of r){let d="";u==="session"?d=s??"":u==="agent"&&(d=i??""),a[l]=this.queryWindowTokens(u,d,m,o)}return a}getSnapshotValue(e,t,o){const s=t.scope??o??"task";let i;t.window?i="":i=s!=="task"?`${s}:`:"";let n;switch(t.field){case"inputTokens":n=e[`${i}inputTokens`]??e.inputTokens;break;case"outputTokens":n=e[`${i}outputTokens`]??e.outputTokens;break;case"tokens":n=(e[`${i}inputTokens`]??e.inputTokens)+(e[`${i}outputTokens`]??e.outputTokens);break;case"calls":n=e[`${i}calls`]??e.calls;break;case"wallClock":n=e.wallClock;break;case"modelMs":n=e.modelMs;break;case"cost":n=e.cost;break;case"toolCalls":n=0;break;default:n=0}return t.window&&(n+=e[`${s}:${t.window}`]??0),n}evaluateCap(e,t,o){const s=this.getSnapshotValue(t,e,o);if(s>=e.maximum)throw S(e.field,e.maximum,s,e.message)}enforcePreModel(e){var u,m;const{taskId:t,sessionId:o,agentName:s}=e.executionContext,i=((m=(u=e.executionContext.agentDefinition)==null?void 0:u.provider)==null?void 0:m.type)??"unknown",n=this.accumulators.get(t);if(!n)return;const{caps:a,scope:c}=this.resolveCaps(s,i),r=a.filter(d=>d.field!=="toolCalls");if(r.length===0)return;const l=this.buildSnapshot(r,n,t,o,s,c);for(const d of r)this.evaluateCap(d,l)}enforcePreTool(e){const{toolName:t,callId:o,executionContext:s}=e,{caps:i,mode:n,scope:a}=this.resolveCaps(s.agentName,"",t),c=this.accumulators.get(s.taskId);if(!c||i.length===0)return;const r=c.toolCalls.get(t)??0,l=this.buildSnapshot(i,c,s.taskId,s.sessionId,s.agentName,a);for(const u of i){const m=u.field==="toolCalls"?r:this.getSnapshotValue(l,u,a);if(m>=u.maximum){const d=u.message??`tool "${t}": ${u.field} limit is ${u.maximum}, current value is ${Math.round(m)}`;throw(u.mode??n??"warning")==="warning"?A(t,o,d):S(`tool:${t}:${u.field}`,u.maximum,m,u.message)}}c.toolCalls.set(t,r+1)}enforceResponseSize(e){const{toolName:t,result:o}=e;if(typeof o!="object"||o===null)return;const{caps:s,mode:i}=this.resolveCaps("","",t),n=s.filter(l=>l.field==="responseSize");if(n.length===0)return;const a=o,c=a.content;if(!Array.isArray(c))return;let r=0;for(const l of c)if(typeof l=="object"&&l!==null){const u=l;u.type==="text"&&(r+=(u.text??"").length)}for(const l of n){if(r<=l.maximum)continue;if((l.mode??i??"warning")==="block")a.content=[{type:"text",text:l.message??`Response size (${r} chars) exceeds limit of ${l.maximum}. Use offset/limit or shell paging tools instead.`}],e.isError=!0;else{let m=l.maximum;const d=[];for(const k of c){if(typeof k!="object"||k===null){d.push(k);continue}const h=k;if(h.type!=="text"){d.push(k);continue}const C=h.text??"";if(C.length<=m)d.push(k),m-=C.length;else{d.push({type:"text",text:C.slice(0,m)});break}}d.push({type:"text",text:`
25
+
26
+ [truncated: response was ${r} chars, limited to ${l.maximum}. ${l.message??"Use offset/limit or shell paging tools for full content."}]`}),a.content=d}break}}}const M=({db:f,config:e})=>{var i,n;const t=O(e),o=((i=t.defaults)==null?void 0:i.costPerInputToken)??0,s=((n=t.defaults)==null?void 0:n.costPerOutputToken)??0;return new y(f,t,o,s)};exports.configSchema=x;exports.createPlugin=M;exports.default=M;exports.pluginConfigSchema=T;
package/index.d.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { PluginFactory } from '@adhd/agent-base-types';
2
+ import { z } from 'zod';
3
+
4
+ declare const capSchema: z.ZodObject<{
5
+ field: z.ZodEnum<{
6
+ tokens: "tokens";
7
+ inputTokens: "inputTokens";
8
+ outputTokens: "outputTokens";
9
+ calls: "calls";
10
+ wallClock: "wallClock";
11
+ modelMs: "modelMs";
12
+ cost: "cost";
13
+ toolCalls: "toolCalls";
14
+ responseSize: "responseSize";
15
+ }>;
16
+ maximum: z.ZodNumber;
17
+ window: z.ZodOptional<z.ZodString>;
18
+ scope: z.ZodOptional<z.ZodEnum<{
19
+ task: "task";
20
+ session: "session";
21
+ agent: "agent";
22
+ global: "global";
23
+ }>>;
24
+ mode: z.ZodOptional<z.ZodEnum<{
25
+ warning: "warning";
26
+ block: "block";
27
+ }>>;
28
+ message: z.ZodOptional<z.ZodString>;
29
+ }, z.core.$strip>;
30
+ export type Cap = z.infer<typeof capSchema>;
31
+ export declare const pluginConfigSchema: z.ZodObject<{
32
+ defaults: z.ZodOptional<z.ZodObject<{
33
+ caps: z.ZodOptional<z.ZodArray<z.ZodObject<{
34
+ field: z.ZodEnum<{
35
+ tokens: "tokens";
36
+ inputTokens: "inputTokens";
37
+ outputTokens: "outputTokens";
38
+ calls: "calls";
39
+ wallClock: "wallClock";
40
+ modelMs: "modelMs";
41
+ cost: "cost";
42
+ toolCalls: "toolCalls";
43
+ responseSize: "responseSize";
44
+ }>;
45
+ maximum: z.ZodNumber;
46
+ window: z.ZodOptional<z.ZodString>;
47
+ scope: z.ZodOptional<z.ZodEnum<{
48
+ task: "task";
49
+ session: "session";
50
+ agent: "agent";
51
+ global: "global";
52
+ }>>;
53
+ mode: z.ZodOptional<z.ZodEnum<{
54
+ warning: "warning";
55
+ block: "block";
56
+ }>>;
57
+ message: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strip>>>;
59
+ mode: z.ZodOptional<z.ZodEnum<{
60
+ warning: "warning";
61
+ block: "block";
62
+ }>>;
63
+ costPerInputToken: z.ZodOptional<z.ZodNumber>;
64
+ costPerOutputToken: z.ZodOptional<z.ZodNumber>;
65
+ scope: z.ZodOptional<z.ZodEnum<{
66
+ task: "task";
67
+ session: "session";
68
+ agent: "agent";
69
+ global: "global";
70
+ }>>;
71
+ }, z.core.$strip>>;
72
+ agent: z.ZodOptional<z.ZodObject<{
73
+ default: z.ZodOptional<z.ZodObject<{
74
+ caps: z.ZodOptional<z.ZodArray<z.ZodObject<{
75
+ field: z.ZodEnum<{
76
+ tokens: "tokens";
77
+ inputTokens: "inputTokens";
78
+ outputTokens: "outputTokens";
79
+ calls: "calls";
80
+ wallClock: "wallClock";
81
+ modelMs: "modelMs";
82
+ cost: "cost";
83
+ toolCalls: "toolCalls";
84
+ responseSize: "responseSize";
85
+ }>;
86
+ maximum: z.ZodNumber;
87
+ window: z.ZodOptional<z.ZodString>;
88
+ scope: z.ZodOptional<z.ZodEnum<{
89
+ task: "task";
90
+ session: "session";
91
+ agent: "agent";
92
+ global: "global";
93
+ }>>;
94
+ mode: z.ZodOptional<z.ZodEnum<{
95
+ warning: "warning";
96
+ block: "block";
97
+ }>>;
98
+ message: z.ZodOptional<z.ZodString>;
99
+ }, z.core.$strip>>>;
100
+ mode: z.ZodOptional<z.ZodEnum<{
101
+ warning: "warning";
102
+ block: "block";
103
+ }>>;
104
+ costPerInputToken: z.ZodOptional<z.ZodNumber>;
105
+ costPerOutputToken: z.ZodOptional<z.ZodNumber>;
106
+ scope: z.ZodOptional<z.ZodEnum<{
107
+ task: "task";
108
+ session: "session";
109
+ agent: "agent";
110
+ global: "global";
111
+ }>>;
112
+ }, z.core.$strip>>;
113
+ overrides: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
114
+ caps: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
115
+ field: z.ZodEnum<{
116
+ tokens: "tokens";
117
+ inputTokens: "inputTokens";
118
+ outputTokens: "outputTokens";
119
+ calls: "calls";
120
+ wallClock: "wallClock";
121
+ modelMs: "modelMs";
122
+ cost: "cost";
123
+ toolCalls: "toolCalls";
124
+ responseSize: "responseSize";
125
+ }>;
126
+ maximum: z.ZodNumber;
127
+ window: z.ZodOptional<z.ZodString>;
128
+ scope: z.ZodOptional<z.ZodEnum<{
129
+ task: "task";
130
+ session: "session";
131
+ agent: "agent";
132
+ global: "global";
133
+ }>>;
134
+ mode: z.ZodOptional<z.ZodEnum<{
135
+ warning: "warning";
136
+ block: "block";
137
+ }>>;
138
+ message: z.ZodOptional<z.ZodString>;
139
+ }, z.core.$strip>>>>;
140
+ mode: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
141
+ warning: "warning";
142
+ block: "block";
143
+ }>>>;
144
+ costPerInputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
145
+ costPerOutputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
146
+ scope: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
147
+ task: "task";
148
+ session: "session";
149
+ agent: "agent";
150
+ global: "global";
151
+ }>>>;
152
+ }, z.core.$strip>>>>;
153
+ }, z.core.$strip>>;
154
+ provider: z.ZodOptional<z.ZodObject<{
155
+ default: z.ZodOptional<z.ZodObject<{
156
+ caps: z.ZodOptional<z.ZodArray<z.ZodObject<{
157
+ field: z.ZodEnum<{
158
+ tokens: "tokens";
159
+ inputTokens: "inputTokens";
160
+ outputTokens: "outputTokens";
161
+ calls: "calls";
162
+ wallClock: "wallClock";
163
+ modelMs: "modelMs";
164
+ cost: "cost";
165
+ toolCalls: "toolCalls";
166
+ responseSize: "responseSize";
167
+ }>;
168
+ maximum: z.ZodNumber;
169
+ window: z.ZodOptional<z.ZodString>;
170
+ scope: z.ZodOptional<z.ZodEnum<{
171
+ task: "task";
172
+ session: "session";
173
+ agent: "agent";
174
+ global: "global";
175
+ }>>;
176
+ mode: z.ZodOptional<z.ZodEnum<{
177
+ warning: "warning";
178
+ block: "block";
179
+ }>>;
180
+ message: z.ZodOptional<z.ZodString>;
181
+ }, z.core.$strip>>>;
182
+ mode: z.ZodOptional<z.ZodEnum<{
183
+ warning: "warning";
184
+ block: "block";
185
+ }>>;
186
+ costPerInputToken: z.ZodOptional<z.ZodNumber>;
187
+ costPerOutputToken: z.ZodOptional<z.ZodNumber>;
188
+ scope: z.ZodOptional<z.ZodEnum<{
189
+ task: "task";
190
+ session: "session";
191
+ agent: "agent";
192
+ global: "global";
193
+ }>>;
194
+ }, z.core.$strip>>;
195
+ overrides: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
196
+ caps: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
197
+ field: z.ZodEnum<{
198
+ tokens: "tokens";
199
+ inputTokens: "inputTokens";
200
+ outputTokens: "outputTokens";
201
+ calls: "calls";
202
+ wallClock: "wallClock";
203
+ modelMs: "modelMs";
204
+ cost: "cost";
205
+ toolCalls: "toolCalls";
206
+ responseSize: "responseSize";
207
+ }>;
208
+ maximum: z.ZodNumber;
209
+ window: z.ZodOptional<z.ZodString>;
210
+ scope: z.ZodOptional<z.ZodEnum<{
211
+ task: "task";
212
+ session: "session";
213
+ agent: "agent";
214
+ global: "global";
215
+ }>>;
216
+ mode: z.ZodOptional<z.ZodEnum<{
217
+ warning: "warning";
218
+ block: "block";
219
+ }>>;
220
+ message: z.ZodOptional<z.ZodString>;
221
+ }, z.core.$strip>>>>;
222
+ mode: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
223
+ warning: "warning";
224
+ block: "block";
225
+ }>>>;
226
+ costPerInputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
227
+ costPerOutputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
228
+ scope: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
229
+ task: "task";
230
+ session: "session";
231
+ agent: "agent";
232
+ global: "global";
233
+ }>>>;
234
+ }, z.core.$strip>>>>;
235
+ }, z.core.$strip>>;
236
+ tool: z.ZodOptional<z.ZodObject<{
237
+ default: z.ZodOptional<z.ZodObject<{
238
+ caps: z.ZodOptional<z.ZodArray<z.ZodObject<{
239
+ field: z.ZodEnum<{
240
+ tokens: "tokens";
241
+ inputTokens: "inputTokens";
242
+ outputTokens: "outputTokens";
243
+ calls: "calls";
244
+ wallClock: "wallClock";
245
+ modelMs: "modelMs";
246
+ cost: "cost";
247
+ toolCalls: "toolCalls";
248
+ responseSize: "responseSize";
249
+ }>;
250
+ maximum: z.ZodNumber;
251
+ window: z.ZodOptional<z.ZodString>;
252
+ scope: z.ZodOptional<z.ZodEnum<{
253
+ task: "task";
254
+ session: "session";
255
+ agent: "agent";
256
+ global: "global";
257
+ }>>;
258
+ mode: z.ZodOptional<z.ZodEnum<{
259
+ warning: "warning";
260
+ block: "block";
261
+ }>>;
262
+ message: z.ZodOptional<z.ZodString>;
263
+ }, z.core.$strip>>>;
264
+ mode: z.ZodOptional<z.ZodEnum<{
265
+ warning: "warning";
266
+ block: "block";
267
+ }>>;
268
+ costPerInputToken: z.ZodOptional<z.ZodNumber>;
269
+ costPerOutputToken: z.ZodOptional<z.ZodNumber>;
270
+ scope: z.ZodOptional<z.ZodEnum<{
271
+ task: "task";
272
+ session: "session";
273
+ agent: "agent";
274
+ global: "global";
275
+ }>>;
276
+ }, z.core.$strip>>;
277
+ overrides: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
278
+ caps: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
279
+ field: z.ZodEnum<{
280
+ tokens: "tokens";
281
+ inputTokens: "inputTokens";
282
+ outputTokens: "outputTokens";
283
+ calls: "calls";
284
+ wallClock: "wallClock";
285
+ modelMs: "modelMs";
286
+ cost: "cost";
287
+ toolCalls: "toolCalls";
288
+ responseSize: "responseSize";
289
+ }>;
290
+ maximum: z.ZodNumber;
291
+ window: z.ZodOptional<z.ZodString>;
292
+ scope: z.ZodOptional<z.ZodEnum<{
293
+ task: "task";
294
+ session: "session";
295
+ agent: "agent";
296
+ global: "global";
297
+ }>>;
298
+ mode: z.ZodOptional<z.ZodEnum<{
299
+ warning: "warning";
300
+ block: "block";
301
+ }>>;
302
+ message: z.ZodOptional<z.ZodString>;
303
+ }, z.core.$strip>>>>;
304
+ mode: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
305
+ warning: "warning";
306
+ block: "block";
307
+ }>>>;
308
+ costPerInputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
309
+ costPerOutputToken: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
310
+ scope: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
311
+ task: "task";
312
+ session: "session";
313
+ agent: "agent";
314
+ global: "global";
315
+ }>>>;
316
+ }, z.core.$strip>>>>;
317
+ }, z.core.$strip>>;
318
+ }, z.core.$strip>;
319
+ export type PluginConfig = z.input<typeof pluginConfigSchema>;
320
+ export declare const configSchema: z.ZodObject<{}, z.core.$loose>;
321
+ declare const createPlugin: PluginFactory;
322
+ export default createPlugin;
323
+ export { createPlugin };
package/index.js ADDED
@@ -0,0 +1,480 @@
1
+ import { z as p } from "zod";
2
+ function S(f) {
3
+ const e = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, t = f.match(e);
4
+ if (!t)
5
+ throw new Error(`invalid ISO 8601 duration: ${f}`);
6
+ const [, o, s, a, n, i, c] = t;
7
+ let r = 0;
8
+ return o && (r += parseInt(o) * 365.25 * 864e5), s && (r += parseInt(s) * 30.44 * 864e5), a && (r += parseInt(a) * 864e5), n && (r += parseInt(n) * 36e5), i && (r += parseInt(i) * 6e4), c && (r += parseFloat(c) * 1e3), Math.round(r);
9
+ }
10
+ const E = [
11
+ "tokens",
12
+ "inputTokens",
13
+ "outputTokens",
14
+ "calls",
15
+ "wallClock",
16
+ "modelMs",
17
+ "cost",
18
+ "toolCalls",
19
+ "responseSize"
20
+ ], M = p.object({
21
+ field: p.enum(E),
22
+ maximum: p.number().min(0),
23
+ window: p.string().optional(),
24
+ scope: p.enum(["task", "session", "agent", "global"]).optional(),
25
+ mode: p.enum(["warning", "block"]).optional(),
26
+ message: p.string().optional()
27
+ }), g = p.object({
28
+ caps: p.array(M).optional(),
29
+ mode: p.enum(["warning", "block"]).optional(),
30
+ costPerInputToken: p.number().min(0).optional(),
31
+ costPerOutputToken: p.number().min(0).optional(),
32
+ scope: p.enum(["task", "session", "agent", "global"]).optional()
33
+ }), w = p.object({
34
+ defaults: g.optional(),
35
+ agent: p.object({
36
+ default: g.optional(),
37
+ overrides: p.record(p.string(), g.partial()).optional().default({})
38
+ }).optional(),
39
+ provider: p.object({
40
+ default: g.optional(),
41
+ overrides: p.record(p.string(), g.partial()).optional().default({})
42
+ }).optional(),
43
+ tool: p.object({
44
+ default: g.optional(),
45
+ overrides: p.record(p.string(), g.partial()).optional().default({})
46
+ }).optional()
47
+ }), y = p.object({}).passthrough(), x = {
48
+ maxInputTokens: { field: "inputTokens" },
49
+ maxOutputTokens: { field: "outputTokens" },
50
+ maxTotalTokens: { field: "tokens" },
51
+ maxModelCalls: { field: "calls" },
52
+ maxWallClockMs: { field: "wallClock" },
53
+ maxModelMs: { field: "modelMs" },
54
+ maxCostUSD: { field: "cost" },
55
+ maxTokensPer24h: { field: "tokens", window: "PT24H" },
56
+ maxCalls: { field: "toolCalls" }
57
+ };
58
+ function b(f) {
59
+ const e = [], t = {};
60
+ for (const [o, s] of Object.entries(f)) {
61
+ const a = x[o];
62
+ if (a && typeof s == "number") {
63
+ const n = { field: a.field, maximum: s };
64
+ a.window && (n.window = a.window), e.push(n);
65
+ } else
66
+ (o === "scope" || o === "mode" || o === "costPerInputToken" || o === "costPerOutputToken" || o === "message") && (t[o] = s);
67
+ }
68
+ return e.length > 0 && (t.caps = e), g.parse(t);
69
+ }
70
+ function _(f) {
71
+ const e = f;
72
+ if (e.defaults !== void 0 || e.agent !== void 0 || e.provider !== void 0 || e.tool !== void 0) {
73
+ const t = w.parse(f);
74
+ return {
75
+ defaults: t.defaults ?? g.parse({}),
76
+ agent: t.agent ?? { overrides: {} },
77
+ provider: t.provider ?? { overrides: {} },
78
+ tool: t.tool ?? { overrides: {} }
79
+ };
80
+ }
81
+ return {
82
+ defaults: b(e),
83
+ agent: { overrides: {} },
84
+ provider: { overrides: {} },
85
+ tool: { overrides: {} }
86
+ };
87
+ }
88
+ function T(f, e, t, o) {
89
+ return {
90
+ isEnforcementError: !0,
91
+ code: "BUDGET_EXCEEDED",
92
+ message: o ?? `${f} limit is ${e}, current value is ${Math.round(t)}`
93
+ };
94
+ }
95
+ function v(f, e, t) {
96
+ return { isToolWarning: !0, toolName: f, callId: e, message: t };
97
+ }
98
+ class O {
99
+ constructor(e, t, o = 0, s = 0) {
100
+ this.db = e, this.cfg = t, this.costPerInput = o, this.costPerOutput = s, this.name = "agent-mcp-budget", this.accumulators = /* @__PURE__ */ new Map();
101
+ }
102
+ install(e) {
103
+ e.register("task:start", (t) => {
104
+ try {
105
+ this.onTaskStart(t);
106
+ } catch {
107
+ }
108
+ }), e.register("pre:model_request", (t) => {
109
+ try {
110
+ this.onPreModelRequest(t);
111
+ } catch {
112
+ }
113
+ }), e.register("post:model_response", (t) => {
114
+ try {
115
+ this.onPostModelResponse(t);
116
+ } catch {
117
+ }
118
+ }), e.register("task:completed", (t) => {
119
+ try {
120
+ this.onTerminal(t.executionContext.taskId);
121
+ } catch {
122
+ }
123
+ }), e.register("task:failed", (t) => {
124
+ try {
125
+ this.onTerminal(t.executionContext.taskId);
126
+ } catch {
127
+ }
128
+ }), e.register("task:cancelled", (t) => {
129
+ try {
130
+ this.onTerminal(t.executionContext.taskId);
131
+ } catch {
132
+ }
133
+ }), e.registerEnforcement(
134
+ "pre:model_request",
135
+ (t) => this.enforcePreModel(t)
136
+ ), e.registerEnforcement("pre:tool_call", (t) => this.enforcePreTool(t)), e.register("transform:tool_result", (t) => {
137
+ try {
138
+ this.enforceResponseSize(t);
139
+ } catch {
140
+ }
141
+ });
142
+ }
143
+ // ── Observational handlers ────────────────────────────────────────────────
144
+ onTaskStart(e) {
145
+ var n, i;
146
+ const { taskId: t, sessionId: o, agentName: s } = e.executionContext, a = ((i = (n = e.executionContext.agentDefinition) == null ? void 0 : n.provider) == null ? void 0 : i.type) ?? "unknown";
147
+ this.accumulators.set(t, {
148
+ taskId: t,
149
+ sessionId: o ?? void 0,
150
+ agentName: s,
151
+ providerType: a,
152
+ startedAtMs: Date.now(),
153
+ inputTokens: 0,
154
+ outputTokens: 0,
155
+ modelCalls: 0,
156
+ totalModelMs: 0,
157
+ toolCalls: /* @__PURE__ */ new Map()
158
+ });
159
+ }
160
+ onPreModelRequest(e) {
161
+ const t = this.accumulators.get(e.executionContext.taskId);
162
+ t && (t.modelCallStartMs = Date.now());
163
+ }
164
+ onPostModelResponse(e) {
165
+ const t = this.accumulators.get(e.executionContext.taskId);
166
+ if (!t)
167
+ return;
168
+ const o = e.tokenUsage;
169
+ o && (t.inputTokens += o.inputTokens ?? 0, t.outputTokens += o.outputTokens ?? 0), t.modelCalls += 1, t.modelCallStartMs !== void 0 && (t.totalModelMs += Date.now() - t.modelCallStartMs, t.modelCallStartMs = void 0);
170
+ }
171
+ onTerminal(e) {
172
+ this.accumulators.delete(e);
173
+ }
174
+ // ── Config resolution ─────────────────────────────────────────────────────
175
+ mergeDim(e) {
176
+ let t = { caps: [] };
177
+ for (const o of e)
178
+ o && (t = {
179
+ caps: [...t.caps ?? [], ...o.caps ?? []],
180
+ mode: o.mode ?? t.mode,
181
+ costPerInputToken: o.costPerInputToken ?? t.costPerInputToken,
182
+ costPerOutputToken: o.costPerOutputToken ?? t.costPerOutputToken,
183
+ scope: o.scope ?? t.scope
184
+ });
185
+ return t;
186
+ }
187
+ resolveCaps(e, t, o) {
188
+ var u, m, d;
189
+ const s = this.cfg.defaults, a = this.cfg.agent, n = this.cfg.provider, i = this.cfg.tool;
190
+ if (o) {
191
+ const k = (u = i == null ? void 0 : i.overrides) == null ? void 0 : u[o], h = this.mergeDim([s, i == null ? void 0 : i.default, k]);
192
+ return {
193
+ caps: h.caps ?? [],
194
+ mode: h.mode,
195
+ scope: h.scope
196
+ };
197
+ }
198
+ const c = (m = a == null ? void 0 : a.overrides) == null ? void 0 : m[e], r = (d = n == null ? void 0 : n.overrides) == null ? void 0 : d[t], l = this.mergeDim([
199
+ s,
200
+ a == null ? void 0 : a.default,
201
+ c,
202
+ n == null ? void 0 : n.default,
203
+ r
204
+ ]);
205
+ return { caps: l.caps ?? [], mode: l.mode, scope: l.scope };
206
+ }
207
+ // ── Scope-aware DB queries ────────────────────────────────────────────────
208
+ queryScopeTotals(e, t, o, s) {
209
+ const a = this.accumulators.get(e), n = a ? {
210
+ inputTokens: a.inputTokens,
211
+ outputTokens: a.outputTokens,
212
+ modelCalls: a.modelCalls
213
+ } : { inputTokens: 0, outputTokens: 0, modelCalls: 0 };
214
+ if (s === "task" || !this.db)
215
+ return n;
216
+ try {
217
+ const i = this.db;
218
+ let c;
219
+ if (s === "session" && t ? c = i.prepare(
220
+ `SELECT
221
+ COALESCE(SUM(tu.input_tokens), 0) AS input,
222
+ COALESCE(SUM(tu.output_tokens), 0) AS output,
223
+ COALESCE(SUM(tu.model_calls), 0) AS calls
224
+ FROM task_usage tu
225
+ JOIN tasks t ON tu.task_id = t.id
226
+ WHERE t.session_id = ? AND tu.task_id != ?`
227
+ ).get(t, e) : s === "agent" ? c = i.prepare(
228
+ `SELECT
229
+ COALESCE(SUM(input_tokens), 0) AS input,
230
+ COALESCE(SUM(output_tokens), 0) AS output,
231
+ COALESCE(SUM(model_calls), 0) AS calls
232
+ FROM task_usage
233
+ WHERE agent_name = ? AND task_id != ?`
234
+ ).get(o, e) : s === "global" && (c = i.prepare(
235
+ `SELECT
236
+ COALESCE(SUM(input_tokens), 0) AS input,
237
+ COALESCE(SUM(output_tokens), 0) AS output,
238
+ COALESCE(SUM(model_calls), 0) AS calls
239
+ FROM task_usage
240
+ WHERE task_id != ?`
241
+ ).get(e)), c)
242
+ return {
243
+ inputTokens: (c.input ?? 0) + n.inputTokens,
244
+ outputTokens: (c.output ?? 0) + n.outputTokens,
245
+ modelCalls: (c.calls ?? 0) + n.modelCalls
246
+ };
247
+ } catch {
248
+ }
249
+ return n;
250
+ }
251
+ queryWindowTokens(e, t, o, s) {
252
+ if (!this.db)
253
+ return 0;
254
+ try {
255
+ const a = this.db, n = new Date(Date.now() - o).toISOString();
256
+ let i;
257
+ if (e === "session") {
258
+ const c = s ? " AND tu.task_id != ?" : "", r = [t, n];
259
+ s && r.push(s), i = a.prepare(
260
+ `SELECT COALESCE(SUM(tu.input_tokens + tu.output_tokens), 0) AS total
261
+ FROM task_usage tu
262
+ JOIN tasks t ON tu.task_id = t.id
263
+ WHERE t.session_id = ? AND tu.created_at >= ?${c}`
264
+ ).get(...r);
265
+ } else if (e === "agent") {
266
+ const c = s ? " AND task_id != ?" : "", r = [t, n];
267
+ s && r.push(s), i = a.prepare(
268
+ `SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
269
+ FROM task_usage
270
+ WHERE agent_name = ? AND created_at >= ?${c}`
271
+ ).get(...r);
272
+ } else if (e === "global") {
273
+ const c = s ? " AND task_id != ?" : "", r = [n];
274
+ s && r.push(s), i = a.prepare(
275
+ `SELECT COALESCE(SUM(input_tokens + output_tokens), 0) AS total
276
+ FROM task_usage
277
+ WHERE created_at >= ?${c}`
278
+ ).get(...r);
279
+ }
280
+ return (i == null ? void 0 : i.total) ?? 0;
281
+ } catch {
282
+ return 0;
283
+ }
284
+ }
285
+ // ── Generic cap evaluation ────────────────────────────────────────────────
286
+ // ── Single-shot usage snapshot ──────────────────────────────────────────
287
+ /**
288
+ * Build a complete usage snapshot for the current enforcement event.
289
+ *
290
+ * Makes exactly `U + W` DB queries where:
291
+ * U = number of unique non-task scopes across all caps (0..3)
292
+ * W = number of unique (scope, window) pairs across all caps
293
+ *
294
+ * Independent of cap count, agent count, session count, or history depth.
295
+ */
296
+ buildSnapshot(e, t, o, s, a, n) {
297
+ const i = {};
298
+ i.inputTokens = t.inputTokens, i.outputTokens = t.outputTokens, i.calls = t.modelCalls, i.wallClock = Date.now() - t.startedAtMs, i.modelMs = t.totalModelMs, i.cost = t.inputTokens * this.costPerInput + t.outputTokens * this.costPerOutput;
299
+ const c = /* @__PURE__ */ new Set(), r = /* @__PURE__ */ new Map();
300
+ for (const l of e) {
301
+ const u = l.scope ?? n ?? "task";
302
+ if (u !== "task" && c.add(u), l.window) {
303
+ const m = `${u}:${l.window}`;
304
+ r.has(m) || r.set(m, {
305
+ scope: u,
306
+ windowMs: S(l.window)
307
+ });
308
+ }
309
+ }
310
+ for (const l of c) {
311
+ const u = this.queryScopeTotals(o, s, a, l);
312
+ i[`${l}:inputTokens`] = u.inputTokens, i[`${l}:outputTokens`] = u.outputTokens, i[`${l}:calls`] = u.modelCalls;
313
+ }
314
+ for (const [l, { scope: u, windowMs: m }] of r) {
315
+ let d = "";
316
+ u === "session" ? d = s ?? "" : u === "agent" && (d = a ?? ""), i[l] = this.queryWindowTokens(u, d, m, o);
317
+ }
318
+ return i;
319
+ }
320
+ getSnapshotValue(e, t, o) {
321
+ const s = t.scope ?? o ?? "task";
322
+ let a;
323
+ t.window ? a = "" : a = s !== "task" ? `${s}:` : "";
324
+ let n;
325
+ switch (t.field) {
326
+ case "inputTokens":
327
+ n = e[`${a}inputTokens`] ?? e.inputTokens;
328
+ break;
329
+ case "outputTokens":
330
+ n = e[`${a}outputTokens`] ?? e.outputTokens;
331
+ break;
332
+ case "tokens":
333
+ n = (e[`${a}inputTokens`] ?? e.inputTokens) + (e[`${a}outputTokens`] ?? e.outputTokens);
334
+ break;
335
+ case "calls":
336
+ n = e[`${a}calls`] ?? e.calls;
337
+ break;
338
+ case "wallClock":
339
+ n = e.wallClock;
340
+ break;
341
+ case "modelMs":
342
+ n = e.modelMs;
343
+ break;
344
+ case "cost":
345
+ n = e.cost;
346
+ break;
347
+ case "toolCalls":
348
+ n = 0;
349
+ break;
350
+ default:
351
+ n = 0;
352
+ }
353
+ return t.window && (n += e[`${s}:${t.window}`] ?? 0), n;
354
+ }
355
+ evaluateCap(e, t, o) {
356
+ const s = this.getSnapshotValue(t, e, o);
357
+ if (s >= e.maximum)
358
+ throw T(e.field, e.maximum, s, e.message);
359
+ }
360
+ // ── Enforcement: pre:model_request ────────────────────────────────────────
361
+ enforcePreModel(e) {
362
+ var u, m;
363
+ const { taskId: t, sessionId: o, agentName: s } = e.executionContext, a = ((m = (u = e.executionContext.agentDefinition) == null ? void 0 : u.provider) == null ? void 0 : m.type) ?? "unknown", n = this.accumulators.get(t);
364
+ if (!n)
365
+ return;
366
+ const { caps: i, scope: c } = this.resolveCaps(s, a), r = i.filter((d) => d.field !== "toolCalls");
367
+ if (r.length === 0)
368
+ return;
369
+ const l = this.buildSnapshot(
370
+ r,
371
+ n,
372
+ t,
373
+ o,
374
+ s,
375
+ c
376
+ );
377
+ for (const d of r)
378
+ this.evaluateCap(d, l);
379
+ }
380
+ // ── Enforcement: pre:tool_call ────────────────────────────────────────────
381
+ enforcePreTool(e) {
382
+ const { toolName: t, callId: o, executionContext: s } = e, {
383
+ caps: a,
384
+ mode: n,
385
+ scope: i
386
+ } = this.resolveCaps(s.agentName, "", t), c = this.accumulators.get(s.taskId);
387
+ if (!c || a.length === 0)
388
+ return;
389
+ const r = c.toolCalls.get(t) ?? 0, l = this.buildSnapshot(
390
+ a,
391
+ c,
392
+ s.taskId,
393
+ s.sessionId,
394
+ s.agentName,
395
+ i
396
+ );
397
+ for (const u of a) {
398
+ const m = u.field === "toolCalls" ? r : this.getSnapshotValue(l, u, i);
399
+ if (m >= u.maximum) {
400
+ const d = u.message ?? `tool "${t}": ${u.field} limit is ${u.maximum}, current value is ${Math.round(m)}`;
401
+ throw (u.mode ?? n ?? "warning") === "warning" ? v(t, o, d) : T(
402
+ `tool:${t}:${u.field}`,
403
+ u.maximum,
404
+ m,
405
+ u.message
406
+ );
407
+ }
408
+ }
409
+ c.toolCalls.set(t, r + 1);
410
+ }
411
+ // ── Enforcement: transform:tool_result (response size) ────────────────────
412
+ enforceResponseSize(e) {
413
+ const { toolName: t, result: o } = e;
414
+ if (typeof o != "object" || o === null)
415
+ return;
416
+ const { caps: s, mode: a } = this.resolveCaps("", "", t), n = s.filter((l) => l.field === "responseSize");
417
+ if (n.length === 0)
418
+ return;
419
+ const i = o, c = i.content;
420
+ if (!Array.isArray(c))
421
+ return;
422
+ let r = 0;
423
+ for (const l of c)
424
+ if (typeof l == "object" && l !== null) {
425
+ const u = l;
426
+ u.type === "text" && (r += (u.text ?? "").length);
427
+ }
428
+ for (const l of n) {
429
+ if (r <= l.maximum)
430
+ continue;
431
+ if ((l.mode ?? a ?? "warning") === "block")
432
+ i.content = [
433
+ {
434
+ type: "text",
435
+ text: l.message ?? `Response size (${r} chars) exceeds limit of ${l.maximum}. Use offset/limit or shell paging tools instead.`
436
+ }
437
+ ], e.isError = !0;
438
+ else {
439
+ let m = l.maximum;
440
+ const d = [];
441
+ for (const k of c) {
442
+ if (typeof k != "object" || k === null) {
443
+ d.push(k);
444
+ continue;
445
+ }
446
+ const h = k;
447
+ if (h.type !== "text") {
448
+ d.push(k);
449
+ continue;
450
+ }
451
+ const C = h.text ?? "";
452
+ if (C.length <= m)
453
+ d.push(k), m -= C.length;
454
+ else {
455
+ d.push({ type: "text", text: C.slice(0, m) });
456
+ break;
457
+ }
458
+ }
459
+ d.push({
460
+ type: "text",
461
+ text: `
462
+
463
+ [truncated: response was ${r} chars, limited to ${l.maximum}. ${l.message ?? "Use offset/limit or shell paging tools for full content."}]`
464
+ }), i.content = d;
465
+ }
466
+ break;
467
+ }
468
+ }
469
+ }
470
+ const $ = ({ db: f, config: e }) => {
471
+ var a, n;
472
+ const t = _(e), o = ((a = t.defaults) == null ? void 0 : a.costPerInputToken) ?? 0, s = ((n = t.defaults) == null ? void 0 : n.costPerOutputToken) ?? 0;
473
+ return new O(f, t, o, s);
474
+ };
475
+ export {
476
+ y as configSchema,
477
+ $ as createPlugin,
478
+ $ as default,
479
+ w as pluginConfigSchema
480
+ };
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@adhd/agent-plugin-budget",
3
+ "version": "0.0.2",
4
+ "description": "Budget enforcement plugin for @adhd/agent-mcp — caps token spend, cost, wall-clock time, and model calls per task/session/agent",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./index.cjs",
10
+ "module": "./index.js",
11
+ "typings": "./index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./index.js",
15
+ "require": "./index.cjs",
16
+ "types": "./index.d.ts"
17
+ }
18
+ },
19
+ "peerDependencies": {
20
+ "@adhd/agent-base-types": "^2.1.1"
21
+ },
22
+ "dependencies": {
23
+ "zod": "*"
24
+ }
25
+ }