@cueai/omni-reader-mcp 1.1.2 → 1.2.0
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 +139 -80
- package/dist/artifact-store.d.ts +58 -0
- package/dist/artifact-store.js +449 -5
- package/dist/capabilities.d.ts +92 -0
- package/dist/capabilities.js +123 -0
- package/dist/constants.d.ts +34 -1
- package/dist/constants.js +39 -1
- package/dist/cube-client.d.ts +3 -0
- package/dist/cube-client.js +141 -9
- package/dist/cursor.d.ts +12 -0
- package/dist/cursor.js +89 -9
- package/dist/operation-journal.d.ts +19 -5
- package/dist/operation-journal.js +435 -73
- package/dist/operation-manager.d.ts +7 -2
- package/dist/operation-manager.js +215 -16
- package/dist/protocol.d.ts +16 -3
- package/dist/protocol.js +25 -1
- package/dist/remote-client.d.ts +4 -2
- package/dist/remote-client.js +155 -13
- package/dist/result-bundle.d.ts +21 -0
- package/dist/result-bundle.js +320 -0
- package/dist/result-contract.d.ts +398 -4
- package/dist/result-contract.js +175 -19
- package/dist/server.d.ts +17 -0
- package/dist/server.js +45 -1
- package/dist/tools.d.ts +7 -3
- package/dist/tools.js +77 -1
- package/package.json +1 -1
package/dist/remote-client.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { parseReaderCapabilities, selectUrlProfile, } from "./capabilities.js";
|
|
3
|
+
import { BRIDGE_RELEASE_VERSION, REMOTE_CAPABILITIES_CUSTOM_FIELD, REMOTE_OMNI_MCP_URL, } from "./constants.js";
|
|
2
4
|
import { OmniBridgeError } from "./errors.js";
|
|
3
5
|
import { parseResultSchema } from "./result-contract.js";
|
|
4
6
|
import { classifySource } from "./source.js";
|
|
7
|
+
const INITIALIZE_REQUEST_ID = "initialize";
|
|
8
|
+
const BRIDGE_CLIENT_NAME = "@cueai/omni-reader-mcp";
|
|
5
9
|
function remoteError(options) {
|
|
6
10
|
return new OmniBridgeError({
|
|
7
11
|
code: options.code,
|
|
@@ -98,6 +102,53 @@ function protocolError() {
|
|
|
98
102
|
retryable: false,
|
|
99
103
|
});
|
|
100
104
|
}
|
|
105
|
+
function unsupportedDetail() {
|
|
106
|
+
return remoteError({
|
|
107
|
+
code: "UNSUPPORTED_DETAIL",
|
|
108
|
+
message: "This remote Omni service does not support the requested output detail.",
|
|
109
|
+
failureScope: "service",
|
|
110
|
+
userAction: "Use plain Markdown output for this source.",
|
|
111
|
+
operationCreated: false,
|
|
112
|
+
retryable: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Extracts exactly `result.capabilities.experimental["cue.omni-reader"]` from
|
|
116
|
+
// an MCP initialize envelope; returns undefined when the exact custom field is
|
|
117
|
+
// absent (server did not declare the capability), throws on contract
|
|
118
|
+
// violations. Support is never inferred from any other initialize field.
|
|
119
|
+
function initializeCustomCapability(value, requestId) {
|
|
120
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
121
|
+
throw protocolError();
|
|
122
|
+
}
|
|
123
|
+
const envelope = value;
|
|
124
|
+
if (envelope.jsonrpc !== "2.0" ||
|
|
125
|
+
envelope.id !== requestId ||
|
|
126
|
+
envelope.error !== undefined) {
|
|
127
|
+
throw protocolError();
|
|
128
|
+
}
|
|
129
|
+
if (envelope.result === null ||
|
|
130
|
+
typeof envelope.result !== "object" ||
|
|
131
|
+
Array.isArray(envelope.result)) {
|
|
132
|
+
throw protocolError();
|
|
133
|
+
}
|
|
134
|
+
const capabilities = envelope.result.capabilities;
|
|
135
|
+
if (capabilities === undefined)
|
|
136
|
+
return undefined;
|
|
137
|
+
if (capabilities === null ||
|
|
138
|
+
typeof capabilities !== "object" ||
|
|
139
|
+
Array.isArray(capabilities)) {
|
|
140
|
+
throw protocolError();
|
|
141
|
+
}
|
|
142
|
+
const experimental = capabilities.experimental;
|
|
143
|
+
if (experimental === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
if (experimental === null ||
|
|
146
|
+
typeof experimental !== "object" ||
|
|
147
|
+
Array.isArray(experimental)) {
|
|
148
|
+
throw protocolError();
|
|
149
|
+
}
|
|
150
|
+
return experimental[REMOTE_CAPABILITIES_CUSTOM_FIELD];
|
|
151
|
+
}
|
|
101
152
|
function jsonFromSse(body, requestId) {
|
|
102
153
|
for (const event of body.split(/\r?\n\r?\n/u)) {
|
|
103
154
|
const data = event
|
|
@@ -144,11 +195,101 @@ function decodeEnvelope(value, requestId) {
|
|
|
144
195
|
export class HttpRemoteOmniClient {
|
|
145
196
|
#apiKey;
|
|
146
197
|
#fetch;
|
|
198
|
+
#capabilitiesCache;
|
|
147
199
|
constructor(options) {
|
|
148
200
|
this.#apiKey = options.apiKey;
|
|
149
201
|
this.#fetch = options.fetchImpl ?? fetch;
|
|
150
202
|
}
|
|
151
|
-
|
|
203
|
+
#requireApiKey() {
|
|
204
|
+
if (this.#apiKey === undefined || this.#apiKey.length === 0) {
|
|
205
|
+
throw remoteError({
|
|
206
|
+
code: "API_KEY_REQUIRED",
|
|
207
|
+
message: "A Cue API Key is required for Omni parsing.",
|
|
208
|
+
failureScope: "authentication",
|
|
209
|
+
userAction: "Create or configure an API Key at https://cuecue.cn/api-key without pasting it into chat.",
|
|
210
|
+
operationCreated: false,
|
|
211
|
+
retryable: false,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Preflight for any non-text tool call (D2-D item 4): sends the MCP
|
|
216
|
+
// initialize request, extracts only the exact
|
|
217
|
+
// `capabilities.experimental["cue.omni-reader"]` custom field, validates it
|
|
218
|
+
// through the closed `omni.reader_capabilities.v1` schema, and caches only
|
|
219
|
+
// until the exact parsed `expires_at`.
|
|
220
|
+
async initializeCapabilities(signal) {
|
|
221
|
+
this.#requireApiKey();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const cached = this.#capabilitiesCache;
|
|
224
|
+
if (cached !== undefined && cached.expiresAt > now) {
|
|
225
|
+
return cached.value;
|
|
226
|
+
}
|
|
227
|
+
let response;
|
|
228
|
+
try {
|
|
229
|
+
response = await this.#fetch(REMOTE_OMNI_MCP_URL, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
233
|
+
accept: "application/json",
|
|
234
|
+
"cache-control": "no-store",
|
|
235
|
+
"content-type": "application/json",
|
|
236
|
+
"idempotency-key": INITIALIZE_REQUEST_ID,
|
|
237
|
+
},
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
jsonrpc: "2.0",
|
|
240
|
+
id: INITIALIZE_REQUEST_ID,
|
|
241
|
+
method: "initialize",
|
|
242
|
+
params: {
|
|
243
|
+
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
244
|
+
capabilities: {},
|
|
245
|
+
clientInfo: {
|
|
246
|
+
name: BRIDGE_CLIENT_NAME,
|
|
247
|
+
version: BRIDGE_RELEASE_VERSION,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
signal,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (signal.aborted)
|
|
256
|
+
throw error;
|
|
257
|
+
throw remoteError({
|
|
258
|
+
code: "SERVICE_TEMPORARILY_UNAVAILABLE",
|
|
259
|
+
message: "Omni could not reach the remote parsing service.",
|
|
260
|
+
failureScope: "service",
|
|
261
|
+
userAction: "Retry with bounded backoff while preserving the same request identity.",
|
|
262
|
+
operationCreated: true,
|
|
263
|
+
retryable: true,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (!response.ok)
|
|
267
|
+
throw responseError(response);
|
|
268
|
+
let envelope;
|
|
269
|
+
try {
|
|
270
|
+
envelope = JSON.parse(await response.text());
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
throw protocolError();
|
|
274
|
+
}
|
|
275
|
+
const custom = initializeCustomCapability(envelope, INITIALIZE_REQUEST_ID);
|
|
276
|
+
if (custom === undefined) {
|
|
277
|
+
throw unsupportedDetail();
|
|
278
|
+
}
|
|
279
|
+
let capabilities;
|
|
280
|
+
try {
|
|
281
|
+
capabilities = parseReaderCapabilities(custom, new Date(now));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw protocolError();
|
|
285
|
+
}
|
|
286
|
+
this.#capabilitiesCache = {
|
|
287
|
+
expiresAt: capabilities.expires_at.getTime(),
|
|
288
|
+
value: capabilities,
|
|
289
|
+
};
|
|
290
|
+
return capabilities;
|
|
291
|
+
}
|
|
292
|
+
async parse(source, clientRequestId, signal, detail) {
|
|
152
293
|
const classified = classifySource(source);
|
|
153
294
|
if (classified.kind !== "url") {
|
|
154
295
|
throw remoteError({
|
|
@@ -160,7 +301,17 @@ export class HttpRemoteOmniClient {
|
|
|
160
301
|
retryable: false,
|
|
161
302
|
});
|
|
162
303
|
}
|
|
163
|
-
|
|
304
|
+
let args = { source: classified.source };
|
|
305
|
+
if (detail === "grounded" || detail === "layout") {
|
|
306
|
+
// D2-D Task 14: the URL profile is obtained and selected BEFORE any
|
|
307
|
+
// non-text tools/call is constructed. Detail is sent only for a valid
|
|
308
|
+
// non-text v3 call; a missing/mismatched profile fails closed with
|
|
309
|
+
// UNSUPPORTED_DETAIL and zero tools/call requests.
|
|
310
|
+
const capabilities = await this.initializeCapabilities(signal);
|
|
311
|
+
selectUrlProfile(capabilities, detail);
|
|
312
|
+
args = { source: classified.source, detail };
|
|
313
|
+
}
|
|
314
|
+
return this.#call("parse", args, clientRequestId, signal);
|
|
164
315
|
}
|
|
165
316
|
status(operationId, waitMs, signal) {
|
|
166
317
|
return this.#call("get_parse_status", {
|
|
@@ -172,16 +323,7 @@ export class HttpRemoteOmniClient {
|
|
|
172
323
|
return this.#call("cancel_parse", { operation_id: operationId }, `${operationId}:cancel`, signal);
|
|
173
324
|
}
|
|
174
325
|
async #call(name, args, requestId, signal) {
|
|
175
|
-
|
|
176
|
-
throw remoteError({
|
|
177
|
-
code: "API_KEY_REQUIRED",
|
|
178
|
-
message: "A Cue API Key is required for Omni parsing.",
|
|
179
|
-
failureScope: "authentication",
|
|
180
|
-
userAction: "Create or configure an API Key at https://cuecue.cn/api-key without pasting it into chat.",
|
|
181
|
-
operationCreated: false,
|
|
182
|
-
retryable: false,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
326
|
+
this.#requireApiKey();
|
|
185
327
|
let response;
|
|
186
328
|
try {
|
|
187
329
|
response = await this.#fetch(REMOTE_OMNI_MCP_URL, {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const BUNDLE_MEDIA_TYPE = "application/vnd.cue.omni-result-bundle+json; version=1";
|
|
2
|
+
export declare const BUNDLE_CONTENT_MEDIA_TYPE = "text/markdown; charset=utf-8";
|
|
3
|
+
export declare const BUNDLE_GROUNDING_MEDIA_TYPE = "application/vnd.cue.omni-grounding+json; version=1";
|
|
4
|
+
export interface VerifiedBundle {
|
|
5
|
+
readonly detail: "grounded" | "layout";
|
|
6
|
+
readonly bundleBytes: number;
|
|
7
|
+
readonly bundleDigest: string;
|
|
8
|
+
readonly content: {
|
|
9
|
+
readonly bytes: Buffer;
|
|
10
|
+
readonly digest: string;
|
|
11
|
+
readonly mediaType: "text/markdown; charset=utf-8";
|
|
12
|
+
};
|
|
13
|
+
readonly grounding: {
|
|
14
|
+
readonly bytes: Buffer;
|
|
15
|
+
readonly digest: string;
|
|
16
|
+
readonly value: unknown;
|
|
17
|
+
readonly mediaType: "application/vnd.cue.omni-grounding+json; version=1";
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function canonicalJson(value: unknown): Buffer<ArrayBuffer>;
|
|
21
|
+
export declare function verifyResultBundle(bytes: Buffer): VerifiedBundle;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { OmniBridgeError } from "./errors.js";
|
|
4
|
+
import { GROUNDING_SCHEMA_VERSION, RESULT_BUNDLE_PROTOCOL_VERSION } from "./protocol.js";
|
|
5
|
+
// Frozen omni.result_bundle.v1 transport/part media types (contract README):
|
|
6
|
+
// the bundle media type is transport metadata only and is never serialized
|
|
7
|
+
// into the bundle value.
|
|
8
|
+
export const BUNDLE_MEDIA_TYPE = "application/vnd.cue.omni-result-bundle+json; version=1";
|
|
9
|
+
export const BUNDLE_CONTENT_MEDIA_TYPE = "text/markdown; charset=utf-8";
|
|
10
|
+
export const BUNDLE_GROUNDING_MEDIA_TYPE = "application/vnd.cue.omni-grounding+json; version=1";
|
|
11
|
+
const sha256DigestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/u);
|
|
12
|
+
// Closed D1 omni.grounding.v1 sidecar schema, mirrored from the checked-in
|
|
13
|
+
// contracts/omni-grounding/v1/schemas/grounding.schema.json (Draft 2020-12).
|
|
14
|
+
// Objects are closed, anchors are exact literals, and every cross-field
|
|
15
|
+
// constraint (safety oneOf, document completeness, segment layout by detail)
|
|
16
|
+
// is enforced.
|
|
17
|
+
const d1ContentSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
media_type: z.literal("text/markdown; charset=utf-8"),
|
|
20
|
+
bytes: z.number().int().nonnegative(),
|
|
21
|
+
digest: sha256DigestSchema,
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
const d1SafetySchema = z.union([
|
|
25
|
+
z
|
|
26
|
+
.object({
|
|
27
|
+
mode: z.literal("detect_only"),
|
|
28
|
+
availability: z.literal("unknown"),
|
|
29
|
+
reason: z.literal("native_report_absent"),
|
|
30
|
+
})
|
|
31
|
+
.strict(),
|
|
32
|
+
z
|
|
33
|
+
.object({
|
|
34
|
+
mode: z.literal("detect_only"),
|
|
35
|
+
availability: z.literal("reported"),
|
|
36
|
+
injections: z.number().int().nonnegative(),
|
|
37
|
+
hidden: z.number().int().nonnegative(),
|
|
38
|
+
})
|
|
39
|
+
.strict(),
|
|
40
|
+
]);
|
|
41
|
+
const d1IncompleteSchema = z.union([
|
|
42
|
+
z
|
|
43
|
+
.object({
|
|
44
|
+
kind: z.literal("page"),
|
|
45
|
+
basis: z.enum(["source_pdf_page_1_based", "rendered_pdf_page_1_based"]),
|
|
46
|
+
values: z.array(z.number().int().min(1)).min(1)
|
|
47
|
+
.refine((values) => new Set(values).size === values.length, { message: "values must be unique" }),
|
|
48
|
+
reason: z.literal("processing_timeout"),
|
|
49
|
+
})
|
|
50
|
+
.strict(),
|
|
51
|
+
z
|
|
52
|
+
.object({
|
|
53
|
+
kind: z.literal("document"),
|
|
54
|
+
basis: z.literal("whole_input"),
|
|
55
|
+
reason: z.enum(["processing_incomplete", "member_inventory_unavailable"]),
|
|
56
|
+
})
|
|
57
|
+
.strict(),
|
|
58
|
+
]);
|
|
59
|
+
const d1TruncatedSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
kind: z.literal("page"),
|
|
62
|
+
basis: z.enum(["source_pdf_page_1_based", "rendered_pdf_page_1_based"]),
|
|
63
|
+
count: z.number().int().min(1),
|
|
64
|
+
exact_values_available: z.literal(false),
|
|
65
|
+
reason: z.literal("content_truncated"),
|
|
66
|
+
})
|
|
67
|
+
.strict();
|
|
68
|
+
const d1DocumentSchema = z
|
|
69
|
+
.object({
|
|
70
|
+
format: z.string().min(1).max(64),
|
|
71
|
+
partial: z.boolean(),
|
|
72
|
+
incomplete: z.array(d1IncompleteSchema),
|
|
73
|
+
truncated: z.array(d1TruncatedSchema),
|
|
74
|
+
safety: d1SafetySchema,
|
|
75
|
+
})
|
|
76
|
+
.strict()
|
|
77
|
+
.refine((document) => document.partial
|
|
78
|
+
? document.incomplete.length > 0 || document.truncated.length > 0
|
|
79
|
+
: document.incomplete.length === 0 && document.truncated.length === 0, { message: "document completeness is inconsistent" });
|
|
80
|
+
const d1SourcePageAnchorSchema = z
|
|
81
|
+
.object({
|
|
82
|
+
kind: z.literal("page"),
|
|
83
|
+
basis: z.literal("source_pdf_page_1_based"),
|
|
84
|
+
reliability: z.literal("reliable"),
|
|
85
|
+
value: z.number().int().min(1),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
const d1RenderedPageAnchorSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
kind: z.literal("page"),
|
|
91
|
+
basis: z.literal("rendered_pdf_page_1_based"),
|
|
92
|
+
reliability: z.literal("conditional"),
|
|
93
|
+
value: z.number().int().min(1),
|
|
94
|
+
})
|
|
95
|
+
.strict();
|
|
96
|
+
const d1DocumentAnchorSchema = z
|
|
97
|
+
.object({
|
|
98
|
+
kind: z.literal("document"),
|
|
99
|
+
basis: z.literal("whole_document"),
|
|
100
|
+
reliability: z.literal("reliable_within_result"),
|
|
101
|
+
})
|
|
102
|
+
.strict();
|
|
103
|
+
const d1OrdinalAnchorSchema = z
|
|
104
|
+
.object({
|
|
105
|
+
kind: z.literal("ordinal"),
|
|
106
|
+
basis: z.literal("media_event_0_based"),
|
|
107
|
+
reliability: z.literal("reliable_within_result"),
|
|
108
|
+
value: z.number().int().min(0),
|
|
109
|
+
})
|
|
110
|
+
.strict();
|
|
111
|
+
const d1MemberAnchorSchema = z
|
|
112
|
+
.object({
|
|
113
|
+
kind: z.literal("archive_member"),
|
|
114
|
+
basis: z.literal("safe_flat_member_name"),
|
|
115
|
+
reliability: z.literal("reliable_within_result"),
|
|
116
|
+
value: z.string().min(1).max(512)
|
|
117
|
+
.regex(/^[^/\\\u0000\r\n]{1,512}$/u)
|
|
118
|
+
.refine((value) => value !== "." && value !== "..", { message: "member name is unsafe" }),
|
|
119
|
+
})
|
|
120
|
+
.strict();
|
|
121
|
+
const d1AnchorSchema = z.union([
|
|
122
|
+
d1SourcePageAnchorSchema,
|
|
123
|
+
d1RenderedPageAnchorSchema,
|
|
124
|
+
d1DocumentAnchorSchema,
|
|
125
|
+
d1OrdinalAnchorSchema,
|
|
126
|
+
d1MemberAnchorSchema,
|
|
127
|
+
]);
|
|
128
|
+
const d1GroundingSchema = z.union([
|
|
129
|
+
z
|
|
130
|
+
.object({
|
|
131
|
+
availability: z.literal("available"),
|
|
132
|
+
anchors: z.array(d1AnchorSchema).min(1),
|
|
133
|
+
})
|
|
134
|
+
.strict(),
|
|
135
|
+
z
|
|
136
|
+
.object({
|
|
137
|
+
availability: z.literal("unavailable"),
|
|
138
|
+
reason: z.enum(["no_reliable_source_anchor", "parser_did_not_preserve_anchor"]),
|
|
139
|
+
anchors: z.array(d1AnchorSchema).max(0),
|
|
140
|
+
})
|
|
141
|
+
.strict(),
|
|
142
|
+
]);
|
|
143
|
+
const d1LayoutItemSchema = z
|
|
144
|
+
.object({
|
|
145
|
+
text: z.string(),
|
|
146
|
+
bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
|
|
147
|
+
font: z.string().optional(),
|
|
148
|
+
size: z.number().positive().optional(),
|
|
149
|
+
heading: z.number().int().min(1).max(6).optional(),
|
|
150
|
+
})
|
|
151
|
+
.strict();
|
|
152
|
+
const d1CoordinateSpaceSchema = z
|
|
153
|
+
.object({
|
|
154
|
+
basis: z.literal("parser_page_native"),
|
|
155
|
+
unit: z.literal("unknown"),
|
|
156
|
+
page_width: z.null(),
|
|
157
|
+
page_height: z.null(),
|
|
158
|
+
rotation_degrees: z.null(),
|
|
159
|
+
})
|
|
160
|
+
.strict();
|
|
161
|
+
const d1LayoutSchema = z.union([
|
|
162
|
+
z
|
|
163
|
+
.object({
|
|
164
|
+
availability: z.literal("available"),
|
|
165
|
+
reliability: z.literal("conditional"),
|
|
166
|
+
coordinate_space: d1CoordinateSpaceSchema,
|
|
167
|
+
items: z.array(d1LayoutItemSchema).min(1),
|
|
168
|
+
})
|
|
169
|
+
.strict(),
|
|
170
|
+
z
|
|
171
|
+
.object({
|
|
172
|
+
availability: z.literal("unavailable"),
|
|
173
|
+
reason: z.enum([
|
|
174
|
+
"ocr_geometry_not_produced",
|
|
175
|
+
"parser_did_not_produce_layout",
|
|
176
|
+
"unsupported_modality",
|
|
177
|
+
"layout_budget_exceeded",
|
|
178
|
+
]),
|
|
179
|
+
})
|
|
180
|
+
.strict(),
|
|
181
|
+
]);
|
|
182
|
+
const d1ContentRangeSchema = z
|
|
183
|
+
.object({
|
|
184
|
+
start: z.number().int().nonnegative(),
|
|
185
|
+
end: z.number().int().nonnegative(),
|
|
186
|
+
})
|
|
187
|
+
.strict();
|
|
188
|
+
const d1SegmentSchema = z
|
|
189
|
+
.object({
|
|
190
|
+
segment_id: z.string().regex(/^segment_[0-9]{6}$/u),
|
|
191
|
+
content_range_utf8: d1ContentRangeSchema,
|
|
192
|
+
grounding: d1GroundingSchema,
|
|
193
|
+
layout: d1LayoutSchema.optional(),
|
|
194
|
+
})
|
|
195
|
+
.strict();
|
|
196
|
+
const d1ValueSchema = z
|
|
197
|
+
.object({
|
|
198
|
+
schema_version: z.literal(GROUNDING_SCHEMA_VERSION),
|
|
199
|
+
detail: z.enum(["grounded", "layout"]),
|
|
200
|
+
content: d1ContentSchema,
|
|
201
|
+
document: d1DocumentSchema,
|
|
202
|
+
segments: z.array(d1SegmentSchema),
|
|
203
|
+
})
|
|
204
|
+
.strict()
|
|
205
|
+
.refine((value) => value.detail === "grounded"
|
|
206
|
+
? value.segments.every((segment) => segment.layout === undefined)
|
|
207
|
+
: value.segments.every((segment) => segment.layout !== undefined), { message: "segment layout is inconsistent with detail" });
|
|
208
|
+
// Closed omni.result_bundle.v1 envelope. `detail` must equal the embedded D1
|
|
209
|
+
// sidecar detail (contract compatibility rule 1).
|
|
210
|
+
const bundleSchema = z
|
|
211
|
+
.object({
|
|
212
|
+
protocol_version: z.literal(RESULT_BUNDLE_PROTOCOL_VERSION),
|
|
213
|
+
detail: z.enum(["grounded", "layout"]),
|
|
214
|
+
content: z
|
|
215
|
+
.object({
|
|
216
|
+
media_type: z.literal(BUNDLE_CONTENT_MEDIA_TYPE),
|
|
217
|
+
text: z.string(),
|
|
218
|
+
})
|
|
219
|
+
.strict(),
|
|
220
|
+
grounding: z
|
|
221
|
+
.object({
|
|
222
|
+
media_type: z.literal(BUNDLE_GROUNDING_MEDIA_TYPE),
|
|
223
|
+
value: d1ValueSchema,
|
|
224
|
+
})
|
|
225
|
+
.strict(),
|
|
226
|
+
})
|
|
227
|
+
.strict()
|
|
228
|
+
.refine((bundle) => bundle.detail === bundle.grounding.value.detail, { message: "bundle detail must equal the D1 sidecar detail" });
|
|
229
|
+
function contractError() {
|
|
230
|
+
return new OmniBridgeError({
|
|
231
|
+
code: "GROUNDING_CONTRACT_INVALID",
|
|
232
|
+
message: "The released result is not a valid omni.result_bundle.v1 bundle.",
|
|
233
|
+
operationCreated: true,
|
|
234
|
+
fileUploaded: true,
|
|
235
|
+
parserStarted: true,
|
|
236
|
+
billed: true,
|
|
237
|
+
contentReleased: true,
|
|
238
|
+
retryable: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// Canonical JSON: compact separators, recursively sorted keys, and no
|
|
242
|
+
// non-finite numbers (allow_nan=false). JSON values parsed from text can
|
|
243
|
+
// contain Infinity through 1e999-style tokens, which canonicalization rejects.
|
|
244
|
+
export function canonicalJson(value) {
|
|
245
|
+
let serialized;
|
|
246
|
+
try {
|
|
247
|
+
serialized = JSON.stringify(value, (key, current) => {
|
|
248
|
+
if (typeof current === "number" && !Number.isFinite(current)) {
|
|
249
|
+
throw new Error("canonical JSON must not contain non-finite numbers");
|
|
250
|
+
}
|
|
251
|
+
if (current !== null && typeof current === "object" && !Array.isArray(current)) {
|
|
252
|
+
const record = current;
|
|
253
|
+
return Object.fromEntries(Object.keys(record).sort().map((name) => [name, record[name]]));
|
|
254
|
+
}
|
|
255
|
+
return current;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw contractError();
|
|
260
|
+
}
|
|
261
|
+
if (serialized === undefined)
|
|
262
|
+
throw contractError();
|
|
263
|
+
return Buffer.from(serialized, "utf8");
|
|
264
|
+
}
|
|
265
|
+
// Strict canonical bundle verification:
|
|
266
|
+
// 1. UTF-8 decode with fatal errors.
|
|
267
|
+
// 2. Closed-key schema validation of the envelope and the D1 sidecar.
|
|
268
|
+
// 3. Exact full byte comparison against the canonical reserialization. This
|
|
269
|
+
// rejects non-canonical wire bytes and, by construction, duplicate JSON
|
|
270
|
+
// keys at any nesting depth (JSON.parse collapses duplicates, so their
|
|
271
|
+
// canonical reserialization can never equal the input bytes).
|
|
272
|
+
// 4. D1 content binding: grounding.value.content bytes/digest must equal the
|
|
273
|
+
// exact content part bytes (design rule 8).
|
|
274
|
+
// The grounding part bytes are the canonical JSON of grounding.value alone,
|
|
275
|
+
// never the {media_type, value} wrapper.
|
|
276
|
+
export function verifyResultBundle(bytes) {
|
|
277
|
+
let text;
|
|
278
|
+
try {
|
|
279
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw contractError();
|
|
283
|
+
}
|
|
284
|
+
let parsed;
|
|
285
|
+
try {
|
|
286
|
+
parsed = JSON.parse(text);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw contractError();
|
|
290
|
+
}
|
|
291
|
+
let bundle;
|
|
292
|
+
try {
|
|
293
|
+
bundle = bundleSchema.parse(parsed);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
throw contractError();
|
|
297
|
+
}
|
|
298
|
+
const canonical = canonicalJson(bundle);
|
|
299
|
+
if (!canonical.equals(bytes))
|
|
300
|
+
throw contractError();
|
|
301
|
+
const contentBytes = Buffer.from(bundle.content.text, "utf8");
|
|
302
|
+
const contentDigest = `sha256:${createHash("sha256").update(contentBytes).digest("hex")}`;
|
|
303
|
+
const bound = bundle.grounding.value.content;
|
|
304
|
+
if (bound.bytes !== contentBytes.length || bound.digest !== contentDigest) {
|
|
305
|
+
throw contractError();
|
|
306
|
+
}
|
|
307
|
+
const groundingBytes = canonicalJson(bundle.grounding.value);
|
|
308
|
+
return {
|
|
309
|
+
detail: bundle.detail,
|
|
310
|
+
bundleBytes: bytes.length,
|
|
311
|
+
bundleDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
312
|
+
content: { bytes: contentBytes, digest: contentDigest, mediaType: BUNDLE_CONTENT_MEDIA_TYPE },
|
|
313
|
+
grounding: {
|
|
314
|
+
bytes: groundingBytes,
|
|
315
|
+
digest: `sha256:${createHash("sha256").update(groundingBytes).digest("hex")}`,
|
|
316
|
+
value: bundle.grounding.value,
|
|
317
|
+
mediaType: BUNDLE_GROUNDING_MEDIA_TYPE,
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|