@hraness/oh 0.2.7 → 0.3.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.
Files changed (55) hide show
  1. package/README.md +119 -12
  2. package/dist/canonical.d.ts.map +1 -1
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.js +91 -19
  5. package/dist/cloudflare-embedding.d.ts +104 -0
  6. package/dist/cloudflare-embedding.d.ts.map +1 -0
  7. package/dist/graph.d.ts.map +1 -1
  8. package/dist/index.js +90 -18
  9. package/dist/libsql-semantic.d.ts +130 -0
  10. package/dist/libsql-semantic.d.ts.map +1 -0
  11. package/dist/libsql.js +105 -18
  12. package/dist/memory-page.d.ts +2 -0
  13. package/dist/memory-page.d.ts.map +1 -0
  14. package/dist/memory-page.js +725 -0
  15. package/dist/memory-pages.d.ts +76 -0
  16. package/dist/memory-pages.d.ts.map +1 -0
  17. package/dist/memory.d.ts +1 -0
  18. package/dist/memory.d.ts.map +1 -1
  19. package/dist/memory.js +478 -18
  20. package/dist/projection-public.js +105 -18
  21. package/dist/projection-suss.js +105 -18
  22. package/dist/sdk.js +90 -18
  23. package/dist/semantic-cloud.d.ts +3 -0
  24. package/dist/semantic-cloud.d.ts.map +1 -0
  25. package/dist/semantic-cloud.js +1870 -0
  26. package/dist/semantic.d.ts.map +1 -1
  27. package/dist/semantic.js +104 -21
  28. package/dist/sqlite/index.js +90 -18
  29. package/dist/store.js +105 -18
  30. package/dist/sync.js +90 -18
  31. package/package.json +10 -2
  32. package/skills/oh/SKILL.md +28 -2
  33. package/spec/README.md +11 -3
  34. package/spec/manifest.json +9 -1
  35. package/spec/v1/cloudflare-embedding-profile.json +13 -0
  36. package/spec/v1/cloudflare-embedding-renderer.json +8 -0
  37. package/spec/v1/memory-page.md +153 -0
  38. package/spec/v1/memory-page.schema.json +154 -0
  39. package/spec/v1/memory.md +18 -0
  40. package/spec/v1/migration.md +13 -0
  41. package/spec/v1/semantic-cloud.md +96 -0
  42. package/src/canonical.ts +28 -13
  43. package/src/cli.ts +1 -1
  44. package/src/cloudflare-embedding.test.ts +306 -0
  45. package/src/cloudflare-embedding.ts +385 -0
  46. package/src/contracts.test.ts +20 -0
  47. package/src/graph.ts +63 -6
  48. package/src/libsql-semantic.test.ts +585 -0
  49. package/src/libsql-semantic.ts +1168 -0
  50. package/src/memory-page.ts +1 -0
  51. package/src/memory-pages.test.ts +277 -0
  52. package/src/memory-pages.ts +440 -0
  53. package/src/memory.ts +2 -0
  54. package/src/semantic-cloud.ts +2 -0
  55. 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
+ }
@@ -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 || !Array.isArray(input.dependencies)) throw new TypeError("Invalid graph record input.");
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 || input.dependencies.length > OH_GRAPH_LIMITS_V1.dependenciesPerRecord) throw new TypeError("Invalid graph record identity.");
60
- const dependencies = input.dependencies.map(recordKey);
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
- if (!isPlainRecord(value) || !Object.hasOwn(value, "recordSha256")) return null;
75
- const recordSha256 = parseSha256Hex(value.recordSha256);
76
- const { recordSha256: _digest, ...input } = value;
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