@martinloop/mcp 0.3.9 → 0.5.1
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/README.md +82 -141
- package/dist/discovery-metadata.d.ts +1 -1
- package/dist/discovery-metadata.js +1 -1
- package/dist/resources.d.ts +1 -2
- package/dist/resources.js +62 -98
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +16 -11
- package/dist/server.d.ts +23 -0
- package/dist/server.js +192 -15
- package/dist/tools/doctor.js +5 -4
- package/dist/tools/get-status.d.ts +2 -0
- package/dist/tools/get-status.js +1 -0
- package/dist/tools/preflight.d.ts +2 -0
- package/dist/tools/preflight.js +43 -3
- package/dist/tools/run-dossier.d.ts +3 -1
- package/dist/tools/run-dossier.js +40 -2
- package/dist/tools/run-loop.d.ts +5 -3
- package/dist/tools/run-loop.js +15 -9
- package/dist/tools/tool-errors.js +1 -1
- package/dist/tools/tool-response.js +9 -2
- package/dist/tools/tool-support.d.ts +4 -2
- package/dist/tools/tool-support.js +6 -15
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +97 -69
- package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
- package/dist/vendor/adapters/cli-bridge.js +54 -13
- package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
- package/dist/vendor/adapters/codex-launcher.js +118 -19
- package/dist/vendor/adapters/index.d.ts +2 -4
- package/dist/vendor/adapters/index.js +2 -4
- package/dist/vendor/adapters/openai-compatible.d.ts +2 -3
- package/dist/vendor/adapters/openai-compatible.js +80 -15
- package/dist/vendor/adapters/verifier-only.js +9 -4
- package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
- package/dist/vendor/contracts/context-handoff.d.ts +100 -0
- package/dist/vendor/contracts/context-handoff.js +11 -0
- package/dist/vendor/contracts/context-manifest.d.ts +195 -0
- package/dist/vendor/contracts/context-manifest.js +17 -0
- package/dist/vendor/contracts/context-shadow.d.ts +75 -0
- package/dist/vendor/contracts/context-shadow.js +8 -0
- package/dist/vendor/contracts/delivery.d.ts +47 -0
- package/dist/vendor/contracts/delivery.js +18 -0
- package/dist/vendor/contracts/exits.d.ts +126 -0
- package/dist/vendor/contracts/exits.js +32 -0
- package/dist/vendor/contracts/index.d.ts +55 -6
- package/dist/vendor/contracts/index.js +24 -0
- package/dist/vendor/contracts/mission.d.ts +102 -0
- package/dist/vendor/contracts/mission.js +64 -0
- package/dist/vendor/contracts/operator.d.ts +3 -1
- package/dist/vendor/contracts/operator.js +2 -1
- package/dist/vendor/contracts/verified-handoff.d.ts +106 -0
- package/dist/vendor/contracts/verified-handoff.js +40 -0
- package/dist/vendor/core/context-chain-gate.d.ts +89 -0
- package/dist/vendor/core/context-chain-gate.js +173 -0
- package/dist/vendor/core/context-compiler.d.ts +53 -0
- package/dist/vendor/core/context-compiler.js +261 -0
- package/dist/vendor/core/context-handoff.d.ts +42 -0
- package/dist/vendor/core/context-handoff.js +167 -0
- package/dist/vendor/core/context-shadow.d.ts +39 -0
- package/dist/vendor/core/context-shadow.js +200 -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/exit-signal.d.ts +88 -0
- package/dist/vendor/core/exit-signal.js +339 -0
- package/dist/vendor/core/exits.d.ts +41 -0
- package/dist/vendor/core/exits.js +258 -0
- package/dist/vendor/core/grounding.d.ts +2 -1
- package/dist/vendor/core/grounding.js +40 -5
- package/dist/vendor/core/index.d.ts +47 -6
- package/dist/vendor/core/index.js +1102 -670
- package/dist/vendor/core/mission/aggregate.d.ts +27 -0
- package/dist/vendor/core/mission/aggregate.js +42 -0
- package/dist/vendor/core/mission/index.d.ts +2 -0
- package/dist/vendor/core/mission/index.js +1 -0
- package/dist/vendor/core/persistence/compiler.d.ts +8 -0
- package/dist/vendor/core/persistence/compiler.js +156 -0
- package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
- package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
- package/dist/vendor/core/persistence/index.d.ts +4 -0
- package/dist/vendor/core/persistence/index.js +2 -0
- package/dist/vendor/core/persistence/ledger.d.ts +1 -1
- package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
- package/dist/vendor/core/persistence/mission-store.js +281 -0
- package/dist/vendor/core/persistence/runs-reader.d.ts +2 -0
- package/dist/vendor/core/policy.d.ts +2 -0
- package/dist/vendor/core/policy.js +11 -8
- package/dist/vendor/core/red-blue/red-phase.d.ts +2 -1
- package/dist/vendor/core/red-blue/red-phase.js +3 -3
- package/dist/vendor/core/red-blue/risk-tiers.d.ts +2 -4
- package/dist/vendor/core/red-blue/risk-tiers.js +1 -3
- package/dist/vendor/core/rollback.js +15 -1
- package/dist/vendor/core/routing.d.ts +0 -42
- package/dist/vendor/core/routing.js +4 -154
- package/dist/vendor/core/savings.d.ts +13 -0
- package/dist/vendor/core/savings.js +22 -0
- package/dist/vendor/core/termination-store.d.ts +15 -0
- package/dist/vendor/core/termination-store.js +44 -0
- package/dist/vendor/core/verified-handoff.d.ts +72 -0
- package/dist/vendor/core/verified-handoff.js +238 -0
- package/package.json +4 -10
- package/server.json +3 -3
- package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
- package/dist/vendor/adapters/stub-agent-cli.js +0 -40
- package/dist/vendor/adapters/stub-direct-provider.d.ts +0 -8
- package/dist/vendor/adapters/stub-direct-provider.js +0 -12
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Handoff Verifier — A-CTX-2
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for verifying upstream-to-downstream context handoffs and
|
|
5
|
+
* deciding whether to circuit-break before the downstream agent executes.
|
|
6
|
+
*
|
|
7
|
+
* Standing rules:
|
|
8
|
+
* - These functions are pure and side-effect-free.
|
|
9
|
+
* - verifyContextHandoff fails closed — any ambiguity → not ok.
|
|
10
|
+
* - decideContextCircuitBreak is the sole gate before adapter execution.
|
|
11
|
+
* - The downstream adapter MUST NOT be called when shouldStop is true.
|
|
12
|
+
*/
|
|
13
|
+
import { HANDOFF_SCHEMA_VERSION } from "../contracts/index.js";
|
|
14
|
+
// ─── Supported schema versions ────────────────────────────────────────────────
|
|
15
|
+
const SUPPORTED_SCHEMAS = new Set([HANDOFF_SCHEMA_VERSION]);
|
|
16
|
+
/**
|
|
17
|
+
* Deterministically verifies a context handoff.
|
|
18
|
+
*
|
|
19
|
+
* Returns ok=false when ANY of the following is true:
|
|
20
|
+
* - schemaVersion is not in SUPPORTED_SCHEMAS
|
|
21
|
+
* - producerReceiptVerified is false
|
|
22
|
+
* - upstreamIntegrity is not "verified"
|
|
23
|
+
* - a required artifact is absent from availableArtifacts
|
|
24
|
+
* - an artifact sha256 does not appear in availableArtifacts (hash changed)
|
|
25
|
+
* - any required claim has state "unverified" or "rejected"
|
|
26
|
+
* - any claim has state "unknown"
|
|
27
|
+
* - unresolvedAssumptions is non-empty
|
|
28
|
+
* - handoffId, chainId, producerRunId, or producerReceiptHash is blank
|
|
29
|
+
*/
|
|
30
|
+
export function verifyContextHandoff(input) {
|
|
31
|
+
const { handoff, producerReceiptVerified, availableArtifacts } = input;
|
|
32
|
+
const reasons = [];
|
|
33
|
+
// ── Schema ──────────────────────────────────────────────────────────────────
|
|
34
|
+
if (!SUPPORTED_SCHEMAS.has(handoff.schemaVersion)) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
integrity: "unsupported_schema",
|
|
38
|
+
reasons: [
|
|
39
|
+
{
|
|
40
|
+
code: "unsupported_schema",
|
|
41
|
+
message: `Handoff schemaVersion "${handoff.schemaVersion}" is not supported. Supported: ${[...SUPPORTED_SCHEMAS].join(", ")}.`
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ── Required identity fields ─────────────────────────────────────────────────
|
|
47
|
+
if (!isPresent(handoff.handoffId)) {
|
|
48
|
+
reasons.push({ code: "missing_handoff_id", message: "handoffId is required." });
|
|
49
|
+
}
|
|
50
|
+
if (!isPresent(handoff.chainId)) {
|
|
51
|
+
reasons.push({ code: "missing_chain_id", message: "chainId is required." });
|
|
52
|
+
}
|
|
53
|
+
if (!isPresent(handoff.producerRunId)) {
|
|
54
|
+
reasons.push({ code: "missing_producer_run_id", message: "producerRunId is required." });
|
|
55
|
+
}
|
|
56
|
+
if (!isPresent(handoff.producerReceiptHash)) {
|
|
57
|
+
reasons.push({ code: "missing_producer_receipt_hash", message: "producerReceiptHash is required." });
|
|
58
|
+
}
|
|
59
|
+
// ── Producer receipt verification ────────────────────────────────────────────
|
|
60
|
+
if (!producerReceiptVerified) {
|
|
61
|
+
reasons.push({
|
|
62
|
+
code: "producer_receipt_unverified",
|
|
63
|
+
message: "The producer receipt hash could not be independently verified."
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
// ── Upstream integrity propagation ───────────────────────────────────────────
|
|
67
|
+
if (handoff.upstreamIntegrity !== "verified") {
|
|
68
|
+
reasons.push({
|
|
69
|
+
code: `upstream_integrity_${handoff.upstreamIntegrity}`,
|
|
70
|
+
message: `Upstream integrity is "${handoff.upstreamIntegrity}" — cannot proceed.`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// ── Artifact verification ────────────────────────────────────────────────────
|
|
74
|
+
for (const artifact of handoff.artifacts) {
|
|
75
|
+
const present = availableArtifacts.has(artifact.sha256);
|
|
76
|
+
if (artifact.required && !present) {
|
|
77
|
+
reasons.push({
|
|
78
|
+
code: "missing_required_artifact",
|
|
79
|
+
message: `Required artifact is absent (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`,
|
|
80
|
+
artifactSha256: artifact.sha256
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else if (!present) {
|
|
84
|
+
reasons.push({
|
|
85
|
+
code: "artifact_hash_mismatch",
|
|
86
|
+
message: `Artifact hash not found in available set (sha256: ${artifact.sha256}${artifact.label ? `, label: ${artifact.label}` : ""}).`,
|
|
87
|
+
artifactSha256: artifact.sha256
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ── Claim verification ────────────────────────────────────────────────────────
|
|
92
|
+
for (const claim of handoff.claims) {
|
|
93
|
+
if (claim.verificationState === "rejected") {
|
|
94
|
+
reasons.push({
|
|
95
|
+
code: "claim_rejected",
|
|
96
|
+
message: `Claim "${claim.claimId}" was rejected: ${claim.statement}`,
|
|
97
|
+
claimId: claim.claimId
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else if (claim.verificationState === "unverified") {
|
|
101
|
+
reasons.push({
|
|
102
|
+
code: "claim_unverified",
|
|
103
|
+
message: `Claim "${claim.claimId}" is unverified: ${claim.statement}`,
|
|
104
|
+
claimId: claim.claimId
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else if (claim.verificationState === "unknown") {
|
|
108
|
+
reasons.push({
|
|
109
|
+
code: "claim_unknown",
|
|
110
|
+
message: `Claim "${claim.claimId}" has unknown verification state: ${claim.statement}`,
|
|
111
|
+
claimId: claim.claimId
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── Unresolved assumptions ────────────────────────────────────────────────────
|
|
116
|
+
if (handoff.unresolvedAssumptions.length > 0) {
|
|
117
|
+
reasons.push({
|
|
118
|
+
code: "unresolved_assumptions",
|
|
119
|
+
message: `${handoff.unresolvedAssumptions.length} assumption(s) remain unresolved: ${handoff.unresolvedAssumptions.slice(0, 3).join("; ")}${handoff.unresolvedAssumptions.length > 3 ? " …" : ""}`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const ok = reasons.length === 0;
|
|
123
|
+
const integrity = ok ? "verified" : deriveIntegrity(handoff, reasons);
|
|
124
|
+
return { ok, integrity, reasons };
|
|
125
|
+
}
|
|
126
|
+
// ─── Circuit breaker ──────────────────────────────────────────────────────────
|
|
127
|
+
/**
|
|
128
|
+
* Converts a ContextHandoffVerification into a gate decision.
|
|
129
|
+
*
|
|
130
|
+
* Healthy: { shouldStop: false, silent: true }
|
|
131
|
+
* Broken: { shouldStop: true, silent: false, reasonCode, message, nextAction }
|
|
132
|
+
*/
|
|
133
|
+
export function decideContextCircuitBreak(verification) {
|
|
134
|
+
if (verification.ok) {
|
|
135
|
+
return {
|
|
136
|
+
shouldStop: false,
|
|
137
|
+
silent: true,
|
|
138
|
+
verification
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const first = verification.reasons[0];
|
|
142
|
+
return {
|
|
143
|
+
shouldStop: true,
|
|
144
|
+
silent: false,
|
|
145
|
+
verification,
|
|
146
|
+
reasonCode: first?.code ?? "handoff_verification_failed",
|
|
147
|
+
message: first?.message ?? "Context handoff verification failed.",
|
|
148
|
+
nextAction: "Repair or replace the upstream handoff receipt, then retry."
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
152
|
+
function isPresent(value) {
|
|
153
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
154
|
+
}
|
|
155
|
+
function deriveIntegrity(handoff, reasons) {
|
|
156
|
+
const codes = reasons.map((r) => r.code);
|
|
157
|
+
if (codes.includes("unsupported_schema"))
|
|
158
|
+
return "unsupported_schema";
|
|
159
|
+
if (codes.some((c) => c === "upstream_integrity_tamper_detected" ||
|
|
160
|
+
c === "artifact_hash_mismatch")) {
|
|
161
|
+
return "tamper_detected";
|
|
162
|
+
}
|
|
163
|
+
if (codes.includes("producer_receipt_unverified") || codes.includes("upstream_integrity_evidence_boundary")) {
|
|
164
|
+
return "evidence_boundary";
|
|
165
|
+
}
|
|
166
|
+
return "incomplete";
|
|
167
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Shadow Compiler — A-CTX-0
|
|
3
|
+
*
|
|
4
|
+
* Deterministic shadow manifest emitter. Observes the compiled prompt and
|
|
5
|
+
* records what a context-aware selector would have chosen, without altering
|
|
6
|
+
* the actual provider input in any way.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - nowMs is always a captured input; Date.now() is never called internally
|
|
10
|
+
* - No source text appears in any output type
|
|
11
|
+
* - Required segments are always preferred first
|
|
12
|
+
* - required-over-budget is recorded, never thrown
|
|
13
|
+
* - Manifest hash covers metadata only (no content)
|
|
14
|
+
*/
|
|
15
|
+
import type { ContextC5EnvelopeV1, ContextEvidence, ContextShadowManifestV1 } from "../contracts/index.js";
|
|
16
|
+
export interface ContextShadowSegment {
|
|
17
|
+
segmentId: string;
|
|
18
|
+
kind: string;
|
|
19
|
+
required: boolean;
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CompileContextShadowInput {
|
|
23
|
+
runId: string;
|
|
24
|
+
taskId?: string;
|
|
25
|
+
adapter: string;
|
|
26
|
+
model?: string;
|
|
27
|
+
/** Milliseconds since epoch — must be explicit. Never call Date.now() here. */
|
|
28
|
+
nowMs: number;
|
|
29
|
+
shadowBudgetTokens: number;
|
|
30
|
+
modelWindowTokens?: number;
|
|
31
|
+
modelWindowEvidence?: ContextEvidence;
|
|
32
|
+
segments: readonly ContextShadowSegment[];
|
|
33
|
+
}
|
|
34
|
+
export interface CompileContextShadowResult {
|
|
35
|
+
manifest: ContextShadowManifestV1;
|
|
36
|
+
receipt: ContextC5EnvelopeV1;
|
|
37
|
+
}
|
|
38
|
+
export declare function estimateContextTokens(text: string): number;
|
|
39
|
+
export declare function compileContextShadow(input: CompileContextShadowInput): CompileContextShadowResult;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Shadow Compiler — A-CTX-0
|
|
3
|
+
*
|
|
4
|
+
* Deterministic shadow manifest emitter. Observes the compiled prompt and
|
|
5
|
+
* records what a context-aware selector would have chosen, without altering
|
|
6
|
+
* the actual provider input in any way.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - nowMs is always a captured input; Date.now() is never called internally
|
|
10
|
+
* - No source text appears in any output type
|
|
11
|
+
* - Required segments are always preferred first
|
|
12
|
+
* - required-over-budget is recorded, never thrown
|
|
13
|
+
* - Manifest hash covers metadata only (no content)
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { CONTEXT_SHADOW_MANIFEST_VERSION, CONTEXT_C5_VERSION } from "../contracts/index.js";
|
|
17
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
18
|
+
function sha256(value) {
|
|
19
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function canonicalize(value) {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return value.map(canonicalize);
|
|
24
|
+
}
|
|
25
|
+
if (value !== null && typeof value === "object") {
|
|
26
|
+
return Object.fromEntries(Object.entries(value)
|
|
27
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
28
|
+
.map(([k, v]) => [k, canonicalize(v)]));
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function canonicalJson(value) {
|
|
33
|
+
return JSON.stringify(canonicalize(value));
|
|
34
|
+
}
|
|
35
|
+
export function estimateContextTokens(text) {
|
|
36
|
+
if (text.length === 0)
|
|
37
|
+
return 0;
|
|
38
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4));
|
|
39
|
+
}
|
|
40
|
+
// ─── Compiler ────────────────────────────────────────────────────────────────
|
|
41
|
+
export function compileContextShadow(input) {
|
|
42
|
+
const { runId, adapter, nowMs, shadowBudgetTokens, segments } = input;
|
|
43
|
+
// Validate: all segmentIds must be non-empty and unique
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const seg of segments) {
|
|
46
|
+
if (!seg.segmentId || seg.segmentId.trim() === "") {
|
|
47
|
+
throw new Error("compileContextShadow: segmentId must be non-empty");
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(seg.segmentId)) {
|
|
50
|
+
throw new Error(`compileContextShadow: duplicate segmentId "${seg.segmentId}"`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(seg.segmentId);
|
|
53
|
+
}
|
|
54
|
+
// Hash each segment's content — never expose the text itself
|
|
55
|
+
const hashed = segments.map((seg) => ({
|
|
56
|
+
segmentId: seg.segmentId,
|
|
57
|
+
kind: seg.kind,
|
|
58
|
+
required: seg.required,
|
|
59
|
+
contentHash: sha256(seg.text),
|
|
60
|
+
estimatedTokens: estimateContextTokens(seg.text)
|
|
61
|
+
}));
|
|
62
|
+
// Compute actualPromptHash from all segments in original order
|
|
63
|
+
const actualPromptHash = sha256(canonicalJson(hashed.map((h) => ({ segmentId: h.segmentId, contentHash: h.contentHash }))));
|
|
64
|
+
const actualEstimatedTokens = hashed.reduce((sum, h) => sum + h.estimatedTokens, 0);
|
|
65
|
+
// Compute compilerInputHash — includes nowMs for cross-run determinism
|
|
66
|
+
const compilerInputHash = sha256(canonicalJson({
|
|
67
|
+
adapter,
|
|
68
|
+
nowMs,
|
|
69
|
+
segments: hashed.map((h) => ({
|
|
70
|
+
segmentId: h.segmentId,
|
|
71
|
+
kind: h.kind,
|
|
72
|
+
required: h.required,
|
|
73
|
+
contentHash: h.contentHash,
|
|
74
|
+
estimatedTokens: h.estimatedTokens
|
|
75
|
+
})),
|
|
76
|
+
shadowBudgetTokens,
|
|
77
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
78
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
79
|
+
}));
|
|
80
|
+
// Shadow selection: required first (in original order), then optional
|
|
81
|
+
const required = hashed.filter((h) => h.required);
|
|
82
|
+
const optional = hashed.filter((h) => !h.required);
|
|
83
|
+
let remaining = shadowBudgetTokens;
|
|
84
|
+
const decisions = [];
|
|
85
|
+
// Required segments — always included even if over budget
|
|
86
|
+
let requiredOverBudget = false;
|
|
87
|
+
const requiredTokens = required.reduce((s, h) => s + h.estimatedTokens, 0);
|
|
88
|
+
if (requiredTokens > shadowBudgetTokens) {
|
|
89
|
+
requiredOverBudget = true;
|
|
90
|
+
}
|
|
91
|
+
for (const h of required) {
|
|
92
|
+
const included = !requiredOverBudget;
|
|
93
|
+
decisions.push({
|
|
94
|
+
segmentId: h.segmentId,
|
|
95
|
+
kind: h.kind,
|
|
96
|
+
contentHash: h.contentHash,
|
|
97
|
+
estimatedTokens: h.estimatedTokens,
|
|
98
|
+
actuallyIncluded: true,
|
|
99
|
+
proposedDecision: included ? "included" : "excluded",
|
|
100
|
+
reason: requiredOverBudget ? "required_over_budget" : "required"
|
|
101
|
+
});
|
|
102
|
+
if (!requiredOverBudget) {
|
|
103
|
+
remaining -= h.estimatedTokens;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Optional segments — include while budget remains
|
|
107
|
+
for (const h of optional) {
|
|
108
|
+
if (h.estimatedTokens <= remaining) {
|
|
109
|
+
decisions.push({
|
|
110
|
+
segmentId: h.segmentId,
|
|
111
|
+
kind: h.kind,
|
|
112
|
+
contentHash: h.contentHash,
|
|
113
|
+
estimatedTokens: h.estimatedTokens,
|
|
114
|
+
actuallyIncluded: true,
|
|
115
|
+
proposedDecision: "included",
|
|
116
|
+
reason: "within_shadow_budget"
|
|
117
|
+
});
|
|
118
|
+
remaining -= h.estimatedTokens;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
decisions.push({
|
|
122
|
+
segmentId: h.segmentId,
|
|
123
|
+
kind: h.kind,
|
|
124
|
+
contentHash: h.contentHash,
|
|
125
|
+
estimatedTokens: h.estimatedTokens,
|
|
126
|
+
actuallyIncluded: true,
|
|
127
|
+
proposedDecision: "excluded",
|
|
128
|
+
reason: "shadow_budget_exhausted"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Restore original segment order in decisions list
|
|
133
|
+
const orderMap = new Map(segments.map((s, i) => [s.segmentId, i]));
|
|
134
|
+
decisions.sort((a, b) => (orderMap.get(a.segmentId) ?? 0) - (orderMap.get(b.segmentId) ?? 0));
|
|
135
|
+
const proposedEstimatedTokens = decisions
|
|
136
|
+
.filter((d) => d.proposedDecision === "included")
|
|
137
|
+
.reduce((s, d) => s + d.estimatedTokens, 0);
|
|
138
|
+
const capturedAt = new Date(nowMs).toISOString();
|
|
139
|
+
const manifestId = sha256(`${runId}:${compilerInputHash}:${capturedAt}`);
|
|
140
|
+
// Manifest hash covers metadata only — no content
|
|
141
|
+
const manifestHashInput = canonicalJson({
|
|
142
|
+
adapter,
|
|
143
|
+
capturedAt,
|
|
144
|
+
compilerInputHash,
|
|
145
|
+
manifestId,
|
|
146
|
+
mode: "shadow",
|
|
147
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
148
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
149
|
+
requiredOverBudget,
|
|
150
|
+
runId,
|
|
151
|
+
shadowBudgetTokens,
|
|
152
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
153
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
154
|
+
});
|
|
155
|
+
const manifestHash = sha256(manifestHashInput);
|
|
156
|
+
const includedCount = decisions.filter((d) => d.proposedDecision === "included").length;
|
|
157
|
+
const excludedCount = decisions.filter((d) => d.proposedDecision === "excluded").length;
|
|
158
|
+
const manifest = {
|
|
159
|
+
schemaVersion: CONTEXT_SHADOW_MANIFEST_VERSION,
|
|
160
|
+
manifestId,
|
|
161
|
+
manifestHash,
|
|
162
|
+
compilerInputHash,
|
|
163
|
+
runId,
|
|
164
|
+
adapter,
|
|
165
|
+
capturedAt,
|
|
166
|
+
mode: "shadow",
|
|
167
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
168
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
169
|
+
shadowBudgetTokens,
|
|
170
|
+
actualPromptHash,
|
|
171
|
+
actualEstimatedTokens,
|
|
172
|
+
proposedEstimatedTokens,
|
|
173
|
+
requiredOverBudget,
|
|
174
|
+
decisions,
|
|
175
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
176
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
177
|
+
};
|
|
178
|
+
const receipt = {
|
|
179
|
+
schemaVersion: CONTEXT_C5_VERSION,
|
|
180
|
+
resource: "context",
|
|
181
|
+
event: "shadow_compiled",
|
|
182
|
+
runId,
|
|
183
|
+
manifestId,
|
|
184
|
+
manifestHash,
|
|
185
|
+
mode: "shadow",
|
|
186
|
+
adapter,
|
|
187
|
+
actualEstimatedTokens,
|
|
188
|
+
proposedEstimatedTokens,
|
|
189
|
+
candidateCount: decisions.length,
|
|
190
|
+
includedCount,
|
|
191
|
+
excludedCount,
|
|
192
|
+
requiredOverBudget,
|
|
193
|
+
modelWindowTokens: input.modelWindowTokens ?? null,
|
|
194
|
+
modelWindowEvidence: input.modelWindowEvidence ?? "unknown",
|
|
195
|
+
createdAt: capturedAt,
|
|
196
|
+
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
|
|
197
|
+
...(input.model === undefined ? {} : { model: input.model })
|
|
198
|
+
};
|
|
199
|
+
return { manifest, receipt };
|
|
200
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DeliveryMessage } from "../../contracts/index.js";
|
|
2
|
+
export type ValidationError = "schema_version_unknown" | "schema_unknown_kind" | "schema_unknown_action_type" | "schema_invalid_id" | "schema_invalid_date" | "schema_invalid_cooldown" | "schema_url_not_https" | "schema_text_too_long" | "schema_response_too_large" | "schema_malformed";
|
|
3
|
+
export interface ParseResult {
|
|
4
|
+
ok: true;
|
|
5
|
+
message: DeliveryMessage | undefined;
|
|
6
|
+
}
|
|
7
|
+
export interface ParseFailure {
|
|
8
|
+
ok: false;
|
|
9
|
+
error: ValidationError;
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse and validate a raw response body from the message selection endpoint.
|
|
14
|
+
* Returns ok:false on any violation — caller must render nothing and log the error.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseMessageSelectionResponse(raw: string): ParseResult | ParseFailure;
|