@batalabs/virlow-mcp-core 3.83.0 → 3.84.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.
@@ -6,6 +6,7 @@ const virlow_memory_1 = require("@batalabs/virlow-memory");
6
6
  const api_js_1 = require("./api.js");
7
7
  const memory_note_js_1 = require("./memory-note.js");
8
8
  const memory_tree_js_1 = require("./memory-tree.js");
9
+ const entity_registry_js_1 = require("./entity-registry.js");
9
10
  const memory_graph_js_1 = require("./memory-graph.js");
10
11
  const memory_secrets_js_1 = require("./memory-secrets.js");
11
12
  /**
@@ -52,6 +53,12 @@ class MemoryStore {
52
53
  embeddings = null;
53
54
  embeddingsLoaded = false;
54
55
  syncInFlight = null;
56
+ /** The entity registry as last read, and the note(s) holding it, oldest
57
+ * first. `registrySupported` is false when the API refused the note type
58
+ * (an API older than the registry): kinds are then only guessed. */
59
+ registry = null;
60
+ registryRows = [];
61
+ registrySupported = null;
55
62
  mutationChain = Promise.resolve();
56
63
  /** Bumped every time clear() runs (i.e. every lock). Operations capture it
57
64
  * at the top and re-check before every cache write, so anything still
@@ -67,6 +74,9 @@ class MemoryStore {
67
74
  }
68
75
  clear() {
69
76
  this.cache.clear();
77
+ this.registry = null;
78
+ this.registryRows = [];
79
+ this.registrySupported = null;
70
80
  this.tree = null;
71
81
  this.embeddings = null;
72
82
  this.embeddingsLoaded = false;
@@ -115,6 +125,9 @@ class MemoryStore {
115
125
  // Hidden notes are segregated in the app; they are not memories.
116
126
  if (row.hidden === true)
117
127
  continue;
128
+ // The entity registry lives in the root but is not a memory.
129
+ if (row.type === entity_registry_js_1.ENTITY_REGISTRY_NOTE_TYPE)
130
+ continue;
118
131
  seen.add(row.id);
119
132
  const existing = this.cache.get(row.id);
120
133
  if (existing && existing.updatedAt === row.updatedAt) {
@@ -173,8 +186,171 @@ class MemoryStore {
173
186
  }
174
187
  await embeddings.save(key);
175
188
  }
189
+ await this.refreshRegistry(key, tree.rootId, startEpoch);
176
190
  return result;
177
191
  }
192
+ /** Re-reads the entity registry. A failure other than "this API does not
193
+ * know the type" keeps what was read before: kinds are a nicety, and a
194
+ * registry hiccup must never fail a recall. */
195
+ async refreshRegistry(key, rootId, startEpoch) {
196
+ try {
197
+ const fresh = await this.fetchRegistry(key, rootId);
198
+ if (this.epoch !== startEpoch)
199
+ return;
200
+ this.registry = fresh.registry;
201
+ this.registryRows = fresh.rows;
202
+ this.registrySupported = fresh.supported;
203
+ }
204
+ catch {
205
+ // Keep the previous registry.
206
+ }
207
+ }
208
+ async fetchRegistry(key, rootId) {
209
+ let rows;
210
+ try {
211
+ const res = await this.api.listNotes({
212
+ folderId: rootId,
213
+ type: entity_registry_js_1.ENTITY_REGISTRY_NOTE_TYPE,
214
+ deleted: false,
215
+ limit: exports.SYNC_PAGE_LIMIT,
216
+ });
217
+ // Filtered here too: an API that ignored the type filter would hand
218
+ // back every memory in the root.
219
+ rows = res.notes.filter((r) => r.type === entity_registry_js_1.ENTITY_REGISTRY_NOTE_TYPE && r.deleted !== true);
220
+ }
221
+ catch (err) {
222
+ // An API that predates the registry rejects the type in its query
223
+ // schema. Degrade to guessed kinds.
224
+ if (err instanceof api_js_1.ApiError && err.status === 400) {
225
+ return { registry: (0, entity_registry_js_1.emptyRegistry)(), rows: [], supported: false };
226
+ }
227
+ throw err;
228
+ }
229
+ // Two devices that each created the registry at once leave two notes;
230
+ // they are read as one, and the next save folds them into the oldest.
231
+ rows.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
232
+ let registry = (0, entity_registry_js_1.emptyRegistry)();
233
+ for (const row of rows) {
234
+ const { content } = await this.decryptRow(key, row);
235
+ registry = (0, entity_registry_js_1.mergeRegistries)(registry, (0, entity_registry_js_1.parseRegistry)(content));
236
+ }
237
+ return { registry, rows, supported: true };
238
+ }
239
+ /** Whether this API stores entity kinds (false: an API older than the
240
+ * registry), and the registry as last read. Requires a prior sync. */
241
+ registryState() {
242
+ return { supported: this.registrySupported !== false, registry: this.registry ?? (0, entity_registry_js_1.emptyRegistry)() };
243
+ }
244
+ /**
245
+ * Describe entities in the registry: kind, aliases, note. Read, change,
246
+ * save with expectedUpdatedAt; on a 409 (another device saved first) read
247
+ * again and re-apply, so per-key changes from both survive. With
248
+ * `onlyIfUntyped`, a kind is set only where none is stored: how add_memory
249
+ * records the kinds an AI names without overruling the person's choice.
250
+ *
251
+ * `unsupported` when the API does not accept the registry note type yet;
252
+ * nothing is saved then and kinds stay guesses.
253
+ */
254
+ describeEntities(patches, opts = {}) {
255
+ return this.enqueue(async () => {
256
+ await this.sync();
257
+ const key = this.requireSession();
258
+ const startEpoch = this.epoch;
259
+ const tree = await this.requireTree();
260
+ if (this.registrySupported === false)
261
+ return { changed: [], unsupported: true };
262
+ for (const patch of patches) {
263
+ (0, memory_secrets_js_1.assertNoSecret)(patch.name, patch.note ?? '', ...(patch.aliases ?? []));
264
+ }
265
+ for (let attempt = 0; attempt < 4; attempt++) {
266
+ const fresh = attempt === 0 && this.registry && this.registrySupported
267
+ ? { registry: this.registry, rows: this.registryRows, supported: true }
268
+ : await this.fetchRegistry(key, tree.rootId);
269
+ if (!fresh.supported) {
270
+ if (this.epoch === startEpoch)
271
+ this.registrySupported = false;
272
+ return { changed: [], unsupported: true };
273
+ }
274
+ if (!(0, entity_registry_js_1.canWriteRegistry)(fresh.registry)) {
275
+ throw new Error('The entity registry was saved by a newer version of Virlow. Update this connector to change it.');
276
+ }
277
+ const now = new Date().toISOString();
278
+ let next = fresh.registry;
279
+ const changed = [];
280
+ for (const patch of patches) {
281
+ const existing = next.entities[(0, entity_registry_js_1.registryKeyFor)(next, patch.name)];
282
+ const effective = opts.onlyIfUntyped && existing?.kind ? { ...patch, kind: undefined } : patch;
283
+ const after = (0, entity_registry_js_1.describeInRegistry)(next, effective, now);
284
+ if (after !== next)
285
+ changed.push(patch.name);
286
+ next = after;
287
+ }
288
+ if (changed.length === 0)
289
+ return { changed, unsupported: false };
290
+ const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, entity_registry_js_1.ENTITY_REGISTRY_TITLE, (0, entity_registry_js_1.serializeRegistry)(next));
291
+ const write = {
292
+ title: '[ENCRYPTED]',
293
+ content: '[ENCRYPTED]',
294
+ encryptedTitle,
295
+ encryptedContent,
296
+ iv,
297
+ salt: (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32))),
298
+ type: entity_registry_js_1.ENTITY_REGISTRY_NOTE_TYPE,
299
+ };
300
+ const [primary, ...extra] = fresh.rows;
301
+ let row;
302
+ try {
303
+ row = primary
304
+ ? await this.api.updateNote(primary.id, { ...write, expectedUpdatedAt: primary.updatedAt })
305
+ : await this.api.createNote({ ...write, folderId: tree.rootId });
306
+ }
307
+ catch (err) {
308
+ if (err instanceof api_js_1.ApiError && err.status === 409)
309
+ continue;
310
+ if (err instanceof api_js_1.ApiError && err.status === 400 && !primary) {
311
+ if (this.epoch === startEpoch)
312
+ this.registrySupported = false;
313
+ return { changed: [], unsupported: true };
314
+ }
315
+ throw err;
316
+ }
317
+ // Their entries are in `next` now; trash the duplicates so the
318
+ // registry is one note again. Best effort: a leftover is merged on
319
+ // the next read anyway.
320
+ for (const dup of extra) {
321
+ await this.api.updateNote(dup.id, { deleted: true }).catch(() => undefined);
322
+ }
323
+ if (this.epoch === startEpoch) {
324
+ this.registry = next;
325
+ this.registryRows = [row];
326
+ this.registrySupported = true;
327
+ }
328
+ return { changed, unsupported: false };
329
+ }
330
+ throw new Error('The entity registry kept changing while saving. Try again.');
331
+ });
332
+ }
333
+ /**
334
+ * Names nothing types yet, most known first, each with a few of the
335
+ * memories that name it and a suggested kind when one signal points one
336
+ * way: what an AI needs to propose kinds, and then call describe_entity.
337
+ */
338
+ async untyped(namespace, limit = 25) {
339
+ await this.sync();
340
+ this.requireSession();
341
+ const graph = this.graph(namespace);
342
+ const all = (0, memory_graph_js_1.untypedEntities)(graph);
343
+ const entities = all.slice(0, limit).map((entity) => {
344
+ const memories = [...entity.memoryIds]
345
+ .map((id) => this.cache.get(id))
346
+ .filter((m) => m !== undefined)
347
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
348
+ .slice(0, 3)
349
+ .map((m) => ({ id: m.id, label: m.label, text: m.text }));
350
+ return { name: entity.name, facts: entity.memoryIds.size, memories, suggestion: (0, memory_graph_js_1.suggestKind)(graph, entity.name) };
351
+ });
352
+ return { total: all.length, entities, supported: this.registrySupported !== false };
353
+ }
178
354
  add(label, text, namespace, meta = {}) {
179
355
  return this.enqueue(async () => {
180
356
  (0, memory_secrets_js_1.assertNoSecret)(label, text, ...(meta.relations ?? []).map((r) => `${r.s} ${r.p} ${r.o}`));
@@ -308,7 +484,7 @@ class MemoryStore {
308
484
  /** The graph over what is cached now. Cheap: rebuilt from the notes on
309
485
  * demand, never stored. */
310
486
  graph(namespace) {
311
- return (0, memory_graph_js_1.buildGraph)([...this.cache.values()].filter((m) => namespace === undefined || m.namespace === namespace || m.namespace === undefined));
487
+ return (0, memory_graph_js_1.buildGraph)([...this.cache.values()].filter((m) => namespace === undefined || m.namespace === namespace || m.namespace === undefined), (0, entity_registry_js_1.describedEntities)(this.registry));
312
488
  }
313
489
  /** New names spelled the way existing memories already spell them, and
314
490
  * predicates in snake_case, so one entity does not split in two. */
@@ -364,16 +540,22 @@ class MemoryStore {
364
540
  await this.sync();
365
541
  this.requireSession();
366
542
  const graph = this.graph(namespace);
367
- const entity = graph.entities.get((0, memory_graph_js_1.entityKey)(name));
543
+ const entity = (0, memory_graph_js_1.findEntity)(graph, name);
368
544
  if (!entity)
369
545
  return null;
370
546
  const now = Date.now();
547
+ const described = graph.described.get(entity.key);
371
548
  const memories = [...entity.memoryIds]
372
549
  .map((id) => this.cache.get(id))
373
550
  .filter(Boolean)
374
551
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
375
552
  return {
376
553
  name: entity.name,
554
+ kind: (0, memory_graph_js_1.entityKinds)(graph).get(entity.key) ?? 'other',
555
+ /** Whether the kind is stored (set by a person or an AI) or guessed. */
556
+ kindStored: described?.kind !== undefined,
557
+ aliases: described?.aliases ?? [],
558
+ note: described?.note,
377
559
  current: memories.filter((m) => (0, memory_graph_js_1.memoryHeldAt)(m, now)),
378
560
  past: memories.filter((m) => !(0, memory_graph_js_1.memoryHeldAt)(m, now)),
379
561
  relations: entity.edges.map((e) => ({ ...e })),
@@ -443,10 +625,16 @@ class MemoryStore {
443
625
  this.requireSession();
444
626
  const now = Date.now();
445
627
  const graph = this.graph(namespace);
628
+ const kinds = (0, memory_graph_js_1.entityKinds)(graph);
446
629
  const entities = [...graph.entities.values()]
447
630
  .sort((a, b) => b.memoryIds.size - a.memoryIds.size)
448
631
  .slice(0, 8)
449
- .map((e) => ({ name: e.name, facts: e.memoryIds.size }));
632
+ .map((e) => ({
633
+ name: e.name,
634
+ facts: e.memoryIds.size,
635
+ kind: kinds.get(e.key) ?? 'other',
636
+ ...(graph.described.get(e.key)?.note !== undefined && { note: graph.described.get(e.key).note }),
637
+ }));
450
638
  const facts = this.list(namespace)
451
639
  .filter((m) => (0, memory_graph_js_1.memoryHeldAt)(m, now))
452
640
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
@@ -38,7 +38,37 @@ export interface Entity {
38
38
  export interface MemoryGraph {
39
39
  entities: Map<string, Entity>;
40
40
  edges: Edge[];
41
+ /** Alias key to the key of the entity it is another name for, from the
42
+ * entity registry. Empty when there is no registry. */
43
+ aliases: Map<string, string>;
44
+ /** What the registry says about each entity, by entity key: a kind the
45
+ * person or an AI set, aliases and a note. Empty when there is none. */
46
+ described: Map<string, DescribedEntity>;
41
47
  }
48
+ /**
49
+ * The part of an entity registry entry the graph reads. The registry itself
50
+ * (an encrypted note beside the memories) is `entity-registry.ts`; the graph
51
+ * takes this narrow shape so it stays pure and has no format to know.
52
+ */
53
+ export interface DescribedEntity {
54
+ /** Display spelling, when one was given. */
55
+ name?: string;
56
+ kind?: EntityKind;
57
+ aliases?: string[];
58
+ note?: string;
59
+ }
60
+ /** The key an entity is filed under in `graph`: its own key, or, when the
61
+ * registry names it as an alias, the key of the entity it is an alias of. */
62
+ export declare function keyOf(graph: Pick<MemoryGraph, 'aliases'>, name: string): string;
63
+ /** The entity a name refers to, through aliases. */
64
+ export declare function findEntity(graph: MemoryGraph, name: string): Entity | undefined;
65
+ /**
66
+ * Alias keys from registry entries. One level only: an alias names the entry
67
+ * it sits on, never a chain. When two entries claim one alias, the first by
68
+ * key wins, so every client resolves it the same way. An alias that is the
69
+ * entry's own key is ignored.
70
+ */
71
+ export declare function aliasMap(described: Map<string, DescribedEntity>): Map<string, string>;
42
72
  /** The matching key for a name: case, spacing and a leading article do not
43
73
  * make a different entity. */
44
74
  export declare function entityKey(name: string): string;
@@ -65,7 +95,13 @@ export declare function heldAt(from: string | undefined, to: string | undefined,
65
95
  * today started in January), else when it was learned.
66
96
  */
67
97
  export declare function memoryHeldAt(memory: GraphMemory, at: number): boolean;
68
- export declare function buildGraph(memories: Iterable<GraphMemory>): MemoryGraph;
98
+ /**
99
+ * Entities and edges from memories. With `described` (the entity registry,
100
+ * by entity key), names the registry lists as aliases fold into the entity
101
+ * they are aliases of, and the registry's display name, kind and note ride
102
+ * along. The memories themselves are never rewritten for this.
103
+ */
104
+ export declare function buildGraph(memories: Iterable<GraphMemory>, described?: Map<string, DescribedEntity>): MemoryGraph;
69
105
  /** Entities named in a piece of text (a prompt, a query), longest names
70
106
  * first so "Dana Whitfield" wins over "Dana". Whole words only. */
71
107
  export declare function entitiesMentioned(graph: MemoryGraph, text: string): Entity[];
@@ -94,8 +130,8 @@ export declare function rankMemories(graph: MemoryGraph, query: string, scored:
94
130
  export declare function supersededBy(graph: MemoryGraph, relation: MemoryRelation, at: number): Edge[];
95
131
  /**
96
132
  * Housekeeping findings for a tidy pass: memories linked to nothing, names
97
- * that look like one entity spelled two ways, and single-valued predicates
98
- * holding two values at once.
133
+ * that look like one entity spelled two ways, single-valued predicates
134
+ * holding two values at once, and names with no kind.
99
135
  */
100
136
  export declare function graphHealth(graph: MemoryGraph, memories: GraphMemory[], at?: number): {
101
137
  unlinked: string[];
@@ -105,6 +141,7 @@ export declare function graphHealth(graph: MemoryGraph, memories: GraphMemory[],
105
141
  predicate: string;
106
142
  objects: string[];
107
143
  }[];
144
+ untyped: string[];
108
145
  };
109
146
  /**
110
147
  * A memory's links with one name swapped for another: how "Dana" becomes
@@ -119,10 +156,55 @@ export declare function renameEntityInMeta(meta: MemoryMeta, from: string, into:
119
156
  * Null when the memory does not link it.
120
157
  */
121
158
  export declare function unlinkEntityFromMeta(meta: MemoryMeta, name: string): MemoryMeta | null;
122
- export type EntityKind = 'person' | 'project' | 'tool' | 'place' | 'organisation' | 'other';
123
159
  /**
124
- * A best guess at what each entity is, from the roles it plays in its
125
- * relations; "other" when nothing says. A guess for filtering and colour,
126
- * never written back.
160
+ * What an entity is. `organization` was spelled `organisation` before the
161
+ * registry existed; readers accept both (see normalizeKind) and writers use
162
+ * this spelling. `other` is the fallback when nothing says.
163
+ */
164
+ export declare const ENTITY_KINDS: readonly ["person", "organization", "project", "tool", "place", "event", "topic", "product", "media", "pet", "group", "goal", "other"];
165
+ export type EntityKind = (typeof ENTITY_KINDS)[number];
166
+ /** A kind as stored or typed, as one of ENTITY_KINDS; undefined when it is
167
+ * not one. Case and a plural "s" do not matter; `organisation` reads as
168
+ * `organization`. */
169
+ export declare function normalizeKind(value: unknown): EntityKind | undefined;
170
+ /**
171
+ * What each end of a predicate usually is. "Dana works_at Acme" says Dana
172
+ * is a person and Acme an organization; "Atlas uses Postgres" says Atlas is
173
+ * a project and Postgres a tool. Unknown predicates say nothing.
174
+ */
175
+ export declare const ROLES: Record<string, [EntityKind | null, EntityKind | null]>;
176
+ /**
177
+ * What each entity is. A kind stored in the entity registry (set by the
178
+ * person or by an AI with describe_entity or add_memory) wins; otherwise a
179
+ * best guess from the roles the entity plays in its relations; "other" when
180
+ * nothing says. The guess is never written back.
127
181
  */
128
182
  export declare function entityKinds(graph: MemoryGraph): Map<string, EntityKind>;
183
+ /** The kind the registry stores for an entity, if any. */
184
+ export declare function storedKind(graph: MemoryGraph, name: string): EntityKind | undefined;
185
+ /**
186
+ * Entities nothing types: no kind in the registry and no relation role to
187
+ * guess one from. These are what a review asks the person (or an AI) to
188
+ * sort out. Most known first.
189
+ */
190
+ export declare function untypedEntities(graph: MemoryGraph): Entity[];
191
+ export interface KindSuggestion {
192
+ kind: EntityKind;
193
+ /** Why, in a few words a person can check: "works at in 2 memories". */
194
+ reason: string;
195
+ }
196
+ /**
197
+ * A suggested kind for an untyped entity, from signals on this device only
198
+ * (memory content is end-to-end encrypted, so no server can suggest
199
+ * anything). Only signals that point one way:
200
+ * - relation roles (ROLES), when the entity plays one;
201
+ * - how typed entities are used: if every typed subject (or object) of a
202
+ * predicate is one kind, an untyped one in the same place is that kind
203
+ * too ("Dana mentors Sam", with Dana a person, makes Lee in "Lee mentors
204
+ * Kim" a person), with at least two typed examples;
205
+ * - the name: a company suffix (Inc, LLC, Ltd, GmbH...) is an organization,
206
+ * and a single name that is the first name of exactly one typed person
207
+ * ("Dana" beside the person "Dana Whitfield") is a person.
208
+ * Null when nothing points one way; a wrong guess costs more than none.
209
+ */
210
+ export declare function suggestKind(graph: MemoryGraph, name: string): KindSuggestion | null;