@unson/brainbase-mcp 0.4.0 → 0.5.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 +146 -471
- package/contracts/judgment-dag/digest.json +4 -4
- package/contracts/judgment-dag/source-lock.json +65 -1
- package/contracts/judgment-value-proof/fixture.json +54 -0
- package/contracts/judgment-value-proof/schema.json +163 -0
- package/contracts/public-message-candidate.schema.json +143 -0
- package/dist/cli.js +46 -6
- package/dist/embedding-provider.d.ts +72 -0
- package/dist/embedding-provider.js +391 -0
- package/dist/graph-retrieval.d.ts +94 -0
- package/dist/graph-retrieval.js +443 -0
- package/dist/judgment-autonomy.d.ts +62 -0
- package/dist/judgment-autonomy.js +301 -0
- package/dist/judgment-dag-artifact-store.d.ts +29 -0
- package/dist/judgment-dag-artifact-store.js +416 -0
- package/dist/judgment-dag-replay-evaluation.d.ts +108 -0
- package/dist/judgment-dag-replay-evaluation.js +424 -0
- package/dist/judgment-dag-runner.d.ts +70 -0
- package/dist/judgment-dag-runner.js +325 -0
- package/dist/judgment-dag.d.ts +6 -0
- package/dist/judgment-dag.js +3 -0
- package/dist/judgment-host.d.ts +21 -0
- package/dist/judgment-host.js +180 -0
- package/dist/judgment-value-proof.d.ts +92 -0
- package/dist/judgment-value-proof.js +229 -0
- package/dist/organization-graph.d.ts +76 -0
- package/dist/organization-graph.js +331 -0
- package/dist/portable-graph.d.ts +39 -0
- package/dist/portable-graph.js +240 -0
- package/dist/server.d.ts +42 -3
- package/dist/server.js +52 -7
- package/docs/management/judgment-dag-milestones.md +31 -1
- package/package.json +20 -4
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { portableGraphDigest, validatePortableGraph } from './portable-graph.js';
|
|
2
|
+
/** The organization adapter is opt-in and never falls back after configuration. */
|
|
3
|
+
export const ORGANIZATION_GRAPH_DEFAULT_TIMEOUT_MS = 10_000;
|
|
4
|
+
export const ORGANIZATION_GRAPH_MAX_RESPONSE_BYTES = 9_000_000;
|
|
5
|
+
const MAX_URL_LENGTH = 2_048;
|
|
6
|
+
const MAX_TOKEN_LENGTH = 4_096;
|
|
7
|
+
const MAX_PROJECT_CODE_LENGTH = 256;
|
|
8
|
+
const MAX_GRAPH_ID_LENGTH = 128;
|
|
9
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;
|
|
10
|
+
export class OrganizationGraphConfigError extends Error {
|
|
11
|
+
code = 'organization_config_invalid';
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(`${'organization_config_invalid'}: ${message}`);
|
|
14
|
+
this.name = 'OrganizationGraphConfigError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class OrganizationGraphError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(`${code}: ${message}`);
|
|
21
|
+
this.name = 'OrganizationGraphError';
|
|
22
|
+
this.code = code;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read the four organization settings as one all-or-nothing configuration.
|
|
27
|
+
* With no settings, OSS keeps its existing local-first behavior. A partial
|
|
28
|
+
* configuration is an error so a typo cannot silently select another store.
|
|
29
|
+
*/
|
|
30
|
+
export function createOrganizationGraphConfig(env = process.env) {
|
|
31
|
+
const rawUrl = env.BRAINBASE_ORGANIZATION_URL;
|
|
32
|
+
const rawToken = env.BRAINBASE_ORGANIZATION_TOKEN;
|
|
33
|
+
const rawProjectCode = env.BRAINBASE_ORGANIZATION_PROJECT;
|
|
34
|
+
const rawGraphId = env.BRAINBASE_ORGANIZATION_GRAPH_ID;
|
|
35
|
+
const configured = [rawUrl, rawToken, rawProjectCode, rawGraphId].some((value) => value !== undefined);
|
|
36
|
+
if (!configured)
|
|
37
|
+
return undefined;
|
|
38
|
+
const missing = [
|
|
39
|
+
['BRAINBASE_ORGANIZATION_URL', rawUrl],
|
|
40
|
+
['BRAINBASE_ORGANIZATION_TOKEN', rawToken],
|
|
41
|
+
['BRAINBASE_ORGANIZATION_PROJECT', rawProjectCode],
|
|
42
|
+
['BRAINBASE_ORGANIZATION_GRAPH_ID', rawGraphId]
|
|
43
|
+
].filter(([, value]) => typeof value !== 'string' || value.trim() === '').map(([name]) => name);
|
|
44
|
+
if (missing.length > 0) {
|
|
45
|
+
throw new OrganizationGraphConfigError(`all organization settings must be set together; missing ${missing.join(', ')}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
url: validateOrganizationUrl(rawUrl),
|
|
49
|
+
token: validateToken(rawToken),
|
|
50
|
+
projectCode: validateProjectCode(rawProjectCode),
|
|
51
|
+
graphId: validateGraphId(rawGraphId)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Create a client without making a network request. */
|
|
55
|
+
export function createOrganizationGraphClient(config) {
|
|
56
|
+
// Validate injected configurations as well as environment-derived ones.
|
|
57
|
+
const normalized = {
|
|
58
|
+
...config,
|
|
59
|
+
url: validateOrganizationUrl(config.url),
|
|
60
|
+
token: validateToken(config.token),
|
|
61
|
+
projectCode: validateProjectCode(config.projectCode),
|
|
62
|
+
graphId: validateGraphId(config.graphId)
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
search: (input) => searchOrganizationGraph(normalized, input),
|
|
66
|
+
importPortableGraph: (bundle) => importPortableGraph(normalized, bundle),
|
|
67
|
+
readPortableGraph: () => readPortableGraph(normalized)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Forward the existing Graph retrieval input unchanged inside the request envelope. */
|
|
71
|
+
export async function searchOrganizationGraph(config, input) {
|
|
72
|
+
const payload = await requestJson(config, 'POST', route(config, 'search'), {
|
|
73
|
+
project_code: config.projectCode,
|
|
74
|
+
input
|
|
75
|
+
});
|
|
76
|
+
return parseGraphRetrievalResponse(payload);
|
|
77
|
+
}
|
|
78
|
+
/** Import is explicit; merely configuring the remote backend never uploads data. */
|
|
79
|
+
export async function importPortableGraph(config, bundle) {
|
|
80
|
+
assertPortableGraphBundle(bundle);
|
|
81
|
+
const expectedDigest = await portableGraphDigest(bundle);
|
|
82
|
+
const payload = await requestJson(config, 'POST', route(config, 'import'), {
|
|
83
|
+
project_code: config.projectCode,
|
|
84
|
+
bundle
|
|
85
|
+
});
|
|
86
|
+
const result = parseImportResult(payload);
|
|
87
|
+
if (result.digest !== expectedDigest) {
|
|
88
|
+
throw new OrganizationGraphError('organization_graph_digest_mismatch', 'organization service returned an import digest that does not match the uploaded bundle');
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/** Read and verify the complete organization-side bundle and its digest. */
|
|
93
|
+
export async function readPortableGraph(config) {
|
|
94
|
+
const payload = await requestJson(config, 'GET', route(config, undefined, { project_code: config.projectCode }));
|
|
95
|
+
const readback = parsePortableGraphReadback(payload);
|
|
96
|
+
assertPortableGraphBundle(readback.bundle);
|
|
97
|
+
const computedDigest = await portableGraphDigest(readback.bundle);
|
|
98
|
+
if (computedDigest !== readback.digest) {
|
|
99
|
+
throw new OrganizationGraphError('organization_graph_readback_mismatch', 'organization service returned a bundle whose digest does not match its contents');
|
|
100
|
+
}
|
|
101
|
+
return readback;
|
|
102
|
+
}
|
|
103
|
+
/** Validate the privacy boundary before any upload leaves the local process. */
|
|
104
|
+
export function assertPortableGraphBundle(bundle) {
|
|
105
|
+
validatePortableGraph(bundle);
|
|
106
|
+
}
|
|
107
|
+
function parseGraphRetrievalResponse(value) {
|
|
108
|
+
if (!isRecord(value)
|
|
109
|
+
|| (value.graphVersion !== 1 && value.graphVersion !== 2)
|
|
110
|
+
|| (value.schemaVersion !== 1 && value.schemaVersion !== 2)
|
|
111
|
+
|| (value.status !== 'ok' && value.status !== 'migration_required')
|
|
112
|
+
|| typeof value.migrationRequired !== 'boolean'
|
|
113
|
+
|| value.authority !== 'organization_graph'
|
|
114
|
+
|| typeof value.query !== 'string'
|
|
115
|
+
|| typeof value.asOf !== 'string'
|
|
116
|
+
|| !['semantic', 'lexical', 'seed'].includes(String(value.method))
|
|
117
|
+
|| !isRecord(value.semantic)
|
|
118
|
+
|| typeof value.semantic.available !== 'boolean'
|
|
119
|
+
|| !['semantic', 'lexical', 'seed'].includes(String(value.semantic.method))
|
|
120
|
+
|| !Array.isArray(value.candidates)
|
|
121
|
+
|| !Array.isArray(value.results)
|
|
122
|
+
|| !Array.isArray(value.observedRelations)
|
|
123
|
+
|| !Array.isArray(value.observedRelationCatalog)
|
|
124
|
+
|| !isRecord(value.traversal)
|
|
125
|
+
|| !Array.isArray(value.traversal.seedIds)
|
|
126
|
+
|| !Array.isArray(value.traversal.steps)
|
|
127
|
+
|| !Number.isInteger(value.traversal.maxSeeds)
|
|
128
|
+
|| !Number.isInteger(value.traversal.maxSteps)
|
|
129
|
+
|| !Number.isInteger(value.traversal.maxLimit)
|
|
130
|
+
|| !['complete', 'partial', 'unknown'].includes(String(value.coverage))
|
|
131
|
+
|| !Array.isArray(value.partialReasons)
|
|
132
|
+
|| !Array.isArray(value.missingEvidence)
|
|
133
|
+
|| !Array.isArray(value.evidence)
|
|
134
|
+
|| !['needs_model_verification', 'insufficient'].includes(String(value.sufficiency))
|
|
135
|
+
|| value.absenceConfirmed !== false) {
|
|
136
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service returned a response outside the OSS GraphRetrievalResponse contract');
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function parseImportResult(value) {
|
|
141
|
+
if (!isRecord(value)
|
|
142
|
+
|| (value.status !== 'imported' && value.status !== 'unchanged')
|
|
143
|
+
|| typeof value.digest !== 'string'
|
|
144
|
+
|| value.digest.trim() === '') {
|
|
145
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service returned an invalid import response');
|
|
146
|
+
}
|
|
147
|
+
return { status: value.status, digest: value.digest };
|
|
148
|
+
}
|
|
149
|
+
function parsePortableGraphReadback(value) {
|
|
150
|
+
if (!isRecord(value) || !('bundle' in value) || typeof value.digest !== 'string' || value.digest.trim() === '') {
|
|
151
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service returned an invalid Graph readback');
|
|
152
|
+
}
|
|
153
|
+
return { bundle: value.bundle, digest: value.digest };
|
|
154
|
+
}
|
|
155
|
+
async function requestJson(config, method, url, body) {
|
|
156
|
+
const fetchImpl = config.fetch ?? defaultFetch;
|
|
157
|
+
const startedAt = Date.now();
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timeoutMs = validateTimeout(config.timeoutMs ?? ORGANIZATION_GRAPH_DEFAULT_TIMEOUT_MS);
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
161
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function')
|
|
162
|
+
timer.unref();
|
|
163
|
+
const headers = {
|
|
164
|
+
accept: 'application/json',
|
|
165
|
+
authorization: `Bearer ${config.token}`
|
|
166
|
+
};
|
|
167
|
+
if (body !== undefined)
|
|
168
|
+
headers['content-type'] = 'application/json';
|
|
169
|
+
let response;
|
|
170
|
+
try {
|
|
171
|
+
response = await withTimeout(fetchImpl(url, {
|
|
172
|
+
method,
|
|
173
|
+
headers,
|
|
174
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
175
|
+
redirect: 'error',
|
|
176
|
+
signal: controller.signal
|
|
177
|
+
}), controller, timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
if (error instanceof OrganizationGraphError)
|
|
182
|
+
throw error;
|
|
183
|
+
if (controller.signal.aborted) {
|
|
184
|
+
throw new OrganizationGraphError('organization_graph_timeout', `organization service did not respond within ${timeoutMs}ms`);
|
|
185
|
+
}
|
|
186
|
+
throw new OrganizationGraphError('organization_graph_unavailable', 'organization service request failed');
|
|
187
|
+
}
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
if (!Number.isInteger(response.status)) {
|
|
190
|
+
throw new OrganizationGraphError('organization_graph_unavailable', 'organization service returned an invalid HTTP status');
|
|
191
|
+
}
|
|
192
|
+
if (response.status >= 300 && response.status < 400) {
|
|
193
|
+
throw new OrganizationGraphError('organization_graph_redirect_rejected', 'organization service redirects are not accepted');
|
|
194
|
+
}
|
|
195
|
+
if (response.status === 401 || response.status === 403) {
|
|
196
|
+
throw new OrganizationGraphError('organization_graph_unauthorized', 'organization service rejected the configured credentials');
|
|
197
|
+
}
|
|
198
|
+
if (response.status < 200 || response.status >= 300) {
|
|
199
|
+
throw new OrganizationGraphError('organization_graph_request_failed', `organization service returned HTTP ${response.status}`);
|
|
200
|
+
}
|
|
201
|
+
let raw;
|
|
202
|
+
try {
|
|
203
|
+
raw = await withTimeout(readResponseText(response, controller, timeoutMs), controller, Math.max(1, timeoutMs - (Date.now() - startedAt)));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error instanceof OrganizationGraphError)
|
|
207
|
+
throw error;
|
|
208
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service response could not be read');
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(raw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service returned invalid JSON');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function readResponseText(response, controller, timeoutMs) {
|
|
218
|
+
if (response.body) {
|
|
219
|
+
const reader = response.body.getReader();
|
|
220
|
+
const chunks = [];
|
|
221
|
+
let bytes = 0;
|
|
222
|
+
while (true) {
|
|
223
|
+
const part = await withTimeout(reader.read(), controller, timeoutMs);
|
|
224
|
+
if (part.done)
|
|
225
|
+
break;
|
|
226
|
+
const chunk = Buffer.from(part.value ?? new Uint8Array());
|
|
227
|
+
bytes += chunk.byteLength;
|
|
228
|
+
if (bytes > ORGANIZATION_GRAPH_MAX_RESPONSE_BYTES) {
|
|
229
|
+
await reader.cancel?.('response too large');
|
|
230
|
+
throw new OrganizationGraphError('organization_graph_response_too_large', 'organization service response exceeds the maximum size');
|
|
231
|
+
}
|
|
232
|
+
chunks.push(chunk);
|
|
233
|
+
}
|
|
234
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
235
|
+
}
|
|
236
|
+
if (response.text) {
|
|
237
|
+
const raw = await withTimeout(response.text(), controller, timeoutMs);
|
|
238
|
+
if (Buffer.byteLength(raw, 'utf8') > ORGANIZATION_GRAPH_MAX_RESPONSE_BYTES) {
|
|
239
|
+
throw new OrganizationGraphError('organization_graph_response_too_large', 'organization service response exceeds the maximum size');
|
|
240
|
+
}
|
|
241
|
+
return raw;
|
|
242
|
+
}
|
|
243
|
+
if (response.json) {
|
|
244
|
+
const value = await withTimeout(response.json(), controller, timeoutMs);
|
|
245
|
+
const raw = JSON.stringify(value);
|
|
246
|
+
if (Buffer.byteLength(raw, 'utf8') > ORGANIZATION_GRAPH_MAX_RESPONSE_BYTES) {
|
|
247
|
+
throw new OrganizationGraphError('organization_graph_response_too_large', 'organization service response exceeds the maximum size');
|
|
248
|
+
}
|
|
249
|
+
return raw;
|
|
250
|
+
}
|
|
251
|
+
throw new OrganizationGraphError('organization_graph_response_invalid', 'organization service response has no readable body');
|
|
252
|
+
}
|
|
253
|
+
async function withTimeout(operation, controller, timeoutMs) {
|
|
254
|
+
let timer;
|
|
255
|
+
const timeout = new Promise((_, reject) => {
|
|
256
|
+
timer = setTimeout(() => {
|
|
257
|
+
controller.abort();
|
|
258
|
+
reject(new OrganizationGraphError('organization_graph_timeout', `organization service did not respond within ${timeoutMs}ms`));
|
|
259
|
+
}, timeoutMs);
|
|
260
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function')
|
|
261
|
+
timer.unref();
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
return await Promise.race([operation, timeout]);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
if (timer !== undefined)
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
function route(config, suffix, query) {
|
|
272
|
+
const base = new URL(config.url);
|
|
273
|
+
const prefix = base.pathname.replace(/\/+$/u, '');
|
|
274
|
+
const routePath = `${prefix}/api/info/graph/portable/${encodeURIComponent(config.graphId)}${suffix ? `/${suffix}` : ''}`;
|
|
275
|
+
const endpoint = new URL(routePath || '/', base.origin);
|
|
276
|
+
for (const [key, value] of Object.entries(query ?? {}))
|
|
277
|
+
endpoint.searchParams.set(key, value);
|
|
278
|
+
return endpoint.href;
|
|
279
|
+
}
|
|
280
|
+
function validateOrganizationUrl(raw) {
|
|
281
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_URL_LENGTH) {
|
|
282
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_URL must be a valid base URL');
|
|
283
|
+
}
|
|
284
|
+
let endpoint;
|
|
285
|
+
try {
|
|
286
|
+
endpoint = new URL(raw.trim());
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_URL must be a valid base URL');
|
|
290
|
+
}
|
|
291
|
+
if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
|
292
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_URL must not contain credentials, query parameters, or a fragment');
|
|
293
|
+
}
|
|
294
|
+
const host = endpoint.hostname.toLowerCase().replace(/^\[|\]$/gu, '');
|
|
295
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
296
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) {
|
|
297
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_URL must use HTTPS; HTTP is allowed only for loopback services');
|
|
298
|
+
}
|
|
299
|
+
endpoint.pathname = endpoint.pathname.replace(/\/+$/u, '') || '/';
|
|
300
|
+
return endpoint.href;
|
|
301
|
+
}
|
|
302
|
+
function validateToken(raw) {
|
|
303
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_TOKEN_LENGTH || CONTROL_CHARACTER.test(raw)) {
|
|
304
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_TOKEN is invalid');
|
|
305
|
+
}
|
|
306
|
+
return raw;
|
|
307
|
+
}
|
|
308
|
+
function validateProjectCode(raw) {
|
|
309
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_PROJECT_CODE_LENGTH || CONTROL_CHARACTER.test(raw)) {
|
|
310
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_PROJECT is invalid');
|
|
311
|
+
}
|
|
312
|
+
return raw.trim();
|
|
313
|
+
}
|
|
314
|
+
function validateGraphId(raw) {
|
|
315
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_GRAPH_ID_LENGTH || !/^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(raw)) {
|
|
316
|
+
throw new OrganizationGraphConfigError('BRAINBASE_ORGANIZATION_GRAPH_ID must be an opaque path-safe ID');
|
|
317
|
+
}
|
|
318
|
+
return raw;
|
|
319
|
+
}
|
|
320
|
+
function validateTimeout(value) {
|
|
321
|
+
if (!Number.isInteger(value) || value < 1 || value > 60_000) {
|
|
322
|
+
throw new OrganizationGraphConfigError('organization graph timeout must be an integer between 1 and 60000 milliseconds');
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
function isRecord(value) {
|
|
327
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
328
|
+
}
|
|
329
|
+
function defaultFetch(input, init) {
|
|
330
|
+
return fetch(input, init);
|
|
331
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type GraphRetrievalInput, type GraphRetrievalResponse } from './graph-retrieval.js';
|
|
2
|
+
import type { EmbeddingProvider } from './embedding-provider.js';
|
|
3
|
+
import type { DecisionRecord, GraphFileV2, PersonalOs } from './types.js';
|
|
4
|
+
/** The public, read-only interchange format for a canonical Graph v2 snapshot. */
|
|
5
|
+
export declare const PORTABLE_GRAPH_SCHEMA_VERSION: 1;
|
|
6
|
+
/** Bounds keep an imported snapshot finite before it reaches a runtime or model. */
|
|
7
|
+
export declare const PORTABLE_GRAPH_MAX_ENTITIES = 10000;
|
|
8
|
+
export declare const PORTABLE_GRAPH_MAX_EDGES = 25000;
|
|
9
|
+
export declare const PORTABLE_GRAPH_MAX_DECISIONS = 10000;
|
|
10
|
+
export declare const PORTABLE_GRAPH_MAX_JSON_BYTES = 8000000;
|
|
11
|
+
export declare const PORTABLE_GRAPH_MAX_JSON_DEPTH = 64;
|
|
12
|
+
export interface PortableGraphBundle {
|
|
13
|
+
schemaVersion: typeof PORTABLE_GRAPH_SCHEMA_VERSION;
|
|
14
|
+
graph: GraphFileV2;
|
|
15
|
+
decisions: DecisionRecord[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a detached Graph v2 bundle for transfer between the OSS and
|
|
19
|
+
* organization runtimes. Personal KG, legacy relationships, source files, and
|
|
20
|
+
* the local data directory are deliberately outside this contract.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createPortableGraph(os: PersonalOs): PortableGraphBundle;
|
|
23
|
+
/**
|
|
24
|
+
* Validate the exact portable contract. Unknown top-level fields are rejected
|
|
25
|
+
* so an organization runtime cannot silently ignore a new capability. Unknown
|
|
26
|
+
* fields inside Graph records are retained after the canonical Graph validator
|
|
27
|
+
* accepts them, which keeps forward-compatible metadata lossless.
|
|
28
|
+
*/
|
|
29
|
+
export declare function validatePortableGraph(value: unknown): asserts value is PortableGraphBundle;
|
|
30
|
+
/** Rehydrate a validated bundle into the shared retrieval input shape. */
|
|
31
|
+
export declare function hydratePortableGraph(bundle: PortableGraphBundle): PersonalOs;
|
|
32
|
+
/** Use exactly the OSS retrieval implementation against a portable bundle. */
|
|
33
|
+
export declare function retrievePortableGraph(bundle: PortableGraphBundle, input: GraphRetrievalInput, provider?: EmbeddingProvider): Promise<GraphRetrievalResponse>;
|
|
34
|
+
/** Canonical JSON with recursively sorted object keys and preserved array order. */
|
|
35
|
+
export declare function canonicalPortableJson(value: unknown): string;
|
|
36
|
+
/** Stable content identity for a portable Graph bundle. */
|
|
37
|
+
export declare function portableGraphDigest(value: PortableGraphBundle): string;
|
|
38
|
+
/** Descriptive alias for callers that prefer the content-identity wording. */
|
|
39
|
+
export declare const digestPortableGraph: typeof portableGraphDigest;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { validateCanonicalGraph } from './canonical-graph.js';
|
|
3
|
+
import { retrieveGraph } from './graph-retrieval.js';
|
|
4
|
+
/** The public, read-only interchange format for a canonical Graph v2 snapshot. */
|
|
5
|
+
export const PORTABLE_GRAPH_SCHEMA_VERSION = 1;
|
|
6
|
+
/** Bounds keep an imported snapshot finite before it reaches a runtime or model. */
|
|
7
|
+
export const PORTABLE_GRAPH_MAX_ENTITIES = 10_000;
|
|
8
|
+
export const PORTABLE_GRAPH_MAX_EDGES = 25_000;
|
|
9
|
+
export const PORTABLE_GRAPH_MAX_DECISIONS = 10_000;
|
|
10
|
+
export const PORTABLE_GRAPH_MAX_JSON_BYTES = 8_000_000;
|
|
11
|
+
export const PORTABLE_GRAPH_MAX_JSON_DEPTH = 64;
|
|
12
|
+
/**
|
|
13
|
+
* Create a detached Graph v2 bundle for transfer between the OSS and
|
|
14
|
+
* organization runtimes. Personal KG, legacy relationships, source files, and
|
|
15
|
+
* the local data directory are deliberately outside this contract.
|
|
16
|
+
*/
|
|
17
|
+
export function createPortableGraph(os) {
|
|
18
|
+
if (!os || typeof os !== 'object' || !('graph' in os) || !('decisions' in os)) {
|
|
19
|
+
throw new Error('PORTABLE-GRAPH-SOURCE: a PersonalOs with graph and decisions is required');
|
|
20
|
+
}
|
|
21
|
+
validateCanonicalGraph(os.graph);
|
|
22
|
+
if (os.graph.version !== 2) {
|
|
23
|
+
throw new Error('PORTABLE-GRAPH-V1-REJECTED: only Graph v2 can be exported');
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(os.decisions)) {
|
|
26
|
+
throw new Error('PORTABLE-GRAPH-DECISIONS: decisions must be an array');
|
|
27
|
+
}
|
|
28
|
+
const decisionIds = new Set(os.graph.entities.filter((entity) => entity.type === 'decision').map((entity) => entity.id));
|
|
29
|
+
const decisions = os.decisions
|
|
30
|
+
.filter((decision) => decisionIds.has(decision.id))
|
|
31
|
+
.map((decision) => structuredClone(decision))
|
|
32
|
+
.sort((left, right) => left.id.localeCompare(right.id, 'en'));
|
|
33
|
+
const bundle = {
|
|
34
|
+
schemaVersion: PORTABLE_GRAPH_SCHEMA_VERSION,
|
|
35
|
+
graph: structuredClone(os.graph),
|
|
36
|
+
decisions
|
|
37
|
+
};
|
|
38
|
+
validatePortableGraph(bundle);
|
|
39
|
+
return bundle;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate the exact portable contract. Unknown top-level fields are rejected
|
|
43
|
+
* so an organization runtime cannot silently ignore a new capability. Unknown
|
|
44
|
+
* fields inside Graph records are retained after the canonical Graph validator
|
|
45
|
+
* accepts them, which keeps forward-compatible metadata lossless.
|
|
46
|
+
*/
|
|
47
|
+
export function validatePortableGraph(value) {
|
|
48
|
+
assertJsonValue(value, '$', new WeakSet(), 0);
|
|
49
|
+
if (!isPlainRecord(value))
|
|
50
|
+
throw new Error('PORTABLE-GRAPH-SHAPE: bundle must be a plain JSON object');
|
|
51
|
+
const topLevelKeys = Object.getOwnPropertyNames(value).sort();
|
|
52
|
+
const expected = ['decisions', 'graph', 'schemaVersion'];
|
|
53
|
+
const unknownKeys = topLevelKeys.filter((key) => !expected.includes(key));
|
|
54
|
+
if (unknownKeys.length > 0) {
|
|
55
|
+
throw new Error(`PORTABLE-GRAPH-SHAPE: unknown top-level field ${unknownKeys[0]}`);
|
|
56
|
+
}
|
|
57
|
+
if (value.schemaVersion !== PORTABLE_GRAPH_SCHEMA_VERSION) {
|
|
58
|
+
throw new Error(`PORTABLE-GRAPH-VERSION: unsupported portable schema version ${String(value.schemaVersion)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!isPlainRecord(value.graph)) {
|
|
61
|
+
throw new Error('PORTABLE-GRAPH-SHAPE: graph must be a plain JSON object');
|
|
62
|
+
}
|
|
63
|
+
if (value.graph.version === 1) {
|
|
64
|
+
throw new Error('PORTABLE-GRAPH-V1-REJECTED: portable bundles require Graph v2');
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(value.decisions)) {
|
|
67
|
+
throw new Error('PORTABLE-GRAPH-DECISIONS: decisions must be an array');
|
|
68
|
+
}
|
|
69
|
+
const graph = value.graph;
|
|
70
|
+
if (graph.version !== 2) {
|
|
71
|
+
// Keep the canonical validator's explicit version error for other future
|
|
72
|
+
// versions while still making the portable boundary clear to callers.
|
|
73
|
+
validateCanonicalGraph(graph);
|
|
74
|
+
}
|
|
75
|
+
validateCanonicalGraph(graph);
|
|
76
|
+
if (graph.entities.length > PORTABLE_GRAPH_MAX_ENTITIES) {
|
|
77
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: graph.entities exceeds ${PORTABLE_GRAPH_MAX_ENTITIES}`);
|
|
78
|
+
}
|
|
79
|
+
if (graph.edges.length > PORTABLE_GRAPH_MAX_EDGES) {
|
|
80
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: graph.edges exceeds ${PORTABLE_GRAPH_MAX_EDGES}`);
|
|
81
|
+
}
|
|
82
|
+
if (value.decisions.length > PORTABLE_GRAPH_MAX_DECISIONS) {
|
|
83
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: decisions exceeds ${PORTABLE_GRAPH_MAX_DECISIONS}`);
|
|
84
|
+
}
|
|
85
|
+
validateDecisionRecords(graph, value.decisions);
|
|
86
|
+
const bytes = Buffer.byteLength(canonicalPortableJson(value), 'utf8');
|
|
87
|
+
if (bytes > PORTABLE_GRAPH_MAX_JSON_BYTES) {
|
|
88
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: JSON payload exceeds ${PORTABLE_GRAPH_MAX_JSON_BYTES} bytes`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Rehydrate a validated bundle into the shared retrieval input shape. */
|
|
92
|
+
export function hydratePortableGraph(bundle) {
|
|
93
|
+
validatePortableGraph(bundle);
|
|
94
|
+
return {
|
|
95
|
+
dataDir: '',
|
|
96
|
+
graph: structuredClone(bundle.graph),
|
|
97
|
+
personalKg: [],
|
|
98
|
+
relationships: { version: 1, relationships: [] },
|
|
99
|
+
decisions: structuredClone(bundle.decisions),
|
|
100
|
+
// sourceCount counts imported source files in the local SSOT. A portable
|
|
101
|
+
// Graph bundle contains no source files, so zero is the truthful value.
|
|
102
|
+
sourceCount: 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Use exactly the OSS retrieval implementation against a portable bundle. */
|
|
106
|
+
export function retrievePortableGraph(bundle, input, provider) {
|
|
107
|
+
return retrieveGraph(hydratePortableGraph(bundle), input, provider);
|
|
108
|
+
}
|
|
109
|
+
/** Canonical JSON with recursively sorted object keys and preserved array order. */
|
|
110
|
+
export function canonicalPortableJson(value) {
|
|
111
|
+
assertJsonValue(value, '$', new WeakSet(), 0);
|
|
112
|
+
return stableJson(value, 0);
|
|
113
|
+
}
|
|
114
|
+
/** Stable content identity for a portable Graph bundle. */
|
|
115
|
+
export function portableGraphDigest(value) {
|
|
116
|
+
validatePortableGraph(value);
|
|
117
|
+
return `sha256:${createHash('sha256').update(canonicalPortableJson(value), 'utf8').digest('hex')}`;
|
|
118
|
+
}
|
|
119
|
+
/** Descriptive alias for callers that prefer the content-identity wording. */
|
|
120
|
+
export const digestPortableGraph = portableGraphDigest;
|
|
121
|
+
function validateDecisionRecords(graph, decisions) {
|
|
122
|
+
const decisionEntities = new Set(graph.entities.filter((entity) => entity.type === 'decision').map((entity) => entity.id));
|
|
123
|
+
const records = new Map();
|
|
124
|
+
decisions.forEach((value, index) => {
|
|
125
|
+
if (!isPlainRecord(value))
|
|
126
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-SHAPE at decisions[${index}]`);
|
|
127
|
+
requireNonEmptyString(value.id, `decisions[${index}].id`);
|
|
128
|
+
requireNonEmptyString(value.title, `decisions[${index}].title`);
|
|
129
|
+
requireNonEmptyString(value.decision, `decisions[${index}].decision`);
|
|
130
|
+
if (records.has(value.id))
|
|
131
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-ID-UNIQUE: duplicate decision ID ${value.id}`);
|
|
132
|
+
records.set(value.id, value);
|
|
133
|
+
optionalString(value.topic, `decisions[${index}].topic`);
|
|
134
|
+
optionalString(value.rationale, `decisions[${index}].rationale`);
|
|
135
|
+
optionalString(value.updatedAt, `decisions[${index}].updatedAt`);
|
|
136
|
+
if (value.effectiveAt !== undefined) {
|
|
137
|
+
if (typeof value.effectiveAt !== 'string' || !isRfc3339(value.effectiveAt)) {
|
|
138
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-DATE at decisions[${index}].effectiveAt`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (value.tags !== undefined && (!Array.isArray(value.tags) || value.tags.some((tag) => typeof tag !== 'string'))) {
|
|
142
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-TAGS at decisions[${index}].tags`);
|
|
143
|
+
}
|
|
144
|
+
if (value.supersedes !== undefined) {
|
|
145
|
+
if (!Array.isArray(value.supersedes) || value.supersedes.some((id) => typeof id !== 'string' || id.trim() === '')) {
|
|
146
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-SUPERSEDES at decisions[${index}].supersedes`);
|
|
147
|
+
}
|
|
148
|
+
const refs = new Set();
|
|
149
|
+
for (const target of value.supersedes) {
|
|
150
|
+
if (refs.has(target))
|
|
151
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-REFERENCE: duplicate supersedes reference ${target}`);
|
|
152
|
+
refs.add(target);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
for (const id of decisionEntities) {
|
|
157
|
+
if (!records.has(id))
|
|
158
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-REFERENCE: missing decision record for Graph entity ${id}`);
|
|
159
|
+
}
|
|
160
|
+
for (const [id, decision] of records) {
|
|
161
|
+
const entity = graph.entities.find((candidate) => candidate.id === id);
|
|
162
|
+
if (!entity || entity.type !== 'decision') {
|
|
163
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-REFERENCE: decision record ${id} has no Graph decision entity`);
|
|
164
|
+
}
|
|
165
|
+
for (const target of decision.supersedes ?? []) {
|
|
166
|
+
if (!records.has(target)) {
|
|
167
|
+
throw new Error(`PORTABLE-GRAPH-DECISION-REFERENCE: decision ${id} supersedes missing decision ${target}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function requireNonEmptyString(value, path) {
|
|
173
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
174
|
+
throw new Error(`PORTABLE-GRAPH-STRING at ${path}`);
|
|
175
|
+
}
|
|
176
|
+
function optionalString(value, path) {
|
|
177
|
+
if (value !== undefined && typeof value !== 'string')
|
|
178
|
+
throw new Error(`PORTABLE-GRAPH-STRING at ${path}`);
|
|
179
|
+
}
|
|
180
|
+
function isRfc3339(value) {
|
|
181
|
+
return /^(?:\d{4})-(?:\d{2})-(?:\d{2})T(?:\d{2}):(?:\d{2}):(?:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && Number.isFinite(Date.parse(value));
|
|
182
|
+
}
|
|
183
|
+
function isPlainRecord(value) {
|
|
184
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
185
|
+
return false;
|
|
186
|
+
const prototype = Object.getPrototypeOf(value);
|
|
187
|
+
return prototype === Object.prototype || prototype === null;
|
|
188
|
+
}
|
|
189
|
+
function assertJsonValue(value, path, active, depth) {
|
|
190
|
+
if (depth > PORTABLE_GRAPH_MAX_JSON_DEPTH)
|
|
191
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: JSON depth exceeds ${PORTABLE_GRAPH_MAX_JSON_DEPTH} at ${path}`);
|
|
192
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
193
|
+
return;
|
|
194
|
+
if (typeof value === 'number') {
|
|
195
|
+
if (!Number.isFinite(value))
|
|
196
|
+
throw new Error(`PORTABLE-GRAPH-JSON: non-finite number at ${path}`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof value !== 'object')
|
|
200
|
+
throw new Error(`PORTABLE-GRAPH-JSON: non-JSON value at ${path}`);
|
|
201
|
+
if (active.has(value))
|
|
202
|
+
throw new Error(`PORTABLE-GRAPH-JSON: cyclic value at ${path}`);
|
|
203
|
+
active.add(value);
|
|
204
|
+
try {
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
207
|
+
if (!(index in value))
|
|
208
|
+
throw new Error(`PORTABLE-GRAPH-JSON: sparse array at ${path}[${index}]`);
|
|
209
|
+
assertJsonValue(value[index], `${path}[${index}]`, active, depth + 1);
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!isPlainRecord(value))
|
|
214
|
+
throw new Error(`PORTABLE-GRAPH-JSON: value at ${path} must be a plain JSON object`);
|
|
215
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
216
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
217
|
+
if (!descriptor || !('value' in descriptor))
|
|
218
|
+
throw new Error(`PORTABLE-GRAPH-JSON: accessor at ${path}.${key}`);
|
|
219
|
+
assertJsonValue(descriptor.value, `${path}.${key}`, active, depth + 1);
|
|
220
|
+
}
|
|
221
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
222
|
+
throw new Error(`PORTABLE-GRAPH-JSON: symbol key at ${path}`);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
active.delete(value);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function stableJson(value, depth) {
|
|
229
|
+
if (depth > PORTABLE_GRAPH_MAX_JSON_DEPTH)
|
|
230
|
+
throw new Error(`PORTABLE-GRAPH-BOUND: JSON depth exceeds ${PORTABLE_GRAPH_MAX_JSON_DEPTH}`);
|
|
231
|
+
if (value === null)
|
|
232
|
+
return 'null';
|
|
233
|
+
if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number')
|
|
234
|
+
return JSON.stringify(value);
|
|
235
|
+
if (Array.isArray(value))
|
|
236
|
+
return `[${value.map((item) => stableJson(item, depth + 1)).join(',')}]`;
|
|
237
|
+
const record = value;
|
|
238
|
+
const pairs = Object.getOwnPropertyNames(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key], depth + 1)}`);
|
|
239
|
+
return `{${pairs.join(',')}}`;
|
|
240
|
+
}
|