@unson/brainbase-mcp 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/graph-embedding-provider.d.ts +38 -0
- package/dist/graph-embedding-provider.js +374 -0
- package/dist/judgment-frame.d.ts +24 -2
- package/dist/judgment-frame.js +61 -0
- package/dist/world-extension.d.ts +12 -0
- package/dist/world-extension.js +24 -3
- package/package.json +6 -2
- package/ui/world/world-canvas-ui.js +3 -2
- package/ui/world/world-district.js +135 -8
- package/ui/world/world-exits.js +200 -0
- package/ui/world/world-view.css +79 -0
- package/ui/world/world-view.js +117 -15
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const GRAPH_EMBEDDING_MODEL_ID = "gemini-embedding-001:768:v2";
|
|
2
|
+
export declare const GRAPH_EMBEDDING_DIMENSIONS = 768;
|
|
3
|
+
export type GraphEmbeddingKind = 'query' | 'passage';
|
|
4
|
+
export type GraphEmbeddingFetchResponse = {
|
|
5
|
+
readonly ok?: boolean;
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
json(): Promise<unknown>;
|
|
8
|
+
};
|
|
9
|
+
export type GraphEmbeddingFetch = (input: string, init: {
|
|
10
|
+
method: 'POST';
|
|
11
|
+
headers: Record<string, string>;
|
|
12
|
+
body: string;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
}) => Promise<GraphEmbeddingFetchResponse> | GraphEmbeddingFetchResponse;
|
|
15
|
+
export interface GraphEmbeddingProviderOptions {
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
fetchImpl?: GraphEmbeddingFetch;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
queueTimeoutMs?: number;
|
|
20
|
+
queryCacheTtlMs?: number;
|
|
21
|
+
queryCacheMaxEntries?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface GraphEmbeddingProvider {
|
|
24
|
+
readonly modelId: string;
|
|
25
|
+
readonly dimensions: number;
|
|
26
|
+
embed(texts: string[], kind: GraphEmbeddingKind): Promise<number[][]>;
|
|
27
|
+
}
|
|
28
|
+
export declare class GraphEmbeddingProviderError extends Error {
|
|
29
|
+
readonly code: string;
|
|
30
|
+
status?: number;
|
|
31
|
+
constructor(code: string, message: string, status?: number);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create the server-side Gemini embedding boundary used by Graph vector
|
|
35
|
+
* indexing and query retrieval. The queue and cache belong to this provider
|
|
36
|
+
* instance, which should be shared by the server process.
|
|
37
|
+
*/
|
|
38
|
+
export declare function createGraphEmbeddingProvider({ apiKey, fetchImpl, timeoutMs, queueTimeoutMs, queryCacheTtlMs, queryCacheMaxEntries, }?: GraphEmbeddingProviderOptions): GraphEmbeddingProvider;
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini embedding boundary for Graph vector indexing and query retrieval.
|
|
3
|
+
*
|
|
4
|
+
* This is an opt-in hosted adapter. Brainbase does not bundle a model and does
|
|
5
|
+
* not call this provider unless a host constructs it with an API key. The model
|
|
6
|
+
* id is part of the stored index identity: vectors written under
|
|
7
|
+
* GRAPH_EMBEDDING_MODEL_ID must keep the same request shape, chunking and
|
|
8
|
+
* pooling, so changing any of them requires a new model id.
|
|
9
|
+
*/
|
|
10
|
+
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents';
|
|
11
|
+
const GEMINI_MODEL = 'models/gemini-embedding-001';
|
|
12
|
+
// v2 includes UTF-8 chunking and mean-pooling for the provider input limit.
|
|
13
|
+
export const GRAPH_EMBEDDING_MODEL_ID = 'gemini-embedding-001:768:v2';
|
|
14
|
+
export const GRAPH_EMBEDDING_DIMENSIONS = 768;
|
|
15
|
+
const MAX_BATCH_SIZE = 16;
|
|
16
|
+
const MAX_TEXT_LENGTH = 6_000;
|
|
17
|
+
// Gemini embedding-001 accepts at most 2,048 input tokens. Keep each passage
|
|
18
|
+
// chunk below that limit without silently relying on provider truncation. A
|
|
19
|
+
// UTF-8 byte boundary is conservative for the multilingual corpus we index.
|
|
20
|
+
const MAX_CHUNK_UTF8_BYTES = 1_800;
|
|
21
|
+
const MAX_REQUEST_TIMEOUT_MS = 15_000;
|
|
22
|
+
const MAX_QUEUE_SIZE = 16;
|
|
23
|
+
const MAX_CACHE_ENTRIES = 256;
|
|
24
|
+
const DEFAULT_CACHE_TTL_MS = 60_000;
|
|
25
|
+
export class GraphEmbeddingProviderError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
status;
|
|
28
|
+
constructor(code, message, status) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'GraphEmbeddingProviderError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
if (Number.isInteger(status))
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function providerError(code, message, status) {
|
|
37
|
+
return new GraphEmbeddingProviderError(code, message, status);
|
|
38
|
+
}
|
|
39
|
+
function boundedInteger(value, fallback, minimum, maximum, code) {
|
|
40
|
+
if (value === undefined)
|
|
41
|
+
return fallback;
|
|
42
|
+
const number = Number(value);
|
|
43
|
+
if (!Number.isInteger(number) || number < minimum) {
|
|
44
|
+
throw providerError(code, 'Embedding provider configuration is invalid');
|
|
45
|
+
}
|
|
46
|
+
return Math.min(number, maximum);
|
|
47
|
+
}
|
|
48
|
+
function copyVector(values) {
|
|
49
|
+
return values.slice();
|
|
50
|
+
}
|
|
51
|
+
function utf8ByteLength(text) {
|
|
52
|
+
return Buffer.byteLength(text, 'utf8');
|
|
53
|
+
}
|
|
54
|
+
function splitPassage(text) {
|
|
55
|
+
if (text.length === 0)
|
|
56
|
+
return [''];
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let chunk = '';
|
|
59
|
+
let chunkBytes = 0;
|
|
60
|
+
for (const character of text) {
|
|
61
|
+
const characterBytes = utf8ByteLength(character);
|
|
62
|
+
if (chunk && chunkBytes + characterBytes > MAX_CHUNK_UTF8_BYTES) {
|
|
63
|
+
chunks.push(chunk);
|
|
64
|
+
chunk = '';
|
|
65
|
+
chunkBytes = 0;
|
|
66
|
+
}
|
|
67
|
+
chunk += character;
|
|
68
|
+
chunkBytes += characterBytes;
|
|
69
|
+
}
|
|
70
|
+
if (chunk || chunks.length === 0)
|
|
71
|
+
chunks.push(chunk);
|
|
72
|
+
return chunks;
|
|
73
|
+
}
|
|
74
|
+
function meanPoolVectors(vectors) {
|
|
75
|
+
if (!Array.isArray(vectors) || vectors.length === 0) {
|
|
76
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned no vectors');
|
|
77
|
+
}
|
|
78
|
+
const pooled = Array(GRAPH_EMBEDDING_DIMENSIONS).fill(0);
|
|
79
|
+
for (const vector of vectors) {
|
|
80
|
+
for (let index = 0; index < pooled.length; index += 1)
|
|
81
|
+
pooled[index] += vector[index];
|
|
82
|
+
}
|
|
83
|
+
return normalizeVector(pooled.map(value => value / vectors.length));
|
|
84
|
+
}
|
|
85
|
+
function normalizeVector(values) {
|
|
86
|
+
if (!Array.isArray(values) || values.length !== GRAPH_EMBEDDING_DIMENSIONS) {
|
|
87
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned an invalid vector');
|
|
88
|
+
}
|
|
89
|
+
let squaredNorm = 0;
|
|
90
|
+
for (const value of values) {
|
|
91
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
92
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned an invalid vector');
|
|
93
|
+
}
|
|
94
|
+
squaredNorm += value * value;
|
|
95
|
+
}
|
|
96
|
+
const norm = Math.sqrt(squaredNorm);
|
|
97
|
+
if (!Number.isFinite(norm) || norm <= 0) {
|
|
98
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned an invalid vector');
|
|
99
|
+
}
|
|
100
|
+
return values.map(value => value / norm);
|
|
101
|
+
}
|
|
102
|
+
function parseEmbeddings(payload, expectedCount) {
|
|
103
|
+
const embeddings = payload?.embeddings;
|
|
104
|
+
if (!payload || !Array.isArray(embeddings) || embeddings.length !== expectedCount) {
|
|
105
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned an invalid response');
|
|
106
|
+
}
|
|
107
|
+
return embeddings.map(embedding => normalizeVector(embedding?.values));
|
|
108
|
+
}
|
|
109
|
+
function createAbortSignal(timeoutMs) {
|
|
110
|
+
if (typeof AbortSignal?.timeout === 'function') {
|
|
111
|
+
return {
|
|
112
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
113
|
+
cleanup() { },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
118
|
+
return {
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
cleanup() {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function createBoundedQueue({ concurrency, maxQueue, timeoutMs }) {
|
|
126
|
+
let active = 0;
|
|
127
|
+
const pending = [];
|
|
128
|
+
function start(item) {
|
|
129
|
+
active += 1;
|
|
130
|
+
Promise.resolve()
|
|
131
|
+
.then(item.task)
|
|
132
|
+
.then(item.resolve, item.reject)
|
|
133
|
+
.finally(() => {
|
|
134
|
+
active -= 1;
|
|
135
|
+
pump();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function pump() {
|
|
139
|
+
while (active < concurrency && pending.length > 0) {
|
|
140
|
+
const item = pending.shift();
|
|
141
|
+
clearTimeout(item.timer);
|
|
142
|
+
start(item);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function enqueue(task) {
|
|
146
|
+
if (active < concurrency) {
|
|
147
|
+
return new Promise((resolve, reject) => start({ task, resolve, reject, timer: undefined }));
|
|
148
|
+
}
|
|
149
|
+
if (pending.length >= maxQueue) {
|
|
150
|
+
return Promise.reject(providerError('embedding_provider_queue_full', 'Embedding provider queue is full'));
|
|
151
|
+
}
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const item = { task, resolve, reject, timer: undefined };
|
|
154
|
+
item.timer = setTimeout(() => {
|
|
155
|
+
const index = pending.indexOf(item);
|
|
156
|
+
if (index === -1)
|
|
157
|
+
return;
|
|
158
|
+
pending.splice(index, 1);
|
|
159
|
+
reject(providerError('embedding_provider_queue_timeout', 'Embedding provider queue timed out'));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
item.timer.unref?.();
|
|
162
|
+
pending.push(item);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { enqueue };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Create the server-side Gemini embedding boundary used by Graph vector
|
|
169
|
+
* indexing and query retrieval. The queue and cache belong to this provider
|
|
170
|
+
* instance, which should be shared by the server process.
|
|
171
|
+
*/
|
|
172
|
+
export function createGraphEmbeddingProvider({ apiKey = process.env.BRAINBASE_EMBEDDING_API_KEY, fetchImpl = globalThis.fetch, timeoutMs = MAX_REQUEST_TIMEOUT_MS, queueTimeoutMs = MAX_REQUEST_TIMEOUT_MS, queryCacheTtlMs = DEFAULT_CACHE_TTL_MS, queryCacheMaxEntries = MAX_CACHE_ENTRIES, } = {}) {
|
|
173
|
+
const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : '';
|
|
174
|
+
if (!normalizedApiKey) {
|
|
175
|
+
throw providerError('embedding_provider_api_key_missing', 'Embedding provider API key is not configured');
|
|
176
|
+
}
|
|
177
|
+
if (typeof fetchImpl !== 'function') {
|
|
178
|
+
throw providerError('embedding_provider_fetch_unavailable', 'Embedding provider fetch is unavailable');
|
|
179
|
+
}
|
|
180
|
+
const requestTimeoutMs = boundedInteger(timeoutMs, MAX_REQUEST_TIMEOUT_MS, 1, MAX_REQUEST_TIMEOUT_MS, 'embedding_provider_timeout_invalid');
|
|
181
|
+
const queueWaitTimeoutMs = boundedInteger(queueTimeoutMs, MAX_REQUEST_TIMEOUT_MS, 1, MAX_REQUEST_TIMEOUT_MS, 'embedding_provider_queue_timeout_invalid');
|
|
182
|
+
const cacheTtlMs = boundedInteger(queryCacheTtlMs, DEFAULT_CACHE_TTL_MS, 1, 24 * 60 * 60 * 1000, 'embedding_provider_cache_ttl_invalid');
|
|
183
|
+
const cacheMaxEntries = boundedInteger(queryCacheMaxEntries, MAX_CACHE_ENTRIES, 1, MAX_CACHE_ENTRIES, 'embedding_provider_cache_size_invalid');
|
|
184
|
+
const queue = createBoundedQueue({
|
|
185
|
+
concurrency: 2,
|
|
186
|
+
maxQueue: MAX_QUEUE_SIZE,
|
|
187
|
+
timeoutMs: queueWaitTimeoutMs,
|
|
188
|
+
});
|
|
189
|
+
const queryCache = new Map();
|
|
190
|
+
const queryFlights = new Map();
|
|
191
|
+
function readQueryCache(text) {
|
|
192
|
+
const entry = queryCache.get(text);
|
|
193
|
+
if (!entry)
|
|
194
|
+
return undefined;
|
|
195
|
+
if (entry.expiresAt <= Date.now()) {
|
|
196
|
+
queryCache.delete(text);
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
// Refresh insertion order for LRU behavior.
|
|
200
|
+
queryCache.delete(text);
|
|
201
|
+
queryCache.set(text, entry);
|
|
202
|
+
return copyVector(entry.values);
|
|
203
|
+
}
|
|
204
|
+
function writeQueryCache(text, values) {
|
|
205
|
+
queryCache.delete(text);
|
|
206
|
+
queryCache.set(text, { values: copyVector(values), expiresAt: Date.now() + cacheTtlMs });
|
|
207
|
+
while (queryCache.size > cacheMaxEntries) {
|
|
208
|
+
queryCache.delete(queryCache.keys().next().value);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function requestBatch(texts, kind) {
|
|
212
|
+
const signalState = createAbortSignal(requestTimeoutMs);
|
|
213
|
+
let aborted = signalState.signal.aborted;
|
|
214
|
+
let timeoutTimer;
|
|
215
|
+
let removeAbortListener = () => { };
|
|
216
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
217
|
+
timeoutTimer = setTimeout(() => {
|
|
218
|
+
aborted = true;
|
|
219
|
+
reject(providerError('embedding_provider_timeout', 'Embedding provider request timed out'));
|
|
220
|
+
}, requestTimeoutMs);
|
|
221
|
+
const onAbort = () => {
|
|
222
|
+
aborted = true;
|
|
223
|
+
reject(providerError('embedding_provider_timeout', 'Embedding provider request timed out'));
|
|
224
|
+
};
|
|
225
|
+
if (signalState.signal.aborted) {
|
|
226
|
+
onAbort();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
signalState.signal.addEventListener('abort', onAbort, { once: true });
|
|
230
|
+
removeAbortListener = () => signalState.signal.removeEventListener('abort', onAbort);
|
|
231
|
+
});
|
|
232
|
+
const payload = {
|
|
233
|
+
requests: texts.map(text => ({
|
|
234
|
+
model: GEMINI_MODEL,
|
|
235
|
+
content: { parts: [{ text }] },
|
|
236
|
+
taskType: kind === 'query' ? 'RETRIEVAL_QUERY' : 'RETRIEVAL_DOCUMENT',
|
|
237
|
+
outputDimensionality: GRAPH_EMBEDDING_DIMENSIONS,
|
|
238
|
+
})),
|
|
239
|
+
};
|
|
240
|
+
let response;
|
|
241
|
+
try {
|
|
242
|
+
response = await Promise.race([
|
|
243
|
+
Promise.resolve(fetchImpl(GEMINI_API_URL, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: {
|
|
246
|
+
'content-type': 'application/json',
|
|
247
|
+
'x-goog-api-key': normalizedApiKey,
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify(payload),
|
|
250
|
+
signal: signalState.signal,
|
|
251
|
+
})),
|
|
252
|
+
timeoutPromise,
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (aborted || signalState.signal.aborted) {
|
|
257
|
+
throw providerError('embedding_provider_timeout', 'Embedding provider request timed out');
|
|
258
|
+
}
|
|
259
|
+
if (error instanceof GraphEmbeddingProviderError)
|
|
260
|
+
throw error;
|
|
261
|
+
throw providerError('embedding_provider_request_failed', 'Embedding provider request failed');
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
removeAbortListener();
|
|
265
|
+
clearTimeout(timeoutTimer);
|
|
266
|
+
signalState.cleanup();
|
|
267
|
+
}
|
|
268
|
+
if (!response || response.ok !== true) {
|
|
269
|
+
throw providerError('embedding_provider_http_error', `Embedding provider request failed (HTTP ${Number.isInteger(response?.status) ? response?.status : 0})`, Number.isInteger(response?.status) ? response?.status : undefined);
|
|
270
|
+
}
|
|
271
|
+
let result;
|
|
272
|
+
try {
|
|
273
|
+
result = await response.json();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
throw providerError('embedding_provider_invalid_response', 'Embedding provider returned an invalid response');
|
|
277
|
+
}
|
|
278
|
+
return parseEmbeddings(result, texts.length);
|
|
279
|
+
}
|
|
280
|
+
async function embedPassages(texts) {
|
|
281
|
+
const chunks = texts.flatMap((text, textIndex) => splitPassage(text).map(chunk => ({ chunk, textIndex })));
|
|
282
|
+
const chunkVectors = texts.map(() => []);
|
|
283
|
+
for (let offset = 0; offset < chunks.length; offset += MAX_BATCH_SIZE) {
|
|
284
|
+
const batch = chunks.slice(offset, offset + MAX_BATCH_SIZE);
|
|
285
|
+
const result = await queue.enqueue(() => requestBatch(batch.map(item => item.chunk), 'passage'));
|
|
286
|
+
result.forEach((values, index) => {
|
|
287
|
+
chunkVectors[batch[index].textIndex].push(values);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return chunkVectors.map(meanPoolVectors);
|
|
291
|
+
}
|
|
292
|
+
async function embedQueryBatch(texts) {
|
|
293
|
+
const promisesByText = new Map();
|
|
294
|
+
const newFlights = [];
|
|
295
|
+
for (const text of texts) {
|
|
296
|
+
if (promisesByText.has(text))
|
|
297
|
+
continue;
|
|
298
|
+
const chunkPromises = [];
|
|
299
|
+
for (const chunk of splitPassage(text)) {
|
|
300
|
+
const cached = readQueryCache(chunk);
|
|
301
|
+
if (cached) {
|
|
302
|
+
chunkPromises.push(Promise.resolve(cached));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const existing = queryFlights.get(chunk);
|
|
306
|
+
if (existing) {
|
|
307
|
+
chunkPromises.push(existing.promise);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
let resolveFlight;
|
|
311
|
+
let rejectFlight;
|
|
312
|
+
const promise = new Promise((resolve, reject) => {
|
|
313
|
+
resolveFlight = resolve;
|
|
314
|
+
rejectFlight = reject;
|
|
315
|
+
});
|
|
316
|
+
const flight = { promise, resolve: resolveFlight, reject: rejectFlight };
|
|
317
|
+
queryFlights.set(chunk, flight);
|
|
318
|
+
chunkPromises.push(promise);
|
|
319
|
+
newFlights.push({ text: chunk, flight });
|
|
320
|
+
}
|
|
321
|
+
promisesByText.set(text, Promise.all(chunkPromises).then(meanPoolVectors));
|
|
322
|
+
}
|
|
323
|
+
if (newFlights.length > 0) {
|
|
324
|
+
for (let offset = 0; offset < newFlights.length; offset += MAX_BATCH_SIZE) {
|
|
325
|
+
const batch = newFlights.slice(offset, offset + MAX_BATCH_SIZE);
|
|
326
|
+
void queue.enqueue(() => requestBatch(batch.map(item => item.text), 'query')).then(vectors => {
|
|
327
|
+
vectors.forEach((values, index) => {
|
|
328
|
+
const { text, flight } = batch[index];
|
|
329
|
+
writeQueryCache(text, values);
|
|
330
|
+
if (queryFlights.get(text) === flight)
|
|
331
|
+
queryFlights.delete(text);
|
|
332
|
+
flight.resolve(copyVector(values));
|
|
333
|
+
});
|
|
334
|
+
}).catch(error => {
|
|
335
|
+
const safeError = error instanceof GraphEmbeddingProviderError
|
|
336
|
+
? error
|
|
337
|
+
: providerError('embedding_provider_request_failed', 'Embedding provider request failed');
|
|
338
|
+
for (const { text, flight } of batch) {
|
|
339
|
+
if (queryFlights.get(text) === flight)
|
|
340
|
+
queryFlights.delete(text);
|
|
341
|
+
flight.reject(safeError);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const vectors = await Promise.all(texts.map(text => promisesByText.get(text)));
|
|
347
|
+
return vectors.map(copyVector);
|
|
348
|
+
}
|
|
349
|
+
async function embed(texts, kind) {
|
|
350
|
+
if (kind !== 'query' && kind !== 'passage') {
|
|
351
|
+
throw providerError('embedding_provider_invalid_kind', 'Embedding kind is invalid');
|
|
352
|
+
}
|
|
353
|
+
if (!Array.isArray(texts) || !texts.every(text => typeof text === 'string')) {
|
|
354
|
+
throw providerError('embedding_provider_invalid_texts', 'Embedding texts are invalid');
|
|
355
|
+
}
|
|
356
|
+
if (texts.some(text => text.length > MAX_TEXT_LENGTH)) {
|
|
357
|
+
throw providerError('embedding_provider_text_too_long', 'Embedding text exceeds the maximum length');
|
|
358
|
+
}
|
|
359
|
+
if (texts.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
if (kind === 'passage')
|
|
362
|
+
return embedPassages(texts);
|
|
363
|
+
const vectors = [];
|
|
364
|
+
for (let offset = 0; offset < texts.length; offset += MAX_BATCH_SIZE) {
|
|
365
|
+
vectors.push(...await embedQueryBatch(texts.slice(offset, offset + MAX_BATCH_SIZE)));
|
|
366
|
+
}
|
|
367
|
+
return vectors;
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
modelId: GRAPH_EMBEDDING_MODEL_ID,
|
|
371
|
+
dimensions: GRAPH_EMBEDDING_DIMENSIONS,
|
|
372
|
+
embed,
|
|
373
|
+
};
|
|
374
|
+
}
|
package/dist/judgment-frame.d.ts
CHANGED
|
@@ -63,12 +63,26 @@ export interface JudgmentFrameSelection {
|
|
|
63
63
|
readonly kind: JudgmentFrameKind;
|
|
64
64
|
readonly why: string;
|
|
65
65
|
}
|
|
66
|
+
export type JudgmentFramePredictionCheckStatus = 'held' | 'failed' | 'unchecked';
|
|
67
|
+
/**
|
|
68
|
+
* How a world-model prediction was checked against data (ADR-014). The check is
|
|
69
|
+
* structural: whether the evidence really shows the result is not judged.
|
|
70
|
+
*/
|
|
71
|
+
export interface JudgmentFramePredictionCheck {
|
|
72
|
+
/** What observation would show the prediction wrong. */
|
|
73
|
+
readonly falsified_if: string;
|
|
74
|
+
readonly status: JudgmentFramePredictionCheckStatus;
|
|
75
|
+
/** Where the check was done. Required for held and failed. */
|
|
76
|
+
readonly evidence?: string;
|
|
77
|
+
}
|
|
66
78
|
export interface JudgmentFrameUse {
|
|
67
79
|
readonly ref: string;
|
|
68
80
|
readonly role: JudgmentFrameRole;
|
|
69
81
|
readonly note: string;
|
|
70
82
|
/** Required for constraint uses: how the option stands against the philosophy. */
|
|
71
83
|
readonly verdict?: JudgmentFrameConstraintVerdict;
|
|
84
|
+
/** Optional for prediction uses only. */
|
|
85
|
+
readonly check?: JudgmentFramePredictionCheck;
|
|
72
86
|
}
|
|
73
87
|
export interface JudgmentFrameOption {
|
|
74
88
|
readonly label: string;
|
|
@@ -85,7 +99,7 @@ export interface JudgmentFrameRecord {
|
|
|
85
99
|
/** Explicit opt-in for the future owner-only public projection. */
|
|
86
100
|
readonly public_frame?: JudgmentFramePublicFrame;
|
|
87
101
|
}
|
|
88
|
-
export type JudgmentFrameIssueCode = 'frame_record_invalid' | 'frame_catalog_mismatch' | 'frame_ref_unknown' | 'frame_kind_mismatch' | 'frame_selection_duplicate' | 'frame_kind_unaddressed' | 'frame_kind_contradiction' | 'frame_use_unselected' | 'frame_role_mismatch' | 'frame_verdict_missing' | 'frame_selection_unused' | 'frame_chosen_unknown' | 'frame_public_invalid';
|
|
102
|
+
export type JudgmentFrameIssueCode = 'frame_record_invalid' | 'frame_catalog_mismatch' | 'frame_ref_unknown' | 'frame_kind_mismatch' | 'frame_selection_duplicate' | 'frame_kind_unaddressed' | 'frame_kind_contradiction' | 'frame_use_unselected' | 'frame_role_mismatch' | 'frame_verdict_missing' | 'frame_selection_unused' | 'frame_chosen_unknown' | 'frame_public_invalid' | 'frame_check_invalid';
|
|
89
103
|
export interface JudgmentFrameIssue {
|
|
90
104
|
readonly code: JudgmentFrameIssueCode;
|
|
91
105
|
readonly path: string;
|
|
@@ -94,7 +108,9 @@ export interface JudgmentFrameIssue {
|
|
|
94
108
|
/** Exceptions that ADR-014 F5 returns to a human. The host decides when they block. */
|
|
95
109
|
export type JudgmentFrameEscalationCode = 'objective_unanchored' | 'chosen_violates_philosophy' | 'chosen_philosophy_tension'
|
|
96
110
|
/** The chosen option predicts with a world model refuted at its current revision (F5, F6). */
|
|
97
|
-
| 'chosen_uses_refuted_model'
|
|
111
|
+
| 'chosen_uses_refuted_model'
|
|
112
|
+
/** The chosen option relies on a prediction that was checked against data and failed. */
|
|
113
|
+
| 'chosen_relies_on_failed_prediction';
|
|
98
114
|
export interface JudgmentFrameEscalation {
|
|
99
115
|
readonly code: JudgmentFrameEscalationCode;
|
|
100
116
|
readonly refs: readonly string[];
|
|
@@ -128,6 +144,12 @@ export declare function buildJudgmentFrameCatalog(records: readonly JudgmentFram
|
|
|
128
144
|
* summaries, when given, annotate world models; they are not part of the digest.
|
|
129
145
|
*/
|
|
130
146
|
export declare function renderJudgmentFrameCatalog(catalog: JudgmentFrameCatalog, outcomes?: ReadonlyMap<string, JudgmentModelOutcomeSummary>): string;
|
|
147
|
+
/** Counts of the chosen option's predictions by check status. A prediction without a check is unchecked. */
|
|
148
|
+
export declare function judgmentFramePredictionCheckCounts(record: JudgmentFrameRecord): {
|
|
149
|
+
held: number;
|
|
150
|
+
failed: number;
|
|
151
|
+
unchecked: number;
|
|
152
|
+
};
|
|
131
153
|
/**
|
|
132
154
|
* Check that a frame record only uses catalog references and that every
|
|
133
155
|
* selection is used in at least one option with the role of its kind.
|
package/dist/judgment-frame.js
CHANGED
|
@@ -188,6 +188,42 @@ export function renderJudgmentFrameCatalog(catalog, outcomes = new Map()) {
|
|
|
188
188
|
}
|
|
189
189
|
return lines.join('\n');
|
|
190
190
|
}
|
|
191
|
+
const PREDICTION_CHECK_STATUSES = ['held', 'failed', 'unchecked'];
|
|
192
|
+
const MAX_CHECK_EVIDENCE = 800;
|
|
193
|
+
function predictionCheckProblem(value, role) {
|
|
194
|
+
if (role !== 'prediction')
|
|
195
|
+
return 'check is only for prediction uses';
|
|
196
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !['falsified_if', 'status', 'evidence'].includes(key))) {
|
|
197
|
+
return 'check takes falsified_if, status, and evidence';
|
|
198
|
+
}
|
|
199
|
+
if (!nonEmpty(value.falsified_if, MAX_NOTE))
|
|
200
|
+
return 'check.falsified_if must say what observation would show the prediction wrong';
|
|
201
|
+
if (!PREDICTION_CHECK_STATUSES.includes(value.status)) {
|
|
202
|
+
return 'check.status must be held, failed, or unchecked';
|
|
203
|
+
}
|
|
204
|
+
if (value.evidence === undefined) {
|
|
205
|
+
return value.status === 'unchecked' ? null : 'a held or failed check needs evidence of where it was checked';
|
|
206
|
+
}
|
|
207
|
+
if (!nonEmpty(value.evidence, MAX_CHECK_EVIDENCE))
|
|
208
|
+
return `check.evidence must be at most ${MAX_CHECK_EVIDENCE} characters`;
|
|
209
|
+
for (const text of [value.falsified_if, value.evidence]) {
|
|
210
|
+
if (typeof text === 'string' && (PUBLIC_FRAME_CONTROL_CHARACTERS.test(text) || containsJudgmentFrameCredential(text))) {
|
|
211
|
+
return 'check contains control characters or a credential-like value';
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
/** Counts of the chosen option's predictions by check status. A prediction without a check is unchecked. */
|
|
217
|
+
export function judgmentFramePredictionCheckCounts(record) {
|
|
218
|
+
const chosen = record.options.find((option) => option.label === record.chosen_option);
|
|
219
|
+
const predictions = (chosen?.uses ?? []).filter((use) => use.role === 'prediction');
|
|
220
|
+
const status = (use) => use.check?.status ?? 'unchecked';
|
|
221
|
+
return {
|
|
222
|
+
held: predictions.filter((use) => status(use) === 'held').length,
|
|
223
|
+
failed: predictions.filter((use) => status(use) === 'failed').length,
|
|
224
|
+
unchecked: predictions.filter((use) => status(use) === 'unchecked').length,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
191
227
|
/**
|
|
192
228
|
* Check that a frame record only uses catalog references and that every
|
|
193
229
|
* selection is used in at least one option with the role of its kind.
|
|
@@ -298,6 +334,13 @@ export function validateJudgmentFrameRecord(value, catalog, outcomes = new Map()
|
|
|
298
334
|
issue('frame_verdict_missing', `${usePath}.verdict`, 'a constraint use needs verdict satisfied, violated, or tension');
|
|
299
335
|
return;
|
|
300
336
|
}
|
|
337
|
+
if (use.check !== undefined) {
|
|
338
|
+
const problem = predictionCheckProblem(use.check, use.role);
|
|
339
|
+
if (problem) {
|
|
340
|
+
issue('frame_check_invalid', `${usePath}.check`, problem);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
301
344
|
used.add(use.ref);
|
|
302
345
|
});
|
|
303
346
|
});
|
|
@@ -330,6 +373,12 @@ export function validateJudgmentFrameRecord(value, catalog, outcomes = new Map()
|
|
|
330
373
|
.map((use) => String(use.ref)))];
|
|
331
374
|
if (refuted.length > 0)
|
|
332
375
|
escalations.push({ code: 'chosen_uses_refuted_model', refs: refuted });
|
|
376
|
+
const failed = [...new Set(chosenUses
|
|
377
|
+
.filter((use) => isRecord(use) && use.role === 'prediction'
|
|
378
|
+
&& isRecord(use.check) && use.check.status === 'failed')
|
|
379
|
+
.map((use) => String(use.ref)))];
|
|
380
|
+
if (failed.length > 0)
|
|
381
|
+
escalations.push({ code: 'chosen_relies_on_failed_prediction', refs: failed });
|
|
333
382
|
}
|
|
334
383
|
return { valid: issues.length === 0, issues, escalations };
|
|
335
384
|
}
|
|
@@ -345,6 +394,17 @@ const frameUseSchema = {
|
|
|
345
394
|
role: { type: 'string', enum: ['constraint', 'criterion', 'prediction'] },
|
|
346
395
|
note: { type: 'string', minLength: 1, maxLength: MAX_NOTE },
|
|
347
396
|
verdict: { type: 'string', enum: ['satisfied', 'violated', 'tension'], description: 'Required when role is constraint.' },
|
|
397
|
+
check: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
additionalProperties: false,
|
|
400
|
+
description: 'Only for prediction uses: what observation would show the prediction wrong, and whether it was checked against data. held and failed need evidence. A chosen option that relies on a failed prediction returns the choice to the human.',
|
|
401
|
+
properties: {
|
|
402
|
+
falsified_if: { type: 'string', minLength: 1, maxLength: MAX_NOTE },
|
|
403
|
+
status: { type: 'string', enum: ['held', 'failed', 'unchecked'] },
|
|
404
|
+
evidence: { type: 'string', minLength: 1, maxLength: 800 },
|
|
405
|
+
},
|
|
406
|
+
required: ['falsified_if', 'status'],
|
|
407
|
+
},
|
|
348
408
|
},
|
|
349
409
|
required: ['ref', 'role', 'note'],
|
|
350
410
|
};
|
|
@@ -494,6 +554,7 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
|
|
|
494
554
|
option_count: args.options.length,
|
|
495
555
|
chosen_option: args.chosen_option,
|
|
496
556
|
escalations: validation.escalations,
|
|
557
|
+
prediction_checks: judgmentFramePredictionCheckCounts(args),
|
|
497
558
|
...(args.public_frame === undefined ? {} : {
|
|
498
559
|
public_frame: {
|
|
499
560
|
...args.public_frame,
|
|
@@ -35,7 +35,14 @@ export interface WorldVocabularyConfig {
|
|
|
35
35
|
readonly label?: string;
|
|
36
36
|
readonly phase?: WorldLifecyclePhase;
|
|
37
37
|
}>>;
|
|
38
|
+
/** The owner's words for the states of a business's tools (story-world-business-exits-v1 AC-04). */
|
|
39
|
+
readonly exit_states?: Readonly<Partial<Record<WorldExitState, {
|
|
40
|
+
readonly label?: string;
|
|
41
|
+
}>>>;
|
|
38
42
|
}
|
|
43
|
+
declare const EXIT_STATES: readonly ["available", "restricted", "unknown", "unavailable"];
|
|
44
|
+
/** The state a host gives a tool of a business (`businessExits` in the world view). */
|
|
45
|
+
export type WorldExitState = (typeof EXIT_STATES)[number];
|
|
39
46
|
export interface WorldVocabulary {
|
|
40
47
|
readonly kinds: readonly {
|
|
41
48
|
readonly key: string | null;
|
|
@@ -54,6 +61,11 @@ export interface WorldVocabulary {
|
|
|
54
61
|
readonly label: string;
|
|
55
62
|
readonly phase: WorldLifecyclePhase;
|
|
56
63
|
}[];
|
|
64
|
+
/** The words for the four tool states, the owner's over the defaults, in a fixed order. */
|
|
65
|
+
readonly exit_states: readonly {
|
|
66
|
+
readonly key: WorldExitState;
|
|
67
|
+
readonly label: string;
|
|
68
|
+
}[];
|
|
57
69
|
}
|
|
58
70
|
export interface WorldEngagement {
|
|
59
71
|
readonly id: string;
|
package/dist/world-extension.js
CHANGED
|
@@ -20,6 +20,7 @@ export const WORLD_EXTENSION_ID = 'world';
|
|
|
20
20
|
export const WORLD_EXTENSION_VERSION = 'world-extension.v1';
|
|
21
21
|
const LIFECYCLE_PHASES = ['active', 'maintenance', 'finished', 'concept'];
|
|
22
22
|
const BUILDING_FORMS = ['tower', 'hall', 'dome', 'office'];
|
|
23
|
+
const EXIT_STATES = ['available', 'restricted', 'unknown', 'unavailable'];
|
|
23
24
|
/** Lifecycle words common to any Graph; a host vocabulary may relabel or add to them. */
|
|
24
25
|
const DEFAULT_STATUSES = Object.freeze({
|
|
25
26
|
active: { label: '進行中', phase: 'active' },
|
|
@@ -29,6 +30,13 @@ const DEFAULT_STATUSES = Object.freeze({
|
|
|
29
30
|
archived: { label: '保管', phase: 'finished' },
|
|
30
31
|
concept: { label: '構想', phase: 'concept' },
|
|
31
32
|
});
|
|
33
|
+
/** The words for the states of a business's tools when the owner gives none. */
|
|
34
|
+
const DEFAULT_EXIT_STATES = Object.freeze({
|
|
35
|
+
available: '使える',
|
|
36
|
+
restricted: '権限が必要',
|
|
37
|
+
unknown: '未確認',
|
|
38
|
+
unavailable: '読めない',
|
|
39
|
+
});
|
|
32
40
|
const KIND_PALETTE = ['#1261ad', '#b07a1f', '#35684c', '#6b4fa0', '#a8433a', '#2f7d86', '#7a6a2b', '#5b6470'];
|
|
33
41
|
const UNCLASSIFIED_COLOR = '#69746d';
|
|
34
42
|
const HEX_COLOR = /^#[0-9a-f]{6}$/iu;
|
|
@@ -197,7 +205,8 @@ export function resolveWorldVocabulary(config, businesses, terms = undefined) {
|
|
|
197
205
|
phase: entry.phase && LIFECYCLE_PHASES.includes(entry.phase) ? entry.phase : base?.phase ?? 'active',
|
|
198
206
|
});
|
|
199
207
|
}
|
|
200
|
-
|
|
208
|
+
const exitStates = EXIT_STATES.map((key) => ({ key, label: text(config?.exit_states?.[key]?.label) ?? DEFAULT_EXIT_STATES[key] }));
|
|
209
|
+
return { kinds, statuses: [...statuses].map(([key, entry]) => ({ key, ...entry })), exit_states: exitStates, terms: terms === undefined ? 'none' : terms === null ? 'unavailable' : 'read' };
|
|
201
210
|
}
|
|
202
211
|
/** Reads and checks a vocabulary file; an invalid file is an error, never silently ignored. */
|
|
203
212
|
export async function readWorldVocabulary(path) {
|
|
@@ -222,7 +231,19 @@ export async function readWorldVocabulary(path) {
|
|
|
222
231
|
throw new Error(`world vocabulary status ${key} has an unknown phase`);
|
|
223
232
|
statuses[key] = { ...(text(entry.label) ? { label: text(entry.label) } : {}), ...(entry.phase ? { phase: entry.phase } : {}) };
|
|
224
233
|
}
|
|
225
|
-
|
|
234
|
+
if (parsed.exit_states === undefined)
|
|
235
|
+
return { kinds, statuses };
|
|
236
|
+
if (!isRecord(parsed.exit_states))
|
|
237
|
+
throw new Error('world vocabulary exit_states must be an object');
|
|
238
|
+
const exitStates = {};
|
|
239
|
+
for (const [key, entry] of Object.entries(parsed.exit_states)) {
|
|
240
|
+
if (!EXIT_STATES.includes(key))
|
|
241
|
+
throw new Error(`world vocabulary has an unknown exit state ${key}`);
|
|
242
|
+
if (!isRecord(entry))
|
|
243
|
+
throw new Error(`world vocabulary exit state ${key} must be an object`);
|
|
244
|
+
exitStates[key] = text(entry.label) ? { label: text(entry.label) } : {};
|
|
245
|
+
}
|
|
246
|
+
return { kinds, statuses, exit_states: exitStates };
|
|
226
247
|
}
|
|
227
248
|
/**
|
|
228
249
|
* The businesses from the same Graph the other screens read (this Mac's Graph).
|
|
@@ -347,7 +368,7 @@ export function createWorldExtension(options = {}) {
|
|
|
347
368
|
return {
|
|
348
369
|
id: WORLD_EXTENSION_ID,
|
|
349
370
|
uiDir: fileURLToPath(new URL('../ui/world/', import.meta.url)),
|
|
350
|
-
uiFiles: ['world-view.js', 'world-view.css', 'world-canvas-ui.js', 'world-canvas-ui.css', 'world-canvas-notes.js', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js', 'world-scenery.js'],
|
|
371
|
+
uiFiles: ['world-view.js', 'world-view.css', 'world-canvas-ui.js', 'world-canvas-ui.css', 'world-canvas-notes.js', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js', 'world-scenery.js', 'world-exits.js'],
|
|
351
372
|
screenEntry: 'world-view.js',
|
|
352
373
|
// P17: the world sits above the home and opens first.
|
|
353
374
|
navPosition: 'first',
|