@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-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, |
+24
-6
@@ -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; |
+2
-2
| { | ||
| "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": { |
+3
-1
@@ -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
749064
1.08%19656
0.72%248
0.81%+ Added
- Removed
Updated