@martinloop/mcp 0.3.7 → 0.3.8
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/LICENSE +216 -0
- package/README.md +153 -137
- package/dist/tools/preflight.d.ts +2 -0
- package/dist/tools/preflight.js +42 -2
- package/dist/tools/tool-support.js +11 -1
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +127 -51
- package/dist/vendor/adapters/codex-launcher.js +10 -1
- package/dist/vendor/contracts/delivery.d.ts +47 -0
- package/dist/vendor/contracts/delivery.js +18 -0
- package/dist/vendor/contracts/index.d.ts +2 -0
- package/dist/vendor/contracts/index.js +2 -0
- package/dist/vendor/core/delivery/index.d.ts +6 -0
- package/dist/vendor/core/delivery/index.js +4 -0
- package/dist/vendor/core/delivery/message-client.d.ts +18 -0
- package/dist/vendor/core/delivery/message-client.js +38 -0
- package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
- package/dist/vendor/core/delivery/message-ledger.js +97 -0
- package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
- package/dist/vendor/core/delivery/message-schema.js +111 -0
- package/dist/vendor/core/delivery/update-check.d.ts +19 -0
- package/dist/vendor/core/delivery/update-check.js +107 -0
- package/dist/vendor/core/index.d.ts +2 -0
- package/dist/vendor/core/index.js +2 -0
- package/package.json +8 -1
- package/server.json +2 -2
|
@@ -17,22 +17,18 @@ import { createAdapterCapabilities, normalizeStructuredErrors, normalizeUsage }
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Cost estimation
|
|
19
19
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// flags or martin.config.yaml pricing section.
|
|
20
|
+
// Streaming enforcement needs a model-specific estimate until Claude emits
|
|
21
|
+
// authoritative total_cost_usd on the final result event. Unknown Claude
|
|
22
|
+
// models are never assigned another model's price.
|
|
24
23
|
// ---------------------------------------------------------------------------
|
|
25
24
|
const BLENDED_INPUT_COST_PER_1K = 0.003; // $/1K input tokens
|
|
26
25
|
const BLENDED_OUTPUT_COST_PER_1K = 0.012; // $/1K output tokens
|
|
27
|
-
//
|
|
26
|
+
// USD per 1K tokens. Claude cache creation uses the documented default
|
|
27
|
+
// five-minute rate; cache reads are priced independently from fresh input.
|
|
28
28
|
const MODEL_PRICING = {
|
|
29
|
-
"claude-opus-4-6": { inputPer1K: 0.
|
|
30
|
-
"claude-sonnet-4-6": { inputPer1K: 0.003, outputPer1K: 0.015 },
|
|
31
|
-
"claude-haiku-4-5": { inputPer1K: 0.
|
|
32
|
-
// Keep legacy names working
|
|
33
|
-
"claude-opus": { inputPer1K: 0.015, outputPer1K: 0.075 },
|
|
34
|
-
"claude-sonnet": { inputPer1K: 0.003, outputPer1K: 0.015 },
|
|
35
|
-
"claude-haiku": { inputPer1K: 0.00025, outputPer1K: 0.00125 },
|
|
29
|
+
"claude-opus-4-6": { inputPer1K: 0.005, cachedInputPer1K: 0.0005, cacheCreationInputPer1K: 0.00625, outputPer1K: 0.025 },
|
|
30
|
+
"claude-sonnet-4-6": { inputPer1K: 0.003, cachedInputPer1K: 0.0003, cacheCreationInputPer1K: 0.00375, outputPer1K: 0.015 },
|
|
31
|
+
"claude-haiku-4-5": { inputPer1K: 0.001, cachedInputPer1K: 0.0001, cacheCreationInputPer1K: 0.00125, outputPer1K: 0.005 },
|
|
36
32
|
// OpenAI coding models
|
|
37
33
|
"codex": { inputPer1K: 0.00125, cachedInputPer1K: 0.000125, outputPer1K: 0.01 },
|
|
38
34
|
"gpt-5-codex": { inputPer1K: 0.00125, cachedInputPer1K: 0.000125, outputPer1K: 0.01 },
|
|
@@ -41,6 +37,37 @@ const MODEL_PRICING = {
|
|
|
41
37
|
"gpt-5.2-codex": { inputPer1K: 0.00175, cachedInputPer1K: 0.000175, outputPer1K: 0.014 },
|
|
42
38
|
"codex-mini-latest": { inputPer1K: 0.0015, cachedInputPer1K: 0.000375, outputPer1K: 0.006 }
|
|
43
39
|
};
|
|
40
|
+
const CLAUDE_MODEL_ALIASES = [
|
|
41
|
+
{ canonicalModelId: "claude-opus-4-6", aliases: [/^claude-opus-4-6-\d{8}$/u, /^claude-opus$/u] },
|
|
42
|
+
{ canonicalModelId: "claude-sonnet-4-6", aliases: [/^claude-sonnet-4-6-\d{8}$/u, /^claude-sonnet$/u] },
|
|
43
|
+
{ canonicalModelId: "claude-haiku-4-5", aliases: [/^claude-haiku-4-5-\d{8}$/u, /^claude-haiku$/u] }
|
|
44
|
+
];
|
|
45
|
+
function resolveModelPricing(modelLabel) {
|
|
46
|
+
const normalized = modelLabel?.trim().toLowerCase();
|
|
47
|
+
if (!normalized) {
|
|
48
|
+
return { status: "unknown" };
|
|
49
|
+
}
|
|
50
|
+
const exact = MODEL_PRICING[normalized];
|
|
51
|
+
if (exact) {
|
|
52
|
+
return { status: "exact", canonicalModelId: normalized, pricing: exact };
|
|
53
|
+
}
|
|
54
|
+
for (const family of CLAUDE_MODEL_ALIASES) {
|
|
55
|
+
if (family.aliases.some((alias) => alias.test(normalized))) {
|
|
56
|
+
return {
|
|
57
|
+
status: "alias",
|
|
58
|
+
canonicalModelId: family.canonicalModelId,
|
|
59
|
+
pricing: MODEL_PRICING[family.canonicalModelId]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { status: "unknown" };
|
|
64
|
+
}
|
|
65
|
+
function calculateUsageCost(usage, pricing) {
|
|
66
|
+
return ((usage.inputTokens / 1000) * pricing.inputPer1K +
|
|
67
|
+
(usage.cachedInputTokens / 1000) * (pricing.cachedInputPer1K ?? pricing.inputPer1K) +
|
|
68
|
+
(usage.cacheCreationInputTokens / 1000) * (pricing.cacheCreationInputPer1K ?? pricing.inputPer1K) +
|
|
69
|
+
(usage.outputTokens / 1000) * pricing.outputPer1K);
|
|
70
|
+
}
|
|
44
71
|
function extractUsage(parsed, modelLabel) {
|
|
45
72
|
if (!parsed?.usage) {
|
|
46
73
|
return normalizeUsage({
|
|
@@ -50,34 +77,38 @@ function extractUsage(parsed, modelLabel) {
|
|
|
50
77
|
provenance: "unavailable"
|
|
51
78
|
});
|
|
52
79
|
}
|
|
53
|
-
const promptTokens =
|
|
54
|
-
|
|
80
|
+
const promptTokens = parsed.usage.inputTokens ?? parsed.usage.input_tokens ?? 0;
|
|
81
|
+
const cacheCreationInputTokens = parsed.usage.cacheCreationInputTokens ?? parsed.usage.cache_creation_input_tokens ?? 0;
|
|
55
82
|
const cachedInputTokens = parsed.usage.cacheReadInputTokens ?? parsed.usage.cache_read_input_tokens ?? 0;
|
|
56
|
-
const tokensIn = promptTokens + cachedInputTokens;
|
|
83
|
+
const tokensIn = promptTokens + cachedInputTokens + cacheCreationInputTokens;
|
|
57
84
|
const tokensOut = parsed.usage.outputTokens ?? parsed.usage.output_tokens ?? 0;
|
|
58
|
-
const
|
|
59
|
-
{ inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
|
|
85
|
+
const pricingResolution = resolveModelPricing(modelLabel);
|
|
60
86
|
// Prefer Claude's own authoritative total_cost_usd (present on the final
|
|
61
87
|
// `result` event in json/stream-json output) over our pricing-table estimate,
|
|
62
88
|
// which can drift from real billed cost (cache discounts, surcharges, etc).
|
|
63
89
|
const hasAuthoritativeCost = typeof parsed.total_cost_usd === "number";
|
|
64
90
|
const actualUsd = hasAuthoritativeCost
|
|
65
91
|
? parsed.total_cost_usd
|
|
66
|
-
:
|
|
67
|
-
|
|
68
|
-
|
|
92
|
+
: pricingResolution.pricing
|
|
93
|
+
? calculateUsageCost({
|
|
94
|
+
inputTokens: promptTokens,
|
|
95
|
+
cachedInputTokens,
|
|
96
|
+
cacheCreationInputTokens,
|
|
97
|
+
outputTokens: tokensOut
|
|
98
|
+
}, pricingResolution.pricing)
|
|
99
|
+
: 0;
|
|
69
100
|
return normalizeUsage({
|
|
70
101
|
actualUsd: Number(actualUsd.toFixed(6)),
|
|
71
102
|
tokensIn,
|
|
72
103
|
tokensOut,
|
|
73
104
|
cachedInputTokens,
|
|
74
|
-
provenance: hasAuthoritativeCost ? "actual" : "estimated",
|
|
105
|
+
provenance: hasAuthoritativeCost ? "actual" : pricingResolution.pricing ? "estimated" : "unavailable",
|
|
75
106
|
providerSettlement: {
|
|
76
107
|
providerId: "claude",
|
|
77
108
|
model: modelLabel ?? "claude",
|
|
78
109
|
transport: "cli",
|
|
79
110
|
source: "claude_json",
|
|
80
|
-
inputTokens: promptTokens,
|
|
111
|
+
inputTokens: promptTokens + cacheCreationInputTokens,
|
|
81
112
|
cachedInputTokens,
|
|
82
113
|
outputTokens: tokensOut,
|
|
83
114
|
rawUsageAvailable: true,
|
|
@@ -239,20 +270,23 @@ function extractGeminiJsonResult(stdout, modelLabel) {
|
|
|
239
270
|
})
|
|
240
271
|
};
|
|
241
272
|
}
|
|
242
|
-
function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
243
|
-
|
|
244
|
-
{ inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
|
|
273
|
+
function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate) {
|
|
274
|
+
let pricingResolution = resolveModelPricing(modelLabel);
|
|
245
275
|
// Safety margin: terminate at 80% of cap to bound one-turn overshoot.
|
|
246
276
|
// Without this, a single expensive turn can blow past the cap before the
|
|
247
277
|
// next check fires (proven live: $1.50 cap → $28.42 actual).
|
|
248
|
-
const
|
|
278
|
+
const largeContext = promptTokenEstimate > 10_000;
|
|
279
|
+
const effectiveCapRatio = largeContext ? 0.7 : 0.8;
|
|
280
|
+
const effectiveCapUsd = capUsd * effectiveCapRatio;
|
|
249
281
|
// Token-count ceiling fallback: if no usage events are ever parsed (e.g.
|
|
250
282
|
// Claude changes its stream-json event format), use raw byte volume as a
|
|
251
283
|
// last-resort circuit breaker. Derived from budget / blended cost per char.
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
284
|
+
const resolveBlendedCostPerChar = () => {
|
|
285
|
+
const pricing = pricingResolution.pricing;
|
|
286
|
+
return pricing
|
|
287
|
+
? (pricing.inputPer1K / 1000 / 4) + (pricing.outputPer1K / 1000 / 4)
|
|
288
|
+
: undefined;
|
|
289
|
+
};
|
|
256
290
|
// Time-based fallback: if we receive data but no usage events for this long,
|
|
257
291
|
// estimate spend from byte volume and enforce the cap. Prevents the inspector
|
|
258
292
|
// from going blind when Claude changes its stream-json event format.
|
|
@@ -269,11 +303,14 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
269
303
|
const checkBudgetExceeded = (terminate) => {
|
|
270
304
|
if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
|
|
271
305
|
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
272
|
-
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (
|
|
306
|
+
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (${String(Math.round(effectiveCapRatio * 100))}% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
|
|
273
307
|
`Subprocess terminated to bound runaway overspend.`);
|
|
274
308
|
}
|
|
275
309
|
};
|
|
276
310
|
const extractUsageFromEvent = (event, terminate) => {
|
|
311
|
+
if (event.type === "system" && event.subtype === "init" && typeof event.model === "string") {
|
|
312
|
+
pricingResolution = resolveModelPricing(event.model);
|
|
313
|
+
}
|
|
277
314
|
// Check for authoritative total_cost_usd on ANY event — if Claude reports
|
|
278
315
|
// cost exceeding cap, terminate immediately regardless of event type.
|
|
279
316
|
if (typeof event.total_cost_usd === "number" && event.total_cost_usd > 0) {
|
|
@@ -293,18 +330,43 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
293
330
|
return;
|
|
294
331
|
}
|
|
295
332
|
const usageRecord = usage;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
333
|
+
const turnInputTokens = usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0;
|
|
334
|
+
const turnCachedInputTokens = usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0;
|
|
335
|
+
const turnCacheCreationInputTokens = usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0;
|
|
336
|
+
const turnTokensIn = turnInputTokens + turnCachedInputTokens + turnCacheCreationInputTokens;
|
|
299
337
|
const turnTokensOut = usageRecord.output_tokens ?? usageRecord.outputTokens ?? 0;
|
|
300
338
|
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
301
339
|
return;
|
|
302
340
|
}
|
|
341
|
+
const turnUsd = pricingResolution.pricing
|
|
342
|
+
? calculateUsageCost({
|
|
343
|
+
inputTokens: turnInputTokens,
|
|
344
|
+
cachedInputTokens: turnCachedInputTokens,
|
|
345
|
+
cacheCreationInputTokens: turnCacheCreationInputTokens,
|
|
346
|
+
outputTokens: turnTokensOut
|
|
347
|
+
}, pricingResolution.pricing)
|
|
348
|
+
: undefined;
|
|
349
|
+
const remainingBudgetBeforeTurn = Math.max(capUsd - cumulativeUsd, 0);
|
|
350
|
+
if (turnUsd !== undefined &&
|
|
351
|
+
capUsd > 0 &&
|
|
352
|
+
remainingBudgetBeforeTurn > 0 &&
|
|
353
|
+
turnUsd > remainingBudgetBeforeTurn * 0.5) {
|
|
354
|
+
cumulativeUsd += turnUsd;
|
|
355
|
+
tokensIn += turnTokensIn;
|
|
356
|
+
tokensOut += turnTokensOut;
|
|
357
|
+
turns += 1;
|
|
358
|
+
usageEventSeen = true;
|
|
359
|
+
terminate(`Single turn spend ~$${turnUsd.toFixed(4)} consumed more than 50% of the remaining per-attempt budget ` +
|
|
360
|
+
`($${remainingBudgetBeforeTurn.toFixed(4)} before the turn). Subprocess terminated to prevent a one-turn overshoot.`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
303
363
|
tokensIn += turnTokensIn;
|
|
304
364
|
tokensOut += turnTokensOut;
|
|
305
365
|
turns += 1;
|
|
306
366
|
usageEventSeen = true;
|
|
307
|
-
|
|
367
|
+
if (turnUsd !== undefined) {
|
|
368
|
+
cumulativeUsd += turnUsd;
|
|
369
|
+
}
|
|
308
370
|
checkBudgetExceeded(terminate);
|
|
309
371
|
};
|
|
310
372
|
const ingestLine = (line, terminate) => {
|
|
@@ -319,10 +381,14 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
319
381
|
catch {
|
|
320
382
|
return;
|
|
321
383
|
}
|
|
322
|
-
|
|
384
|
+
// result events contain aggregate usage that duplicates previously streamed
|
|
385
|
+
// assistant-message usage events — skip to avoid double-counting.
|
|
323
386
|
if (event.type === "result") {
|
|
324
387
|
finalResult = event;
|
|
325
388
|
}
|
|
389
|
+
else {
|
|
390
|
+
extractUsageFromEvent(event, terminate);
|
|
391
|
+
}
|
|
326
392
|
};
|
|
327
393
|
return {
|
|
328
394
|
onChunk: (chunk, terminate) => {
|
|
@@ -333,7 +399,11 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
333
399
|
// Token-count ceiling fallback: if we've ingested a lot of bytes but
|
|
334
400
|
// never seen a single usage event, the event format may have changed
|
|
335
401
|
// and the inspector is silently blind. Terminate as a last resort.
|
|
336
|
-
|
|
402
|
+
const blendedCostPerChar = resolveBlendedCostPerChar();
|
|
403
|
+
const bytesCeiling = blendedCostPerChar && blendedCostPerChar > 0
|
|
404
|
+
? Math.ceil((capUsd / blendedCostPerChar) * 2)
|
|
405
|
+
: undefined;
|
|
406
|
+
if (!usageEventSeen && capUsd > 0 && bytesCeiling !== undefined && totalBytes > bytesCeiling) {
|
|
337
407
|
terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` +
|
|
338
408
|
`without any usage events parsed. The Claude stream-json event format may have changed. ` +
|
|
339
409
|
`Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
|
|
@@ -348,8 +418,8 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
348
418
|
firstChunkAt !== undefined &&
|
|
349
419
|
Date.now() - firstChunkAt > USAGE_BLIND_TIMEOUT_MS &&
|
|
350
420
|
totalBytes > 10_000) {
|
|
351
|
-
const estimatedUsd = totalBytes * blendedCostPerChar;
|
|
352
|
-
if (estimatedUsd > effectiveCapUsd) {
|
|
421
|
+
const estimatedUsd = blendedCostPerChar === undefined ? undefined : totalBytes * blendedCostPerChar;
|
|
422
|
+
if (estimatedUsd !== undefined && estimatedUsd > effectiveCapUsd) {
|
|
353
423
|
terminate(`No usage events received after ${String(Math.round((Date.now() - firstChunkAt) / 1000))}s ` +
|
|
354
424
|
`(${String(totalBytes)} bytes). Estimated cost ~$${estimatedUsd.toFixed(4)} exceeds cap ` +
|
|
355
425
|
`$${capUsd.toFixed(4)}. Subprocess terminated to prevent unmetered spend.`);
|
|
@@ -443,11 +513,11 @@ export function createAgentCliAdapter(options) {
|
|
|
443
513
|
},
|
|
444
514
|
async execute(request) {
|
|
445
515
|
const prompt = buildPrompt(request);
|
|
446
|
-
const estimatedUsage = estimateUsage(prompt, options.model ?? options.command);
|
|
516
|
+
const estimatedUsage = estimateUsage(prompt, options.model ?? options.command, options.command);
|
|
447
517
|
// Preflight: bail if projected cost exceeds remaining budget
|
|
448
518
|
if (request.context.remainingBudgetUsd > 0) {
|
|
449
|
-
const projected = estimatePromptCost(prompt, options.model ?? "");
|
|
450
|
-
if (projected > request.context.remainingBudgetUsd * 0.95) {
|
|
519
|
+
const projected = estimatePromptCost(prompt, options.model ?? "", options.command);
|
|
520
|
+
if (projected !== undefined && projected > request.context.remainingBudgetUsd * 0.95) {
|
|
451
521
|
return {
|
|
452
522
|
status: "failed",
|
|
453
523
|
summary: `Preflight: projected cost $${projected.toFixed(4)} exceeds remaining budget $${request.context.remainingBudgetUsd.toFixed(4)}.`,
|
|
@@ -463,7 +533,7 @@ export function createAgentCliAdapter(options) {
|
|
|
463
533
|
};
|
|
464
534
|
}
|
|
465
535
|
}
|
|
466
|
-
const args = options.argsBuilder(prompt);
|
|
536
|
+
const args = options.argsBuilder(prompt, request);
|
|
467
537
|
const stdinData = options.stdinBuilder?.(prompt);
|
|
468
538
|
// Live cumulative-cost circuit breaker: a single attempt should never be
|
|
469
539
|
// allowed to spend more than the loop has left. `--output-format json`
|
|
@@ -472,7 +542,7 @@ export function createAgentCliAdapter(options) {
|
|
|
472
542
|
// instant projected spend crosses what remains — bounding the worst case
|
|
473
543
|
// to roughly one turn's overshoot rather than the entire runaway session.
|
|
474
544
|
const streamingUsage = options.streamingUsageCap && request.context.remainingBudgetUsd > 0
|
|
475
|
-
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command)
|
|
545
|
+
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn)
|
|
476
546
|
: undefined;
|
|
477
547
|
const agentResult = await runSubprocess(options.command, args, {
|
|
478
548
|
cwd: workingDirectory,
|
|
@@ -797,7 +867,7 @@ export function createClaudeCliAdapter(options = {}) {
|
|
|
797
867
|
supportsJsonOutput: true,
|
|
798
868
|
streamingUsageCap: true,
|
|
799
869
|
spawnImpl: options.spawnImpl,
|
|
800
|
-
argsBuilder: (_prompt) => [
|
|
870
|
+
argsBuilder: (_prompt, request) => [
|
|
801
871
|
"--output-format",
|
|
802
872
|
"stream-json",
|
|
803
873
|
"--verbose",
|
|
@@ -1026,21 +1096,27 @@ function sanitizeForPrompt(input) {
|
|
|
1026
1096
|
}
|
|
1027
1097
|
return redactSecretsForPrompt(out);
|
|
1028
1098
|
}
|
|
1029
|
-
function estimatePromptCost(promptText, model) {
|
|
1099
|
+
function estimatePromptCost(promptText, model, providerCommand) {
|
|
1030
1100
|
const inputTokens = Math.ceil(promptText.length / 3.5);
|
|
1031
1101
|
const outputTokens = 2000;
|
|
1032
|
-
const pricing =
|
|
1102
|
+
const pricing = resolveModelPricing(model).pricing ?? (providerCommand === "claude"
|
|
1103
|
+
? undefined
|
|
1104
|
+
: { inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K });
|
|
1105
|
+
if (!pricing) {
|
|
1106
|
+
return undefined;
|
|
1107
|
+
}
|
|
1033
1108
|
return (inputTokens / 1000) * pricing.inputPer1K + (outputTokens / 1000) * pricing.outputPer1K;
|
|
1034
1109
|
}
|
|
1035
|
-
function estimateUsage(promptText, model) {
|
|
1110
|
+
function estimateUsage(promptText, model, providerCommand) {
|
|
1036
1111
|
const inputTokens = Math.ceil(promptText.length / 3.5);
|
|
1037
1112
|
const outputTokens = 2_000;
|
|
1113
|
+
const estimatedUsd = estimatePromptCost(promptText, model, providerCommand);
|
|
1038
1114
|
return normalizeUsage({
|
|
1039
|
-
actualUsd:
|
|
1040
|
-
estimatedUsd:
|
|
1115
|
+
actualUsd: estimatedUsd ?? 0,
|
|
1116
|
+
...(estimatedUsd === undefined ? {} : { estimatedUsd }),
|
|
1041
1117
|
tokensIn: inputTokens,
|
|
1042
1118
|
tokensOut: outputTokens,
|
|
1043
|
-
provenance: "estimated"
|
|
1119
|
+
provenance: estimatedUsd === undefined ? "unavailable" : "estimated"
|
|
1044
1120
|
});
|
|
1045
1121
|
}
|
|
1046
1122
|
function redactSecretsForPrompt(input) {
|
|
@@ -357,6 +357,10 @@ function discoverCommandOffPath(command, platform, env) {
|
|
|
357
357
|
const localAppData = env.LOCALAPPDATA;
|
|
358
358
|
if (localAppData)
|
|
359
359
|
dirs.push(join(localAppData, "OpenAI", "Codex", "bin"));
|
|
360
|
+
// Claude Code native installer places binary at %USERPROFILE%\.local\bin
|
|
361
|
+
const userProfile = env.USERPROFILE ?? env.HOMEPATH;
|
|
362
|
+
if (userProfile)
|
|
363
|
+
dirs.push(join(userProfile, ".local", "bin"));
|
|
360
364
|
if (home)
|
|
361
365
|
dirs.push(join(home, "scoop", "shims"));
|
|
362
366
|
}
|
|
@@ -383,8 +387,13 @@ function discoverCommandOffPath(command, platform, env) {
|
|
|
383
387
|
return undefined;
|
|
384
388
|
}
|
|
385
389
|
function suggestInstall(command) {
|
|
390
|
+
if (command === "claude") {
|
|
391
|
+
const installCmd = process.platform === "win32"
|
|
392
|
+
? "irm https://claude.ai/install.ps1 | iex"
|
|
393
|
+
: "curl -fsSL https://claude.ai/install.sh | bash";
|
|
394
|
+
return `Install with: ${installCmd}`;
|
|
395
|
+
}
|
|
386
396
|
const installs = {
|
|
387
|
-
claude: "Install with: npm install -g @anthropic-ai/claude-code",
|
|
388
397
|
codex: "Install with: npm install -g @openai/codex",
|
|
389
398
|
gemini: "Install with: npm install -g @google/gemini-cli"
|
|
390
399
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4 Delivery — M1 Contract
|
|
3
|
+
*
|
|
4
|
+
* Shared with Lane B (Control Plane). Do not modify unilaterally.
|
|
5
|
+
* The server selects one message; the client never downloads the catalog.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DELIVERY_MESSAGE_SCHEMA_VERSION: "delivery-message/1";
|
|
8
|
+
export declare const DELIVERY_RECORD_SCHEMA_VERSION: "delivery-record/1";
|
|
9
|
+
export declare const MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION: "martin-message-selection/1";
|
|
10
|
+
export declare const ALLOWED_ACTION_TYPES: readonly ["upgrade_cli", "upgrade_mcp", "submit_feedback", "open_release_notes", "dismiss", "view_spend_report", "view_run_explain"];
|
|
11
|
+
export type ActionType = (typeof ALLOWED_ACTION_TYPES)[number];
|
|
12
|
+
export type MessageKind = "update" | "feedback_request" | "milestone";
|
|
13
|
+
export interface DeliveryMessage {
|
|
14
|
+
schemaVersion: typeof DELIVERY_MESSAGE_SCHEMA_VERSION;
|
|
15
|
+
id: string;
|
|
16
|
+
revision: number;
|
|
17
|
+
kind: MessageKind;
|
|
18
|
+
title: string;
|
|
19
|
+
body: string;
|
|
20
|
+
action: {
|
|
21
|
+
type: ActionType;
|
|
22
|
+
url?: string;
|
|
23
|
+
targetVersion?: string;
|
|
24
|
+
};
|
|
25
|
+
expiresAt: string;
|
|
26
|
+
cooldownHours: number;
|
|
27
|
+
}
|
|
28
|
+
export interface MessageSelectionResponse {
|
|
29
|
+
schemaVersion: typeof MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION;
|
|
30
|
+
message?: DeliveryMessage;
|
|
31
|
+
}
|
|
32
|
+
/** Local cooldown/dismissal state — persisted in .martin/delivery-record.json */
|
|
33
|
+
export interface DeliveryRecord {
|
|
34
|
+
schemaVersion: typeof DELIVERY_RECORD_SCHEMA_VERSION;
|
|
35
|
+
lastMessageId?: string;
|
|
36
|
+
lastShownAtEpochMs?: number;
|
|
37
|
+
dismissedIds: string[];
|
|
38
|
+
cooldownUntilEpochMs?: number;
|
|
39
|
+
cachedMessage?: DeliveryMessage;
|
|
40
|
+
cachedAtEpochMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/** Structured update field for --json and MCP responses */
|
|
43
|
+
export interface UpdateAvailableField {
|
|
44
|
+
targetVersion: string;
|
|
45
|
+
kind: "cli" | "mcp";
|
|
46
|
+
message?: string;
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R4 Delivery — M1 Contract
|
|
3
|
+
*
|
|
4
|
+
* Shared with Lane B (Control Plane). Do not modify unilaterally.
|
|
5
|
+
* The server selects one message; the client never downloads the catalog.
|
|
6
|
+
*/
|
|
7
|
+
export const DELIVERY_MESSAGE_SCHEMA_VERSION = "delivery-message/1";
|
|
8
|
+
export const DELIVERY_RECORD_SCHEMA_VERSION = "delivery-record/1";
|
|
9
|
+
export const MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION = "martin-message-selection/1";
|
|
10
|
+
export const ALLOWED_ACTION_TYPES = [
|
|
11
|
+
"upgrade_cli",
|
|
12
|
+
"upgrade_mcp",
|
|
13
|
+
"submit_feedback",
|
|
14
|
+
"open_release_notes",
|
|
15
|
+
"dismiss",
|
|
16
|
+
"view_spend_report",
|
|
17
|
+
"view_run_explain",
|
|
18
|
+
];
|
|
@@ -410,3 +410,5 @@ export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } fro
|
|
|
410
410
|
export type { IdentityAttestation, IdentityClaims, IdentityToken } from "./identity.js";
|
|
411
411
|
export { cloneCircuitBreakDecision, cloneTrajectoryAssessment } from "./trajectory.js";
|
|
412
412
|
export type { CircuitBreakDecision, TrajectoryAssessment, TrajectorySignal } from "./trajectory.js";
|
|
413
|
+
export { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, DELIVERY_RECORD_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION } from "./delivery.js";
|
|
414
|
+
export type { ActionType, DeliveryMessage, DeliveryRecord, MessageKind, MessageSelectionResponse, UpdateAvailableField } from "./delivery.js";
|
|
@@ -223,3 +223,5 @@ export { DEFAULT_ROUTING_POLICY } from "./execution-policy.js";
|
|
|
223
223
|
export { cloneContextGraphSnapshot } from "./context-graph.js";
|
|
224
224
|
export { cloneIdentityAttestation, cloneIdentityClaims, cloneIdentityToken } from "./identity.js";
|
|
225
225
|
export { cloneCircuitBreakDecision, cloneTrajectoryAssessment } from "./trajectory.js";
|
|
226
|
+
// ─── R4 Delivery — M1 Contract ──────────────────────────────────────────────
|
|
227
|
+
export { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, DELIVERY_RECORD_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION } from "./delivery.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { fetchSelectedMessage } from "./message-client.js";
|
|
2
|
+
export type { MessageClientOptions, MessageSelectRequest } from "./message-client.js";
|
|
3
|
+
export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js";
|
|
4
|
+
export { parseMessageSelectionResponse } from "./message-schema.js";
|
|
5
|
+
export type { ParseFailure, ParseResult, ValidationError } from "./message-schema.js";
|
|
6
|
+
export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { fetchSelectedMessage } from "./message-client.js";
|
|
2
|
+
export { cacheMessage, isCooldownExpired, isDismissed, loadDeliveryRecord, recordDismissed, recordShown, resolveDefaultLedgerPath, saveDeliveryRecord, } from "./message-ledger.js";
|
|
3
|
+
export { parseMessageSelectionResponse } from "./message-schema.js";
|
|
4
|
+
export { getCliInstalledVersion, getMcpInstalledVersion, isNewerVersion } from "./update-check.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type DeliveryMessage } from "../../contracts/index.js";
|
|
2
|
+
export interface MessageSelectRequest {
|
|
3
|
+
installId?: string;
|
|
4
|
+
clientVersion: string;
|
|
5
|
+
clientKind: "cli" | "mcp";
|
|
6
|
+
/** Enum signal — never a transcript or raw content */
|
|
7
|
+
trigger?: "first_verified_run" | "milestone_reached" | "version_check";
|
|
8
|
+
}
|
|
9
|
+
export interface MessageClientOptions {
|
|
10
|
+
endpoint?: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fetch a server-selected message from the Control Plane.
|
|
15
|
+
* Returns null on any network failure, timeout, or validation error.
|
|
16
|
+
* Never throws — the primary command must never be affected.
|
|
17
|
+
*/
|
|
18
|
+
export declare function fetchSelectedMessage(request: MessageSelectRequest, options?: MessageClientOptions): Promise<DeliveryMessage | null>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseMessageSelectionResponse } from "./message-schema.js";
|
|
2
|
+
const DEFAULT_ENDPOINT = "https://api.martinloop.com/v1/messages/select";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 4_000;
|
|
4
|
+
/**
|
|
5
|
+
* Fetch a server-selected message from the Control Plane.
|
|
6
|
+
* Returns null on any network failure, timeout, or validation error.
|
|
7
|
+
* Never throws — the primary command must never be affected.
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchSelectedMessage(request, options = {}) {
|
|
10
|
+
const endpoint = options.endpoint ?? process.env["MARTIN_MESSAGE_ENDPOINT"] ?? DEFAULT_ENDPOINT;
|
|
11
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
12
|
+
let controller;
|
|
13
|
+
let timeoutId;
|
|
14
|
+
try {
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
17
|
+
const response = await fetch(endpoint, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
return null;
|
|
25
|
+
const raw = await response.text();
|
|
26
|
+
const result = parseMessageSelectionResponse(raw);
|
|
27
|
+
if (!result.ok)
|
|
28
|
+
return null;
|
|
29
|
+
return result.message ?? null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (timeoutId !== undefined)
|
|
36
|
+
clearTimeout(timeoutId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type DeliveryMessage, type DeliveryRecord } from "../../contracts/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws.
|
|
4
|
+
*/
|
|
5
|
+
export declare function loadDeliveryRecord(ledgerPath: string): DeliveryRecord;
|
|
6
|
+
/**
|
|
7
|
+
* Persist DeliveryRecord atomically via temp-file + rename.
|
|
8
|
+
*/
|
|
9
|
+
export declare function saveDeliveryRecord(ledgerPath: string, record: DeliveryRecord): void;
|
|
10
|
+
/**
|
|
11
|
+
* Returns true when the cooldown period has elapsed and the message should be shown.
|
|
12
|
+
* Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isCooldownExpired(record: DeliveryRecord, nowMs: number): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Returns true if this message id was permanently dismissed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isDismissed(record: DeliveryRecord, messageId: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Record that a message was shown. Updates cooldown window.
|
|
21
|
+
*/
|
|
22
|
+
export declare function recordShown(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord;
|
|
23
|
+
/**
|
|
24
|
+
* Record that a message was permanently dismissed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recordDismissed(record: DeliveryRecord, messageId: string): DeliveryRecord;
|
|
27
|
+
/**
|
|
28
|
+
* Cache a fetched message for offline use.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cacheMessage(record: DeliveryRecord, message: DeliveryMessage, nowMs: number): DeliveryRecord;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json
|
|
33
|
+
* or ~/.martin/delivery-record.json.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveDefaultLedgerPath(): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DELIVERY_RECORD_SCHEMA_VERSION } from "../../contracts/index.js";
|
|
5
|
+
const EMPTY_RECORD = {
|
|
6
|
+
schemaVersion: DELIVERY_RECORD_SCHEMA_VERSION,
|
|
7
|
+
dismissedIds: [],
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Load DeliveryRecord from disk. Returns empty record on any read/parse failure — never throws.
|
|
11
|
+
*/
|
|
12
|
+
export function loadDeliveryRecord(ledgerPath) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(ledgerPath, "utf8");
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (typeof parsed === "object" &&
|
|
17
|
+
parsed !== null &&
|
|
18
|
+
!Array.isArray(parsed) &&
|
|
19
|
+
parsed["schemaVersion"] === DELIVERY_RECORD_SCHEMA_VERSION) {
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// file missing or corrupt — start fresh
|
|
25
|
+
}
|
|
26
|
+
return { ...EMPTY_RECORD, dismissedIds: [] };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Persist DeliveryRecord atomically via temp-file + rename.
|
|
30
|
+
*/
|
|
31
|
+
export function saveDeliveryRecord(ledgerPath, record) {
|
|
32
|
+
const dir = path.dirname(ledgerPath);
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
const tmp = path.join(os.tmpdir(), `martin-dlv-${process.pid}-${Date.now()}.json`);
|
|
35
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), "utf8");
|
|
36
|
+
fs.renameSync(tmp, ledgerPath);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns true when the cooldown period has elapsed and the message should be shown.
|
|
40
|
+
* Clock-skew safe: if now < lastShownAtEpochMs (clock jumped back), treat cooldown as expired.
|
|
41
|
+
*/
|
|
42
|
+
export function isCooldownExpired(record, nowMs) {
|
|
43
|
+
const until = record.cooldownUntilEpochMs;
|
|
44
|
+
if (until === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
const last = record.lastShownAtEpochMs ?? 0;
|
|
47
|
+
// If clock appears to have gone backwards past last-shown, treat as expired
|
|
48
|
+
if (nowMs < last)
|
|
49
|
+
return true;
|
|
50
|
+
return nowMs >= until;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Returns true if this message id was permanently dismissed.
|
|
54
|
+
*/
|
|
55
|
+
export function isDismissed(record, messageId) {
|
|
56
|
+
return record.dismissedIds.includes(messageId);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Record that a message was shown. Updates cooldown window.
|
|
60
|
+
*/
|
|
61
|
+
export function recordShown(record, message, nowMs) {
|
|
62
|
+
return {
|
|
63
|
+
...record,
|
|
64
|
+
lastMessageId: message.id,
|
|
65
|
+
lastShownAtEpochMs: nowMs,
|
|
66
|
+
cooldownUntilEpochMs: nowMs + message.cooldownHours * 60 * 60 * 1000,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Record that a message was permanently dismissed.
|
|
71
|
+
*/
|
|
72
|
+
export function recordDismissed(record, messageId) {
|
|
73
|
+
if (record.dismissedIds.includes(messageId))
|
|
74
|
+
return record;
|
|
75
|
+
return {
|
|
76
|
+
...record,
|
|
77
|
+
dismissedIds: [...record.dismissedIds, messageId],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Cache a fetched message for offline use.
|
|
82
|
+
*/
|
|
83
|
+
export function cacheMessage(record, message, nowMs) {
|
|
84
|
+
return {
|
|
85
|
+
...record,
|
|
86
|
+
cachedMessage: message,
|
|
87
|
+
cachedAtEpochMs: nowMs,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the default ledger path: $MARTIN_STATE_DIR/delivery-record.json
|
|
92
|
+
* or ~/.martin/delivery-record.json.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveDefaultLedgerPath() {
|
|
95
|
+
const stateDir = process.env["MARTIN_STATE_DIR"] ?? path.join(os.homedir(), ".martin");
|
|
96
|
+
return path.join(stateDir, "delivery-record.json");
|
|
97
|
+
}
|