@hraness/oh 0.2.7 → 0.3.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 +116 -12
- package/dist/canonical.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +91 -19
- package/dist/cloudflare-embedding.d.ts +104 -0
- package/dist/cloudflare-embedding.d.ts.map +1 -0
- package/dist/graph.d.ts.map +1 -1
- package/dist/index.js +90 -18
- package/dist/libsql-semantic.d.ts +111 -0
- package/dist/libsql-semantic.d.ts.map +1 -0
- package/dist/libsql.js +105 -18
- package/dist/memory-page.d.ts +2 -0
- package/dist/memory-page.d.ts.map +1 -0
- package/dist/memory-page.js +725 -0
- package/dist/memory-pages.d.ts +76 -0
- package/dist/memory-pages.d.ts.map +1 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +478 -18
- package/dist/projection-public.js +105 -18
- package/dist/projection-suss.js +105 -18
- package/dist/sdk.js +90 -18
- package/dist/semantic-cloud.d.ts +3 -0
- package/dist/semantic-cloud.d.ts.map +1 -0
- package/dist/semantic-cloud.js +1843 -0
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +104 -21
- package/dist/sqlite/index.js +90 -18
- package/dist/store.js +105 -18
- package/dist/sync.js +90 -18
- package/package.json +10 -2
- package/skills/oh/SKILL.md +28 -2
- package/spec/README.md +11 -3
- package/spec/manifest.json +9 -1
- package/spec/v1/cloudflare-embedding-profile.json +13 -0
- package/spec/v1/cloudflare-embedding-renderer.json +8 -0
- package/spec/v1/memory-page.md +153 -0
- package/spec/v1/memory-page.schema.json +154 -0
- package/spec/v1/memory.md +18 -0
- package/spec/v1/migration.md +13 -0
- package/spec/v1/semantic-cloud.md +87 -0
- package/src/canonical.ts +28 -13
- package/src/cli.ts +1 -1
- package/src/cloudflare-embedding.test.ts +306 -0
- package/src/cloudflare-embedding.ts +385 -0
- package/src/contracts.test.ts +20 -0
- package/src/graph.ts +63 -6
- package/src/libsql-semantic.test.ts +478 -0
- package/src/libsql-semantic.ts +1117 -0
- package/src/memory-page.ts +1 -0
- package/src/memory-pages.test.ts +277 -0
- package/src/memory-pages.ts +440 -0
- package/src/memory.ts +2 -0
- package/src/semantic-cloud.ts +2 -0
- package/src/semantic.ts +14 -3
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import {
|
|
2
|
+
boundedText,
|
|
3
|
+
canonicalSha256,
|
|
4
|
+
parseSha256Hex,
|
|
5
|
+
sha256Hex,
|
|
6
|
+
utf8ByteLength,
|
|
7
|
+
type Sha256Hex,
|
|
8
|
+
} from "./canonical";
|
|
9
|
+
import { normalizeOhEmbeddingV1 } from "./semantic";
|
|
10
|
+
|
|
11
|
+
const cloudflareEmbeddingProfilePayload = Object.freeze({
|
|
12
|
+
dimensions: 768,
|
|
13
|
+
distance: "cosine",
|
|
14
|
+
documentFormat: "title: {title} | text: {content}",
|
|
15
|
+
inputUtf8Bytes: 448,
|
|
16
|
+
model: "@cf/google/embeddinggemma-300m",
|
|
17
|
+
normalization: "l2",
|
|
18
|
+
profileId: "oh.cloudflare.embeddinggemma.v1",
|
|
19
|
+
provider: "cloudflare.workers-ai",
|
|
20
|
+
queryFormat: "task: search result | query: {query}",
|
|
21
|
+
v: 1 as const,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** A hosted embedding space. It is intentionally distinct from the local QMD profile. */
|
|
25
|
+
export const OH_CLOUDFLARE_EMBEDDING_PROFILE_V1 = Object.freeze({
|
|
26
|
+
...cloudflareEmbeddingProfilePayload,
|
|
27
|
+
profileSha256: canonicalSha256(cloudflareEmbeddingProfilePayload),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const semanticRendererPayload = Object.freeze({
|
|
31
|
+
documentFormat: cloudflareEmbeddingProfilePayload.documentFormat,
|
|
32
|
+
inputUtf8Bytes: cloudflareEmbeddingProfilePayload.inputUtf8Bytes,
|
|
33
|
+
rendererId: "oh.embedding-input.utf8-chunks.v1",
|
|
34
|
+
split: "unicode-scalar-greedy",
|
|
35
|
+
v: 1 as const,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const OH_SEMANTIC_RENDERER_V1 = Object.freeze({
|
|
39
|
+
...semanticRendererPayload,
|
|
40
|
+
rendererSha256: canonicalSha256(semanticRendererPayload),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const OH_CLOUDFLARE_EMBEDDING_LIMITS_V1 = Object.freeze({
|
|
44
|
+
batchInputs: 32,
|
|
45
|
+
deadlineMs: 30_000,
|
|
46
|
+
documentBytes: 8 * 1024 * 1024,
|
|
47
|
+
inputUtf8Bytes: 448,
|
|
48
|
+
responseBytes: 8 * 1024 * 1024,
|
|
49
|
+
renderedChunks: 256,
|
|
50
|
+
titleBytes: 16 * 1024,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type OhRenderedEmbeddingInputV1 = Readonly<{
|
|
54
|
+
input: string;
|
|
55
|
+
inputSha256: Sha256Hex;
|
|
56
|
+
kind: "document" | "query";
|
|
57
|
+
utf8Bytes: number;
|
|
58
|
+
v: 1;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
export type OhRenderedDocumentChunkV1 = Readonly<{
|
|
62
|
+
content: string;
|
|
63
|
+
input: OhRenderedEmbeddingInputV1;
|
|
64
|
+
ordinal: number;
|
|
65
|
+
title: string;
|
|
66
|
+
v: 1;
|
|
67
|
+
}>;
|
|
68
|
+
|
|
69
|
+
export type OhRenderedDocumentV1 = Readonly<{
|
|
70
|
+
chunks: readonly OhRenderedDocumentChunkV1[];
|
|
71
|
+
diagnostic: null | Readonly<{
|
|
72
|
+
code: "oversize-prefix" | "partial";
|
|
73
|
+
maximumChunks: number;
|
|
74
|
+
omittedUtf8Bytes: number;
|
|
75
|
+
v: 1;
|
|
76
|
+
}>;
|
|
77
|
+
sourceUtf8Bytes: number;
|
|
78
|
+
status: "complete" | "oversize" | "partial";
|
|
79
|
+
v: 1;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
export class OhCloudflareEmbeddingError extends Error {
|
|
83
|
+
readonly code: "aborted" | "invalid-input" | "invalid-response" | "provider-unavailable";
|
|
84
|
+
readonly status: number | null;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
code: OhCloudflareEmbeddingError["code"],
|
|
88
|
+
message: string,
|
|
89
|
+
status: number | null = null,
|
|
90
|
+
) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "OhCloudflareEmbeddingError";
|
|
93
|
+
this.code = code;
|
|
94
|
+
this.status = status;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const renderedEmbeddingInputs = new WeakSet<object>();
|
|
99
|
+
|
|
100
|
+
function formattedInput(kind: "document" | "query", input: string): OhRenderedEmbeddingInputV1 {
|
|
101
|
+
const utf8Bytes = utf8ByteLength(input);
|
|
102
|
+
if (utf8Bytes > OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.inputUtf8Bytes) {
|
|
103
|
+
throw new OhCloudflareEmbeddingError(
|
|
104
|
+
"invalid-input",
|
|
105
|
+
`A formatted embedding input exceeds ${OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.inputUtf8Bytes} UTF-8 bytes.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const rendered: OhRenderedEmbeddingInputV1 = {
|
|
109
|
+
input,
|
|
110
|
+
inputSha256: sha256Hex(input),
|
|
111
|
+
kind,
|
|
112
|
+
utf8Bytes,
|
|
113
|
+
v: 1,
|
|
114
|
+
};
|
|
115
|
+
renderedEmbeddingInputs.add(rendered);
|
|
116
|
+
return Object.freeze(rendered);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderOhCloudflareEmbeddingQueryV1(query: string): OhRenderedEmbeddingInputV1 {
|
|
120
|
+
const parsed = boundedText(query, OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.inputUtf8Bytes);
|
|
121
|
+
if (parsed === null) {
|
|
122
|
+
throw new OhCloudflareEmbeddingError("invalid-input", "An embedding query must be bounded NFC text.");
|
|
123
|
+
}
|
|
124
|
+
return formattedInput("query", `task: search result | query: ${parsed}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function prefixForTitle(title: string): string {
|
|
128
|
+
return `title: ${title} | text: `;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Renders every source scalar in order. A caller-selected chunk limit is made
|
|
133
|
+
* visible as `partial`; a title that leaves no content capacity is `oversize`.
|
|
134
|
+
*/
|
|
135
|
+
export function renderOhCloudflareEmbeddingDocumentV1(input: Readonly<{
|
|
136
|
+
content: string;
|
|
137
|
+
maximumChunks?: number;
|
|
138
|
+
title: string;
|
|
139
|
+
}>): OhRenderedDocumentV1 {
|
|
140
|
+
const title = boundedText(input.title, OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.titleBytes);
|
|
141
|
+
const content = input.content === "" ? "" : boundedText(
|
|
142
|
+
input.content,
|
|
143
|
+
OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.documentBytes,
|
|
144
|
+
);
|
|
145
|
+
const maximumChunks = input.maximumChunks ?? OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.renderedChunks;
|
|
146
|
+
if (title === null || content === null || !Number.isSafeInteger(maximumChunks)
|
|
147
|
+
|| maximumChunks < 1 || maximumChunks > OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.renderedChunks) {
|
|
148
|
+
throw new OhCloudflareEmbeddingError(
|
|
149
|
+
"invalid-input",
|
|
150
|
+
"A semantic document needs bounded NFC title/content and a valid chunk limit.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const sourceUtf8Bytes = utf8ByteLength(content);
|
|
154
|
+
const prefix = prefixForTitle(title);
|
|
155
|
+
const capacity = OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.inputUtf8Bytes - utf8ByteLength(prefix);
|
|
156
|
+
if (capacity < 1) {
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
chunks: Object.freeze([]),
|
|
159
|
+
diagnostic: Object.freeze({ code: "oversize-prefix", maximumChunks,
|
|
160
|
+
omittedUtf8Bytes: sourceUtf8Bytes, v: 1 }),
|
|
161
|
+
sourceUtf8Bytes,
|
|
162
|
+
status: "oversize",
|
|
163
|
+
v: 1,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const chunks: OhRenderedDocumentChunkV1[] = [];
|
|
168
|
+
let cursor = 0;
|
|
169
|
+
let emittedBytes = 0;
|
|
170
|
+
chunks: while ((cursor < content.length || (content.length === 0 && chunks.length === 0))
|
|
171
|
+
&& chunks.length < maximumChunks) {
|
|
172
|
+
const start = cursor;
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
while (cursor < content.length) {
|
|
175
|
+
const codePoint = content.codePointAt(cursor);
|
|
176
|
+
if (codePoint === undefined) break;
|
|
177
|
+
const scalar = String.fromCodePoint(codePoint);
|
|
178
|
+
const scalarBytes = utf8ByteLength(scalar);
|
|
179
|
+
if (bytes + scalarBytes > capacity) break;
|
|
180
|
+
bytes += scalarBytes;
|
|
181
|
+
cursor += scalar.length;
|
|
182
|
+
}
|
|
183
|
+
if (cursor === start && content.length > 0) {
|
|
184
|
+
break chunks;
|
|
185
|
+
}
|
|
186
|
+
const chunkContent = content.slice(start, cursor);
|
|
187
|
+
const rendered = formattedInput("document", `${prefix}${chunkContent}`);
|
|
188
|
+
chunks.push(Object.freeze({ content: chunkContent, input: rendered,
|
|
189
|
+
ordinal: chunks.length, title, v: 1 }));
|
|
190
|
+
emittedBytes += bytes;
|
|
191
|
+
}
|
|
192
|
+
const omittedUtf8Bytes = sourceUtf8Bytes - emittedBytes;
|
|
193
|
+
const partial = cursor < content.length;
|
|
194
|
+
const oversize = partial && chunks.length === 0;
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
chunks: Object.freeze(chunks),
|
|
197
|
+
diagnostic: partial ? Object.freeze({ code: oversize ? "oversize-prefix" : "partial", maximumChunks,
|
|
198
|
+
omittedUtf8Bytes, v: 1 as const }) : null,
|
|
199
|
+
sourceUtf8Bytes,
|
|
200
|
+
status: oversize ? "oversize" : partial ? "partial" : "complete",
|
|
201
|
+
v: 1,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type OhEmbeddingFetchV1 = (
|
|
206
|
+
input: string | URL | Request,
|
|
207
|
+
init?: RequestInit,
|
|
208
|
+
) => Promise<Response>;
|
|
209
|
+
|
|
210
|
+
export type OhCloudflareEmbeddingClientOptionsV1 = Readonly<{
|
|
211
|
+
accountId: string;
|
|
212
|
+
apiToken: string;
|
|
213
|
+
deadlineMs?: number;
|
|
214
|
+
fetch?: OhEmbeddingFetchV1;
|
|
215
|
+
maximumBatchInputs?: number;
|
|
216
|
+
maximumResponseBytes?: number;
|
|
217
|
+
}>;
|
|
218
|
+
|
|
219
|
+
function exactPositiveInteger(value: number, maximum: number, label: string): number {
|
|
220
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
221
|
+
throw new RangeError(`${label} must be an integer from 1 through ${maximum}.`);
|
|
222
|
+
}
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function boundedResponseText(response: Response, maximumBytes: number): Promise<string> {
|
|
227
|
+
const declaredLength = response.headers.get("content-length");
|
|
228
|
+
if (declaredLength !== null) {
|
|
229
|
+
const parsed = Number(declaredLength);
|
|
230
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > maximumBytes) {
|
|
231
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding response exceeds its byte limit.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (response.body === null) return "";
|
|
235
|
+
const reader = response.body.getReader();
|
|
236
|
+
const parts: Uint8Array[] = [];
|
|
237
|
+
let size = 0;
|
|
238
|
+
try {
|
|
239
|
+
while (true) {
|
|
240
|
+
const next = await reader.read();
|
|
241
|
+
if (next.done) break;
|
|
242
|
+
size += next.value.byteLength;
|
|
243
|
+
if (size > maximumBytes) {
|
|
244
|
+
await reader.cancel();
|
|
245
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding response exceeds its byte limit.");
|
|
246
|
+
}
|
|
247
|
+
parts.push(next.value);
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
reader.releaseLock();
|
|
251
|
+
}
|
|
252
|
+
const bytes = new Uint8Array(size);
|
|
253
|
+
let offset = 0;
|
|
254
|
+
for (const part of parts) { bytes.set(part, offset); offset += part.byteLength; }
|
|
255
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseCloudflareVectors(value: unknown, count: number): readonly (readonly number[])[] {
|
|
259
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
260
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned an invalid envelope.");
|
|
261
|
+
}
|
|
262
|
+
const envelope = value as Record<string, unknown>;
|
|
263
|
+
if (envelope.success !== true || typeof envelope.result !== "object"
|
|
264
|
+
|| envelope.result === null || Array.isArray(envelope.result)) {
|
|
265
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned an unsuccessful envelope.");
|
|
266
|
+
}
|
|
267
|
+
const result = envelope.result as Record<string, unknown>;
|
|
268
|
+
if (!Array.isArray(result.data) || result.data.length !== count
|
|
269
|
+
|| !Array.isArray(result.shape) || result.shape.length !== 2
|
|
270
|
+
|| result.shape[0] !== count
|
|
271
|
+
|| result.shape[1] !== OH_CLOUDFLARE_EMBEDDING_PROFILE_V1.dimensions) {
|
|
272
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned an incompatible shape.");
|
|
273
|
+
}
|
|
274
|
+
const vectors = result.data.map((candidate) => {
|
|
275
|
+
if (!Array.isArray(candidate)
|
|
276
|
+
|| candidate.length !== OH_CLOUDFLARE_EMBEDDING_PROFILE_V1.dimensions
|
|
277
|
+
|| candidate.some((component) => typeof component !== "number" || !Number.isFinite(component))) {
|
|
278
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned an invalid vector.");
|
|
279
|
+
}
|
|
280
|
+
try { return Object.freeze([...normalizeOhEmbeddingV1(candidate as number[])]); }
|
|
281
|
+
catch { throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned an invalid vector."); }
|
|
282
|
+
});
|
|
283
|
+
return Object.freeze(vectors);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Fixed Workers AI adapter; credentials are never included in surfaced errors. */
|
|
287
|
+
export class OhCloudflareEmbeddingClientV1 {
|
|
288
|
+
readonly profile = OH_CLOUDFLARE_EMBEDDING_PROFILE_V1;
|
|
289
|
+
readonly #accountId: string;
|
|
290
|
+
readonly #apiToken: string;
|
|
291
|
+
readonly #deadlineMs: number;
|
|
292
|
+
readonly #fetch: OhEmbeddingFetchV1;
|
|
293
|
+
readonly #maximumBatchInputs: number;
|
|
294
|
+
readonly #maximumResponseBytes: number;
|
|
295
|
+
|
|
296
|
+
constructor(options: OhCloudflareEmbeddingClientOptionsV1) {
|
|
297
|
+
if (!/^[a-f0-9]{32}$/iu.test(options.accountId)
|
|
298
|
+
|| options.apiToken.length < 16 || options.apiToken.length > 4096
|
|
299
|
+
|| /[\r\n]/u.test(options.apiToken)) {
|
|
300
|
+
throw new OhCloudflareEmbeddingError("invalid-input", "Cloudflare credentials are malformed.");
|
|
301
|
+
}
|
|
302
|
+
this.#accountId = options.accountId.toLowerCase();
|
|
303
|
+
this.#apiToken = options.apiToken;
|
|
304
|
+
this.#deadlineMs = exactPositiveInteger(
|
|
305
|
+
options.deadlineMs ?? 15_000,
|
|
306
|
+
OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.deadlineMs,
|
|
307
|
+
"deadlineMs",
|
|
308
|
+
);
|
|
309
|
+
this.#maximumBatchInputs = exactPositiveInteger(
|
|
310
|
+
options.maximumBatchInputs ?? 16,
|
|
311
|
+
OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.batchInputs,
|
|
312
|
+
"maximumBatchInputs",
|
|
313
|
+
);
|
|
314
|
+
this.#maximumResponseBytes = exactPositiveInteger(
|
|
315
|
+
options.maximumResponseBytes ?? OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.responseBytes,
|
|
316
|
+
OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.responseBytes,
|
|
317
|
+
"maximumResponseBytes",
|
|
318
|
+
);
|
|
319
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async embed(
|
|
323
|
+
inputs: readonly OhRenderedEmbeddingInputV1[],
|
|
324
|
+
options: Readonly<{ signal?: AbortSignal }> = {},
|
|
325
|
+
): Promise<readonly (readonly number[])[]> {
|
|
326
|
+
if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > this.#maximumBatchInputs) {
|
|
327
|
+
throw new OhCloudflareEmbeddingError(
|
|
328
|
+
"invalid-input",
|
|
329
|
+
`An embedding batch must contain 1 through ${this.#maximumBatchInputs} inputs.`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const text = inputs.map((candidate) => {
|
|
333
|
+
if (!renderedEmbeddingInputs.has(candidate as object)
|
|
334
|
+
|| candidate.v !== 1 || (candidate.kind !== "document" && candidate.kind !== "query")
|
|
335
|
+
|| candidate.utf8Bytes !== utf8ByteLength(candidate.input)
|
|
336
|
+
|| candidate.utf8Bytes > OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.inputUtf8Bytes
|
|
337
|
+
|| parseSha256Hex(candidate.inputSha256) === null
|
|
338
|
+
|| candidate.inputSha256 !== sha256Hex(candidate.input)) {
|
|
339
|
+
throw new OhCloudflareEmbeddingError("invalid-input", "A rendered embedding input is invalid.");
|
|
340
|
+
}
|
|
341
|
+
return candidate.input;
|
|
342
|
+
});
|
|
343
|
+
const deadline = AbortSignal.timeout(this.#deadlineMs);
|
|
344
|
+
const signal = options.signal === undefined ? deadline : AbortSignal.any([options.signal, deadline]);
|
|
345
|
+
let response: Response;
|
|
346
|
+
try {
|
|
347
|
+
response = await this.#fetch(
|
|
348
|
+
`https://api.cloudflare.com/client/v4/accounts/${this.#accountId}/ai/run/${OH_CLOUDFLARE_EMBEDDING_PROFILE_V1.model}`,
|
|
349
|
+
{
|
|
350
|
+
body: JSON.stringify({ text }),
|
|
351
|
+
headers: {
|
|
352
|
+
authorization: `Bearer ${this.#apiToken}`,
|
|
353
|
+
"content-type": "application/json",
|
|
354
|
+
},
|
|
355
|
+
method: "POST",
|
|
356
|
+
redirect: "error",
|
|
357
|
+
signal,
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
} catch {
|
|
361
|
+
if (signal.aborted) {
|
|
362
|
+
throw new OhCloudflareEmbeddingError("aborted", "The embedding request was aborted.");
|
|
363
|
+
}
|
|
364
|
+
throw new OhCloudflareEmbeddingError("provider-unavailable", "The embedding provider is unavailable.");
|
|
365
|
+
}
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
try { await response.body?.cancel(); } catch { /* The sanitized status is sufficient. */ }
|
|
368
|
+
throw new OhCloudflareEmbeddingError(
|
|
369
|
+
"provider-unavailable",
|
|
370
|
+
`The embedding provider rejected the request with HTTP ${response.status}.`,
|
|
371
|
+
response.status,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
let value: unknown;
|
|
375
|
+
try { value = JSON.parse(await boundedResponseText(response, this.#maximumResponseBytes)); }
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (error instanceof OhCloudflareEmbeddingError) throw error;
|
|
378
|
+
if (signal.aborted) {
|
|
379
|
+
throw new OhCloudflareEmbeddingError("aborted", "The embedding request was aborted.");
|
|
380
|
+
}
|
|
381
|
+
throw new OhCloudflareEmbeddingError("invalid-response", "The embedding provider returned invalid JSON.");
|
|
382
|
+
}
|
|
383
|
+
return parseCloudflareVectors(value, inputs.length);
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/contracts.test.ts
CHANGED
|
@@ -111,6 +111,26 @@ describe("graph contracts", () => {
|
|
|
111
111
|
const statement = createKnowledgeGraphRecordV1({ dependencies: [entity.key], key: "statement:ada-name",
|
|
112
112
|
kind: "statement", v: 1, value: { text: "Ada" } });
|
|
113
113
|
expect(parseKnowledgeGraphRecordV1(entity)).toEqual(entity);
|
|
114
|
+
const hidden = { ...entity } as Record<PropertyKey, unknown>;
|
|
115
|
+
Object.defineProperty(hidden, "hidden", { value: true });
|
|
116
|
+
const symbolic = { ...entity } as Record<PropertyKey, unknown>;
|
|
117
|
+
symbolic[Symbol("hidden")] = true;
|
|
118
|
+
let accessorReads = 0;
|
|
119
|
+
const accessor = { ...entity } as Record<PropertyKey, unknown>;
|
|
120
|
+
Object.defineProperty(accessor, "recordSha256", {
|
|
121
|
+
enumerable: true,
|
|
122
|
+
get() { accessorReads += 1; throw new Error("must not execute"); },
|
|
123
|
+
});
|
|
124
|
+
const dependencyAccessor = { ...statement, dependencies: [...statement.dependencies] };
|
|
125
|
+
Object.defineProperty(dependencyAccessor.dependencies, "0", {
|
|
126
|
+
enumerable: true,
|
|
127
|
+
get() { accessorReads += 1; return entity.key; },
|
|
128
|
+
});
|
|
129
|
+
expect(parseKnowledgeGraphRecordV1(hidden)).toBeNull();
|
|
130
|
+
expect(parseKnowledgeGraphRecordV1(symbolic)).toBeNull();
|
|
131
|
+
expect(parseKnowledgeGraphRecordV1(accessor)).toBeNull();
|
|
132
|
+
expect(parseKnowledgeGraphRecordV1(dependencyAccessor)).toBeNull();
|
|
133
|
+
expect(accessorReads).toBe(0);
|
|
114
134
|
const first = createKnowledgeGraphRevisionV1({ changes: [
|
|
115
135
|
{ kind: "put", record: statement, v: 1 }, { kind: "put", record: entity, v: 1 },
|
|
116
136
|
], operationId: "op_first", parent: null });
|
package/src/graph.ts
CHANGED
|
@@ -45,6 +45,54 @@ export type KnowledgeGraphRecordRefV1 = Readonly<{
|
|
|
45
45
|
v: 1;
|
|
46
46
|
}>;
|
|
47
47
|
|
|
48
|
+
const KNOWLEDGE_GRAPH_RECORD_KEYS_V1 = [
|
|
49
|
+
"dependencies", "key", "kind", "recordSha256", "v", "value",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
function exactKnowledgeGraphRecordEnvelopeV1(value: unknown): Record<string, unknown> | null {
|
|
53
|
+
try {
|
|
54
|
+
if (!isPlainRecord(value)) return null;
|
|
55
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
56
|
+
if (ownKeys.length !== KNOWLEDGE_GRAPH_RECORD_KEYS_V1.length
|
|
57
|
+
|| ownKeys.some((key) => typeof key !== "string")
|
|
58
|
+
|| KNOWLEDGE_GRAPH_RECORD_KEYS_V1.some((key) => !ownKeys.includes(key))) return null;
|
|
59
|
+
const detached: Record<string, unknown> = {};
|
|
60
|
+
for (const key of KNOWLEDGE_GRAPH_RECORD_KEYS_V1) {
|
|
61
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
62
|
+
if (descriptor === undefined || !descriptor.enumerable
|
|
63
|
+
|| descriptor.get !== undefined || descriptor.set !== undefined) return null;
|
|
64
|
+
detached[key] = descriptor.value;
|
|
65
|
+
}
|
|
66
|
+
return detached;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function exactGraphDependenciesV1(value: unknown): readonly unknown[] | null {
|
|
73
|
+
try {
|
|
74
|
+
if (!Array.isArray(value)) return null;
|
|
75
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
76
|
+
const length = lengthDescriptor?.value;
|
|
77
|
+
if (typeof length !== "number" || !Number.isSafeInteger(length)
|
|
78
|
+
|| length < 0 || length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) return null;
|
|
79
|
+
const ownKeys = Reflect.ownKeys(value);
|
|
80
|
+
if (ownKeys.length !== length + 1 || !ownKeys.includes("length")
|
|
81
|
+
|| ownKeys.some((key) => key !== "length" && (typeof key !== "string"
|
|
82
|
+
|| !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length))) return null;
|
|
83
|
+
const detached: unknown[] = [];
|
|
84
|
+
for (let index = 0; index < length; index += 1) {
|
|
85
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
86
|
+
if (descriptor === undefined || !descriptor.enumerable
|
|
87
|
+
|| descriptor.get !== undefined || descriptor.set !== undefined) return null;
|
|
88
|
+
detached.push(descriptor.value);
|
|
89
|
+
}
|
|
90
|
+
return detached;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
48
96
|
function recordKey(value: unknown): string | null {
|
|
49
97
|
return typeof value === "string" && value.length <= 512
|
|
50
98
|
&& /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u.test(value)
|
|
@@ -53,11 +101,13 @@ function recordKey(value: unknown): string | null {
|
|
|
53
101
|
|
|
54
102
|
export function createKnowledgeGraphRecordV1(input: KnowledgeGraphRecordInputV1): KnowledgeGraphRecordV1 {
|
|
55
103
|
if (!isPlainRecord(input) || !hasExactKeys(input, ["dependencies", "key", "kind", "v", "value"])
|
|
56
|
-
|| input.v !== 1
|
|
104
|
+
|| input.v !== 1) throw new TypeError("Invalid graph record input.");
|
|
105
|
+
const dependencyInput = exactGraphDependenciesV1(input.dependencies);
|
|
106
|
+
if (dependencyInput === null) throw new TypeError("Invalid graph record dependencies.");
|
|
57
107
|
const key = recordKey(input.key);
|
|
58
108
|
const kind = OH_KNOWLEDGE_GRAPH_RECORD_KINDS_V1.find((candidate) => candidate === input.kind);
|
|
59
|
-
if (key === null || kind === undefined
|
|
60
|
-
const dependencies =
|
|
109
|
+
if (key === null || kind === undefined) throw new TypeError("Invalid graph record identity.");
|
|
110
|
+
const dependencies = dependencyInput.map(recordKey);
|
|
61
111
|
if (dependencies.some((dependency) => dependency === null)
|
|
62
112
|
|| !orderedUnique(dependencies as string[], String) || dependencies.includes(key)) {
|
|
63
113
|
throw new TypeError("Graph dependencies must be ordered, unique, and non-reflexive.");
|
|
@@ -71,9 +121,16 @@ export function createKnowledgeGraphRecordV1(input: KnowledgeGraphRecordInputV1)
|
|
|
71
121
|
}
|
|
72
122
|
|
|
73
123
|
export function parseKnowledgeGraphRecordV1(value: unknown): KnowledgeGraphRecordV1 | null {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
124
|
+
const envelope = exactKnowledgeGraphRecordEnvelopeV1(value);
|
|
125
|
+
if (envelope === null) return null;
|
|
126
|
+
const recordSha256 = parseSha256Hex(envelope.recordSha256);
|
|
127
|
+
const input = {
|
|
128
|
+
dependencies: envelope.dependencies,
|
|
129
|
+
key: envelope.key,
|
|
130
|
+
kind: envelope.kind,
|
|
131
|
+
v: envelope.v,
|
|
132
|
+
value: envelope.value,
|
|
133
|
+
};
|
|
77
134
|
try {
|
|
78
135
|
const created = createKnowledgeGraphRecordV1(input as unknown as KnowledgeGraphRecordInputV1);
|
|
79
136
|
return recordSha256 !== null && created.recordSha256 === recordSha256
|