@budgetary/mcp
Advanced tools
Sorry, the diff of this file is too big to display
| import { | ||
| MAX_TRANSCRIPT_BYTES, | ||
| MeasuredStore, | ||
| PendingStore, | ||
| capTrace, | ||
| entryBinding, | ||
| findProvenTranscript, | ||
| isTranscriptDir, | ||
| measuredFilePath, | ||
| pendingFilePath, | ||
| persistedCounts, | ||
| readTranscriptUsage, | ||
| submitActuals | ||
| } from "./chunk-O5H2WSVL.js"; | ||
| // src/reconcile.ts | ||
| import { readdirSync, readFileSync, statSync } from "fs"; | ||
| import { BudgetaryClient } from "@budgetary/sdk"; | ||
| var RECONCILE_MAX_RETRIES = 0; | ||
| function fileContains(path, needle) { | ||
| try { | ||
| const st = statSync(path); | ||
| if (!st.isFile() || st.size > MAX_TRANSCRIPT_BYTES) return false; | ||
| return readFileSync(path, "utf8").includes(needle); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function listDir(dir) { | ||
| try { | ||
| return readdirSync(dir); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function mtimeMs(path) { | ||
| try { | ||
| return statSync(path).mtimeMs; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function fingerprint(path) { | ||
| try { | ||
| const st = statSync(path); | ||
| return `${st.size}:${st.mtimeMs}`; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function reconcileEntry(args) { | ||
| const logger = args.logger ?? { warn: () => { | ||
| } }; | ||
| const now = (args.now ?? (() => /* @__PURE__ */ new Date()))(); | ||
| const binding = entryBinding(args.entry); | ||
| if (binding === null) return "no-binding"; | ||
| const store = new PendingStore({ | ||
| path: pendingFilePath(args.home), | ||
| logger | ||
| }); | ||
| const measured = new MeasuredStore({ | ||
| path: measuredFilePath(args.home), | ||
| logger, | ||
| now: args.now ?? (() => /* @__PURE__ */ new Date()) | ||
| }); | ||
| const factory = args.clientFactory ?? ((opts) => new BudgetaryClient(opts)); | ||
| const client = factory({ | ||
| apiKey: args.apiKey, | ||
| baseUrl: args.baseUrl, | ||
| maxRetries: RECONCILE_MAX_RETRIES | ||
| }); | ||
| const persisted = persistedCounts(args.entry); | ||
| if (persisted !== null) { | ||
| return finish(await trySubmit(persisted)); | ||
| } | ||
| if (!isTranscriptDir(binding.transcriptDir, args.home)) return "no-transcript"; | ||
| const path = findProvenTranscript( | ||
| binding, | ||
| { listDir, contains: fileContains, mtimeMs }, | ||
| Date.parse(args.entry.created_at) | ||
| ); | ||
| if (path === null) return "no-transcript"; | ||
| const before = fingerprint(path); | ||
| if (before === null) return "no-transcript"; | ||
| const usage = (args.readUsage ?? readTranscriptUsage)(path, { target: false }); | ||
| if (fingerprint(path) !== before) return "transcript-changed"; | ||
| if (usage === null) return "no-usage"; | ||
| const trace = capTrace(usage.trace) ?? void 0; | ||
| const counts = { | ||
| tokensIn: usage.tokensIn, | ||
| tokensOut: usage.tokensOut, | ||
| // ★★ No `success`. This path runs in a LATER session, against a PREVIOUS | ||
| // session's pending entry, and every gate it passes proves only that the | ||
| // earlier session ENDED: the serving process is gone and the transcript is | ||
| // complete and stable. None of that observes whether the task WORKED — a | ||
| // session that was opened, estimated and abandoned satisfies all of it. A | ||
| // constant is not a measurement, and picking either constant is an | ||
| // affirmative false claim about every reconciled run: `true` records | ||
| // abandoned work as achievement, `false` strands genuine successes | ||
| // permanently (the server stores only the first submission's value). The | ||
| // counts here ARE measured from the run's own transcript and still submit; | ||
| // the outcome is left unobserved, which is the only honest record. | ||
| // Measured, not inferred from this process's clock. The hook can bound the | ||
| // run with a real session-end moment; we cannot, so the transcript's own | ||
| // last-write time is used as the end bound. Using `now` here would report | ||
| // the age of the ENTRY (up to a full day) as the run's duration. | ||
| durationMs: durationFromTranscript(path, args.entry, now), | ||
| ...trace ? { trace } : {} | ||
| }; | ||
| return finish(await trySubmit(counts)); | ||
| async function trySubmit(measuredCounts) { | ||
| try { | ||
| const outcome = await submitActuals({ | ||
| store, | ||
| client, | ||
| entry: args.entry, | ||
| counts: measuredCounts, | ||
| logger, | ||
| measured | ||
| }); | ||
| return outcome.submitted; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function finish(submitted) { | ||
| return submitted ? "submitted" : "not-submitted"; | ||
| } | ||
| } | ||
| function durationFromTranscript(path, entry, now) { | ||
| const created = Date.parse(entry.created_at); | ||
| if (!Number.isFinite(created)) return 0; | ||
| let endMs; | ||
| try { | ||
| endMs = statSync(path).mtimeMs; | ||
| } catch { | ||
| return 0; | ||
| } | ||
| if (!Number.isFinite(endMs) || endMs < created || endMs > now.getTime() + 1e3) { | ||
| return 0; | ||
| } | ||
| return Math.max(0, Math.round(endMs - created)); | ||
| } | ||
| export { | ||
| reconcileEntry | ||
| }; |
+1
-1
@@ -12,3 +12,3 @@ import { | ||
| submitActuals | ||
| } from "./chunk-XVFRGAHR.js"; | ||
| } from "./chunk-O5H2WSVL.js"; | ||
| export { | ||
@@ -15,0 +15,0 @@ MAX_ATTEMPTS, |
+1
-1
| { | ||
| "name": "@budgetary/mcp", | ||
| "version": "0.12.0", | ||
| "version": "0.13.0", | ||
| "description": "Model Context Protocol server for Budgetary: a portable pre-flight token-spend estimate tool for any MCP-capable host.", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.thriftell/budgetary", |
+8
-3
| # @budgetary/mcp | ||
| A single [Model Context Protocol](https://modelcontextprotocol.io) server that gives any MCP-capable host — Claude Code, Cursor, GitHub Copilot, Codex, and others — a pre-flight, probabilistic **token-spend estimate** for a coding task before you run it, and a best-effort, never-fabricated way to record what the task actually cost. Build it once; add it everywhere. It replaces the previously-planned per-host extensions. | ||
| A single [Model Context Protocol](https://modelcontextprotocol.io) server that gives any MCP-capable host — Claude Code, Cursor, GitHub Copilot, Codex, and others — a pre-flight, probabilistic **token-spend estimate** for a coding task before you run it, and a best-effort, never-fabricated way to measure what the task actually cost. The forecast is a probability; the measurement is a count. Where there is no firm basis to forecast a particular task, it says so instead of guessing. Build it once; add it everywhere. It replaces the previously-planned per-host extensions. | ||
@@ -241,7 +241,12 @@ The server exposes exactly one model-invokable tool, `estimate`. It talks to the hosted Budgetary API at `https://api.budgetary.tools`. | ||
| Your first successful `estimate` on a machine appends a one-time note pointing here, and `npx @budgetary/mcp doctor` repeats a short form of it on every run. That note is **one call late** and says so: by the time anything can be rendered, `estimate` has already sent the task text. It is a disclosure and a pointer — it asks for nothing, gates nothing, and grants nothing. This section is the account it points at. | ||
| Only these things leave your machine, and only to `https://api.budgetary.tools`: | ||
| - The **task description** you pass to `estimate`. | ||
| - The **task description** you pass to `estimate`, verbatim. | ||
| - If the model supplied one, the **target model identifier** it named (e.g. `claude-opus-4-7`) — the optional `model` argument of the `estimate` tool, omitted when absent. | ||
| - The **host tag** — `BUDGETARY_HOST` if you set it, otherwise the constant `mcp`. It says which MCP host the call came from and nothing else. | ||
| - A per-call **request id**, a fresh random UUID generated for each `estimate` so a retried call is not counted twice. It is derived from nothing about you, your machine, or your task. | ||
| - If you set it, the **language tag** you declared (e.g. `TypeScript`) — a benign label, the same kind of thing as the host name. Never sent unless you opt in via `BUDGETARY_LANGUAGE` or the config `language` field. | ||
| - After a run, the **token counts** (`tokens_in`, `tokens_out`), a `success` flag, and a duration. | ||
| - After a run, the **token counts** (`tokens_in`, `tokens_out`) and a duration; a `success` flag **only when the outcome was actually observed**, and never otherwise; and, when one was declared, how the run **ended** — one of `natural`, `harness_watchdog`, `operative_cap`, `kill_switch`, and nothing else. | ||
| - A constant **client label** — `mcp_client` unless an operator overrode it with `BUDGETARY_SOURCE` (see above). It says which client sent the row and nothing else: it is a fixed string, derived from no part of you, your machine, or your task. | ||
@@ -248,0 +253,0 @@ - On Claude Code, a **behavior trace**: per step, the host tool name (e.g. `Read`, `Bash`), its token count, a **redacted descriptor** of what it acted on, and whether it succeeded. The descriptor exposes a program name *in the clear only when it is a common, non-sensitive tool* (e.g. `pytest`, `npm run`) — a pasted credential or a private script name is never shown, only its **salted digest**; everything after the program (paths, arguments, the rest of the command) always lives inside the digest, or a bare path digest for a file tool. Custom/internal tool names (e.g. an org's private MCP tool) are reported generically as `mcp:other`, never verbatim. **No file contents, absolute paths, command arguments, or output ever leave the machine** — only an allowlisted program name and an opaque key. Set `BUDGETARY_TRACE_TARGET=off` to drop the descriptor entirely (the trace falls back to tool names + token counts); any value other than an explicit `1`/`true`/`on`/`yes` is treated as off. |
Sorry, the diff of this file is too big to display
| import { | ||
| MAX_TRANSCRIPT_BYTES, | ||
| MeasuredStore, | ||
| PendingStore, | ||
| capTrace, | ||
| entryBinding, | ||
| findProvenTranscript, | ||
| isTranscriptDir, | ||
| measuredFilePath, | ||
| pendingFilePath, | ||
| persistedCounts, | ||
| readTranscriptUsage, | ||
| submitActuals | ||
| } from "./chunk-XVFRGAHR.js"; | ||
| // src/reconcile.ts | ||
| import { readdirSync, readFileSync, statSync } from "fs"; | ||
| import { BudgetaryClient } from "@budgetary/sdk"; | ||
| var RECONCILE_MAX_RETRIES = 0; | ||
| function fileContains(path, needle) { | ||
| try { | ||
| const st = statSync(path); | ||
| if (!st.isFile() || st.size > MAX_TRANSCRIPT_BYTES) return false; | ||
| return readFileSync(path, "utf8").includes(needle); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function listDir(dir) { | ||
| try { | ||
| return readdirSync(dir); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function mtimeMs(path) { | ||
| try { | ||
| return statSync(path).mtimeMs; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function fingerprint(path) { | ||
| try { | ||
| const st = statSync(path); | ||
| return `${st.size}:${st.mtimeMs}`; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function reconcileEntry(args) { | ||
| const logger = args.logger ?? { warn: () => { | ||
| } }; | ||
| const now = (args.now ?? (() => /* @__PURE__ */ new Date()))(); | ||
| const binding = entryBinding(args.entry); | ||
| if (binding === null) return "no-binding"; | ||
| const store = new PendingStore({ | ||
| path: pendingFilePath(args.home), | ||
| logger | ||
| }); | ||
| const measured = new MeasuredStore({ | ||
| path: measuredFilePath(args.home), | ||
| logger, | ||
| now: args.now ?? (() => /* @__PURE__ */ new Date()) | ||
| }); | ||
| const factory = args.clientFactory ?? ((opts) => new BudgetaryClient(opts)); | ||
| const client = factory({ | ||
| apiKey: args.apiKey, | ||
| baseUrl: args.baseUrl, | ||
| maxRetries: RECONCILE_MAX_RETRIES | ||
| }); | ||
| const persisted = persistedCounts(args.entry); | ||
| if (persisted !== null) { | ||
| return finish(await trySubmit(persisted)); | ||
| } | ||
| if (!isTranscriptDir(binding.transcriptDir, args.home)) return "no-transcript"; | ||
| const path = findProvenTranscript( | ||
| binding, | ||
| { listDir, contains: fileContains, mtimeMs }, | ||
| Date.parse(args.entry.created_at) | ||
| ); | ||
| if (path === null) return "no-transcript"; | ||
| const before = fingerprint(path); | ||
| if (before === null) return "no-transcript"; | ||
| const usage = (args.readUsage ?? readTranscriptUsage)(path, { target: false }); | ||
| if (fingerprint(path) !== before) return "transcript-changed"; | ||
| if (usage === null) return "no-usage"; | ||
| const trace = capTrace(usage.trace) ?? void 0; | ||
| const counts = { | ||
| tokensIn: usage.tokensIn, | ||
| tokensOut: usage.tokensOut, | ||
| // ★★ No `success`. This path runs in a LATER session, against a PREVIOUS | ||
| // session's pending entry, and every gate it passes proves only that the | ||
| // earlier session ENDED: the serving process is gone and the transcript is | ||
| // complete and stable. None of that observes whether the task WORKED — a | ||
| // session that was opened, estimated and abandoned satisfies all of it. A | ||
| // constant is not a measurement, and picking either constant is an | ||
| // affirmative false claim about every reconciled run: `true` records | ||
| // abandoned work as achievement, `false` strands genuine successes | ||
| // permanently (the server stores only the first submission's value). The | ||
| // counts here ARE measured from the run's own transcript and still submit; | ||
| // the outcome is left unobserved, which is the only honest record. | ||
| // Measured, not inferred from this process's clock. The hook can bound the | ||
| // run with a real session-end moment; we cannot, so the transcript's own | ||
| // last-write time is used as the end bound. Using `now` here would report | ||
| // the age of the ENTRY (up to a full day) as the run's duration. | ||
| durationMs: durationFromTranscript(path, args.entry, now), | ||
| ...trace ? { trace } : {} | ||
| }; | ||
| return finish(await trySubmit(counts)); | ||
| async function trySubmit(measuredCounts) { | ||
| try { | ||
| const outcome = await submitActuals({ | ||
| store, | ||
| client, | ||
| entry: args.entry, | ||
| counts: measuredCounts, | ||
| logger, | ||
| measured | ||
| }); | ||
| return outcome.submitted; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function finish(submitted) { | ||
| return submitted ? "submitted" : "not-submitted"; | ||
| } | ||
| } | ||
| function durationFromTranscript(path, entry, now) { | ||
| const created = Date.parse(entry.created_at); | ||
| if (!Number.isFinite(created)) return 0; | ||
| let endMs; | ||
| try { | ||
| endMs = statSync(path).mtimeMs; | ||
| } catch { | ||
| return 0; | ||
| } | ||
| if (!Number.isFinite(endMs) || endMs < created || endMs > now.getTime() + 1e3) { | ||
| return 0; | ||
| } | ||
| return Math.max(0, Math.round(endMs - created)); | ||
| } | ||
| export { | ||
| reconcileEntry | ||
| }; |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
762828
0.71%19839
0.33%261
1.95%