Sign In

@budgetary/mcp

Package Overview
Dependencies
Maintainers
1
Versions
21
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@budgetary/mcp - npm Package Compare versions

Comparing version
0.9.3
to
0.10.0
dist/chunk-6MVOLG6B.js

Sorry, the diff of this file is too big to display

+144
import {
MAX_TRANSCRIPT_BYTES,
MeasuredStore,
PendingStore,
capTrace,
entryBinding,
findProvenTranscript,
isTranscriptDir,
measuredFilePath,
pendingFilePath,
persistedCounts,
readTranscriptUsage,
submitActuals
} from "./chunk-6MVOLG6B.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,
// We submit ONLY for a session whose serving process is gone and whose
// transcript is complete and stable — i.e. one that ran to termination,
// which is the same condition the hook path encodes as success (it counts
// `clear` / `logout` / `prompt_input_exit`, the normal endings). The hook's
// conservative default of `false` is not available to us: it keys off a
// termination reason only the host can report, and defaulting to it would
// stamp `false` on EVERY reconciled run — a systematic label, not a
// measurement. See the PR for the divergence this leaves.
success: true,
// 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
};
+48
-5

@@ -1,2 +0,2 @@

import { Phases, Assessment, ActualsTraceStep, BudgetaryClientOptions, BudgetaryClient } from '@budgetary/sdk';
import { Phases, Assessment, ActualsTraceStep, CensoringCategory, BudgetaryClientOptions, BudgetaryClient } from '@budgetary/sdk';

@@ -106,2 +106,12 @@ interface SessionEndBreadcrumb {

has_trace?: boolean;
/**
* The run-termination category the failed submit DECLARED (an outer harness's
* `--censoring`, or the human's `report-actual` answer), persisted so the
* retry — which IS the first submission that stores the row — resubmits it.
* Absent when nothing observed the ending, which is most entries and is the
* honest record. Re-validated at read time against the exact vocabulary
* (a corrupt value degrades to omitted, never normalized), so — like every
* optional field above — it needs no bump to the file `version`.
*/
censoring?: string;
forecast_p10?: number;

@@ -181,2 +191,12 @@ forecast_p50?: number;

/**
* The exact-match gate for a run-termination category: `raw` when it is one of
* the four {@link CENSORING_CATEGORIES} literals, else `undefined` (omit).
* Deliberately no case folding, no trimming, no aliasing — a normalizing
* coercion is where a typo becomes a confident wrong category. The server
* applies the same exact match and drops anything else to `null`; dropping it
* client-side keeps the wire honest and matches that posture. Not an error:
* a submit whose category is dropped still submits everything else.
*/
declare function censoringCategory(raw: unknown): CensoringCategory | undefined;
/**
* Realized counts for a completed run. These are ALWAYS supplied by the

@@ -200,2 +220,13 @@ * caller — either a session-end hook reading the real transcript, or a human

trace?: TraceStep[];
/**
* Optional run-termination category, forwarded VERBATIM from a caller that
* observed it: an outer harness's `--censoring` declaration, or the human's
* own answer on `report-actual`. `undefined` means "nothing observed the
* ending" and OMITS the field — never a default, never `natural`. Two submit
* paths can never populate it, by design: the SessionEnd hook (its `reason`
* is session-scoped, not run-scoped) and the in-process reconcile (it runs
* in a later session and observed nothing about the run's ending). Never
* model-supplied.
*/
censoring?: CensoringCategory;
}

@@ -358,7 +389,19 @@ /**

* Whether the run completed its objective. The transcript carries real token
* counts but not a trustworthy success signal, so the human supplies it
* (default true; `--failed` sets it false). This is the ONLY caller-declared
* field — the token counts are always measured, never entered.
* counts but not a trustworthy success signal, so the caller supplies it
* (default true; `--failed` sets it false). Like {@link censoring}, it is a
* caller declaration — the token counts are always measured, never entered.
*/
success: boolean;
/**
* The raw `--censoring <value>` declaration, when the invoking harness passed
* one. A harness that spawned the agent host is a measuring instrument: it
* owns its watchdog and reads the host's own result output, so this is the
* same kind of declaration about the same run as `--success`/`--failed`. The
* client is a wire, not a translator: the value is checked for an EXACT match
* against the four contract categories and forwarded verbatim; anything else
* (a typo, a case variant, a fifth word) is OMITTED from the body — never
* normalized, never defaulted, and never an error that fails the submit.
* Absent ⇒ the field is absent and the body is byte-identical to today's.
*/
censoring?: string;
env: NodeJS.ProcessEnv;

@@ -414,2 +457,2 @@ home?: string;

export { type ActualCounts, type AutoActualsArgs, MAX_ATTEMPTS, type ManualActualsArgs, type PendingListArgs, type PendingWriter, type RolloutActualsArgs, type SessionEndPayload, type SubmitActualsArgs, type SubmitOutcome, breadcrumbForecastVsActual, describeAge, persistedCounts, runAutoActuals, runManualActuals, runPendingList, runRolloutActuals, submitActuals };
export { type ActualCounts, type AutoActualsArgs, MAX_ATTEMPTS, type ManualActualsArgs, type PendingListArgs, type PendingWriter, type RolloutActualsArgs, type SessionEndPayload, type SubmitActualsArgs, type SubmitOutcome, breadcrumbForecastVsActual, censoringCategory, describeAge, persistedCounts, runAutoActuals, runManualActuals, runPendingList, runRolloutActuals, submitActuals };
+3
-1
import {
MAX_ATTEMPTS,
breadcrumbForecastVsActual,
censoringCategory,
describeAge,

@@ -11,6 +12,7 @@ persistedCounts,

submitActuals
} from "./chunk-GJILMW34.js";
} from "./chunk-6MVOLG6B.js";
export {
MAX_ATTEMPTS,
breadcrumbForecastVsActual,
censoringCategory,
describeAge,

@@ -17,0 +19,0 @@ persistedCounts,

@@ -109,2 +109,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';

success: boolean;
/**
* The RAW `--censoring <value>` declaration, or null when the flag was
* absent. Syntactic only: the exact-match check against the four contract
* categories happens downstream (`runRolloutActuals`), where an unrecognized
* value is OMITTED from the body — never normalized, never an error. Consumed
* ONLY by the `--transcript` form; the stdin hook path never reads it.
*/
censoring: string | null;
/** A usage error (e.g. `--transcript` with no path), or null. */

@@ -115,8 +123,18 @@ error: string | null;

* Parse `on-session-end` arguments: an optional rollout/transcript file path
* (via `--transcript`/`--rollout` or a bare positional) and a success flag
* (`--failed` / `--success`, default success). The counts are always measured
* from the file; only success is caller-declared. A `--transcript`/`--rollout`
* with no value (or a flag-shaped value like `--failed`) is a usage ERROR — it
* must never be swallowed as the path, nor fall through to the stdin hook path
* where the explicit request to submit a file would silently do nothing.
* (via `--transcript`/`--rollout` or a bare positional), a success flag
* (`--failed` / `--success`, default success), and an optional
* `--censoring <value>` run-termination declaration. The counts are always
* measured from the file; only success and censoring are caller-declared. A
* `--transcript`/`--rollout` with no value (or a flag-shaped value like
* `--failed`) is a usage ERROR — it must never be swallowed as the path, nor
* fall through to the stdin hook path where the explicit request to submit a
* file would silently do nothing.
*
* `--censoring` MUST be handled inside this loop: the loop silently ignores
* unrecognised flags, and its bare-positional branch claims the first non-flag
* token when no transcript is set yet — so an unhandled `--censoring natural`
* would swallow `natural` as the transcript path. Its value token is consumed
* even when it is not a valid category (validation is downstream and
* fail-closed to omission); a flag-shaped or missing value simply leaves the
* declaration null — an absent observation, never an error.
*/

@@ -123,0 +141,0 @@ declare function parseOnSessionEndArgs(rest: string[]): OnSessionEndArgs;

{
"name": "@budgetary/mcp",
"version": "0.9.3",
"version": "0.10.0",
"description": "Model Context Protocol server for Budgetary: a portable pre-flight token-spend estimate tool for any MCP-capable host.",

@@ -35,3 +35,3 @@ "mcpName": "io.github.thriftell/budgetary",

"dependencies": {
"@budgetary/sdk": "0.7.2"
"@budgetary/sdk": "0.8.0"
},

@@ -38,0 +38,0 @@ "devDependencies": {

@@ -200,2 +200,4 @@ # @budgetary/mcp

Run it from the directory you estimated in. Add `--failed` if the task didn't complete.
A harness that spawned the session itself — and therefore *observed how the run ended* — can declare that too, with `--censoring <category>`: exactly one of `natural`, `harness_watchdog`, `operative_cap`, `kill_switch` (the API contract's closed vocabulary). The value is matched **exactly** and forwarded verbatim; anything else is omitted from the submission — never normalized into a category, and never an error. Don't pass it for a run whose ending you didn't observe: an absent field honestly records "unknown", while a guessed `natural` is a false claim the server has no way to detect.
- **Cursor / Copilot / other hosts — manual.** These hosts do **not** hand a third-party server the token totals of a completed agent run, and the language model does not know them either. So you record them yourself when you have a moment:

@@ -207,3 +209,3 @@

It shows this project's most recent pending estimate and prompts you for the input/output token counts (read them from your host's usage UI, grouped numbers like `48,000` are fine), whether the task succeeded, and an optional duration.
It shows this project's most recent pending estimate and prompts you for the input/output token counts (read them from your host's usage UI, grouped numbers like `48,000` are fine), whether the task succeeded, an optional duration, and — if you observed it — how the run **ended** (a cap, a watchdog, a deliberate abort, or on its own). Pressing Enter on that last question records nothing for it: "not sure" is a first-class answer, and an unobserved ending is never turned into a value.

@@ -210,0 +212,0 @@ To see which estimates still await actuals at any time (read-only, no server call):

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-GJILMW34.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,
// We submit ONLY for a session whose serving process is gone and whose
// transcript is complete and stable — i.e. one that ran to termination,
// which is the same condition the hook path encodes as success (it counts
// `clear` / `logout` / `prompt_input_exit`, the normal endings). The hook's
// conservative default of `false` is not available to us: it keys off a
// termination reason only the host can report, and defaulting to it would
// stamp `false` on EVERY reconciled run — a systematic label, not a
// measurement. See the PR for the divergence this leaves.
success: true,
// 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