@batalabs/virlow-mcp-core 3.83.0 → 3.84.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.
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NEIGHBOUR_BOOST = exports.DIRECT_BOOST = exports.SINGLE_VALUED = exports.ONE_SUBJECT_PER_OBJECT = exports.ONE_OBJECT_PER_SUBJECT = void 0;
3
+ exports.ROLES = exports.ENTITY_KINDS = exports.NEIGHBOUR_BOOST = exports.DIRECT_BOOST = exports.SINGLE_VALUED = exports.ONE_SUBJECT_PER_OBJECT = exports.ONE_OBJECT_PER_SUBJECT = void 0;
4
+ exports.keyOf = keyOf;
5
+ exports.findEntity = findEntity;
6
+ exports.aliasMap = aliasMap;
4
7
  exports.entityKey = entityKey;
5
8
  exports.normalizePredicate = normalizePredicate;
6
9
  exports.heldAt = heldAt;
@@ -13,7 +16,45 @@ exports.supersededBy = supersededBy;
13
16
  exports.graphHealth = graphHealth;
14
17
  exports.renameEntityInMeta = renameEntityInMeta;
15
18
  exports.unlinkEntityFromMeta = unlinkEntityFromMeta;
19
+ exports.normalizeKind = normalizeKind;
16
20
  exports.entityKinds = entityKinds;
21
+ exports.storedKind = storedKind;
22
+ exports.untypedEntities = untypedEntities;
23
+ exports.suggestKind = suggestKind;
24
+ /** The key an entity is filed under in `graph`: its own key, or, when the
25
+ * registry names it as an alias, the key of the entity it is an alias of. */
26
+ function keyOf(graph, name) {
27
+ const key = entityKey(name);
28
+ return graph.aliases?.get(key) ?? key;
29
+ }
30
+ /** The entity a name refers to, through aliases. */
31
+ function findEntity(graph, name) {
32
+ return graph.entities.get(keyOf(graph, name));
33
+ }
34
+ /**
35
+ * Alias keys from registry entries. One level only: an alias names the entry
36
+ * it sits on, never a chain. When two entries claim one alias, the first by
37
+ * key wins, so every client resolves it the same way. An alias that is the
38
+ * entry's own key is ignored.
39
+ */
40
+ function aliasMap(described) {
41
+ const aliases = new Map();
42
+ for (const key of [...described.keys()].sort()) {
43
+ for (const alias of described.get(key).aliases ?? []) {
44
+ const aliasKey = entityKey(alias);
45
+ if (!aliasKey || aliasKey === key || aliases.has(aliasKey))
46
+ continue;
47
+ // An entry that is itself an alias of something else does not gain
48
+ // aliases of its own: that would be a chain.
49
+ aliases.set(aliasKey, key);
50
+ }
51
+ }
52
+ for (const [aliasKey, target] of [...aliases]) {
53
+ if (aliases.has(target))
54
+ aliases.delete(aliasKey);
55
+ }
56
+ return aliases;
57
+ }
17
58
  /** The matching key for a name: case, spacing and a leading article do not
18
59
  * make a different entity. */
19
60
  function entityKey(name) {
@@ -87,12 +128,19 @@ function memoryHeldAt(memory, at) {
87
128
  const start = memory.meta.validFrom ?? relationStarts[0] ?? memory.meta.created;
88
129
  return heldAt(start, memory.meta.validTo, at);
89
130
  }
90
- function buildGraph(memories) {
131
+ /**
132
+ * Entities and edges from memories. With `described` (the entity registry,
133
+ * by entity key), names the registry lists as aliases fold into the entity
134
+ * they are aliases of, and the registry's display name, kind and note ride
135
+ * along. The memories themselves are never rewritten for this.
136
+ */
137
+ function buildGraph(memories, described = new Map()) {
91
138
  const entities = new Map();
92
139
  const spellings = new Map();
93
140
  const edges = [];
141
+ const aliases = aliasMap(described);
94
142
  const touch = (name, memoryId) => {
95
- const key = entityKey(name);
143
+ const key = keyOf({ aliases }, name);
96
144
  let entity = entities.get(key);
97
145
  if (!entity) {
98
146
  entity = { key, name: name.trim(), memoryIds: new Set(), mentionIds: new Set(), edges: [] };
@@ -114,22 +162,29 @@ function buildGraph(memories) {
114
162
  touch(relation.s, memory.id).edges.push(edge);
115
163
  // The object is an entity only if something else names it as one;
116
164
  // "async standups" as the object of prefers stays a literal.
117
- const objectKey = entityKey(relation.o);
165
+ const objectKey = keyOf({ aliases }, relation.o);
118
166
  if (entities.has(objectKey) || isEntityLike(relation.o)) {
119
167
  touch(relation.o, memory.id).edges.push(edge);
120
168
  }
121
169
  }
122
170
  }
123
171
  for (const [key, counts] of spellings) {
124
- const [best] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
125
- if (best)
172
+ // The registry's spelling first; then the canonical name's own most
173
+ // common spelling, so "Dana" as an alias never renames Dana Whitfield;
174
+ // then whatever is most common.
175
+ const given = described.get(key)?.name?.trim();
176
+ const own = [...counts.entries()].filter(([n]) => entityKey(n) === key);
177
+ const [best] = (own.length > 0 ? own : [...counts.entries()]).sort((a, b) => b[1] - a[1]);
178
+ if (given)
179
+ entities.get(key).name = given;
180
+ else if (best)
126
181
  entities.get(key).name = best[0];
127
182
  }
128
183
  // A memory that names a known entity in its words belongs to it too,
129
184
  // without anyone writing the link: "Deploy Atlas on Fridays" is about
130
185
  // Atlas once any memory names Atlas as an entity. Computed, never
131
186
  // written back, so it costs nothing and a rename is never stale.
132
- const graph = { entities, edges };
187
+ const graph = { entities, edges, aliases, described };
133
188
  for (const memory of all) {
134
189
  for (const entity of entitiesMentioned(graph, `${memory.label} ${memory.text}`)) {
135
190
  if (entity.memoryIds.has(memory.id))
@@ -155,17 +210,25 @@ function entitiesMentioned(graph, text) {
155
210
  .replace(/[^a-z0-9.\s]/g, ' ')
156
211
  .replace(/\.(?![a-z0-9])|(?<![a-z0-9])\./g, ' ')
157
212
  .replace(/\s+/g, ' ')} `;
158
- const found = [];
159
- const byLength = [...graph.entities.values()].sort((a, b) => b.key.length - a.key.length);
213
+ const found = new Set();
214
+ // Every name an entity goes by: its key, and the aliases the registry
215
+ // gives it.
216
+ const needles = [...graph.entities.values()].map((e) => [e.key, e]);
217
+ for (const [alias, target] of graph.aliases ?? []) {
218
+ const entity = graph.entities.get(target);
219
+ if (entity)
220
+ needles.push([alias, entity]);
221
+ }
222
+ needles.sort((a, b) => b[0].length - a[0].length);
160
223
  let remaining = haystack;
161
- for (const entity of byLength) {
162
- const needle = ` ${entity.key} `;
163
- if (entity.key.length < 2 || !remaining.includes(needle))
224
+ for (const [key, entity] of needles) {
225
+ const needle = ` ${key} `;
226
+ if (key.length < 2 || !remaining.includes(needle))
164
227
  continue;
165
- found.push(entity);
228
+ found.add(entity);
166
229
  remaining = remaining.split(needle).join(' ');
167
230
  }
168
- return found;
231
+ return [...found];
169
232
  }
170
233
  /** Entities one relation away from any of `from`. */
171
234
  function neighbours(graph, from) {
@@ -174,7 +237,7 @@ function neighbours(graph, from) {
174
237
  for (const entity of from) {
175
238
  for (const edge of entity.edges) {
176
239
  for (const end of [edge.s, edge.o]) {
177
- const key = entityKey(end);
240
+ const key = keyOf(graph, end);
178
241
  const other = graph.entities.get(key);
179
242
  if (other && !seen.has(key)) {
180
243
  seen.add(key);
@@ -222,8 +285,8 @@ function rankMemories(graph, query, scored, opts) {
222
285
  */
223
286
  function supersededBy(graph, relation, at) {
224
287
  const p = normalizePredicate(relation.p);
225
- const s = entityKey(relation.s);
226
- const o = entityKey(relation.o);
288
+ const s = keyOf(graph, relation.s);
289
+ const o = keyOf(graph, relation.o);
227
290
  const bySubject = exports.ONE_OBJECT_PER_SUBJECT.has(p);
228
291
  const byObject = exports.ONE_SUBJECT_PER_OBJECT.has(p);
229
292
  if (!bySubject && !byObject)
@@ -231,8 +294,8 @@ function supersededBy(graph, relation, at) {
231
294
  return graph.edges.filter((edge) => {
232
295
  if (edge.p !== p || !heldAt(edge.from, edge.to, at))
233
296
  return false;
234
- const es = entityKey(edge.s);
235
- const eo = entityKey(edge.o);
297
+ const es = keyOf(graph, edge.s);
298
+ const eo = keyOf(graph, edge.o);
236
299
  if (es === s && eo === o)
237
300
  return false;
238
301
  return bySubject ? es === s : eo === o;
@@ -240,8 +303,8 @@ function supersededBy(graph, relation, at) {
240
303
  }
241
304
  /**
242
305
  * Housekeeping findings for a tidy pass: memories linked to nothing, names
243
- * that look like one entity spelled two ways, and single-valued predicates
244
- * holding two values at once.
306
+ * that look like one entity spelled two ways, single-valued predicates
307
+ * holding two values at once, and names with no kind.
245
308
  */
246
309
  function graphHealth(graph, memories, at = Date.now()) {
247
310
  // Linked by nothing, and naming nothing the graph knows: a memory that
@@ -272,10 +335,14 @@ function graphHealth(graph, memories, at = Date.now()) {
272
335
  continue;
273
336
  const bySubject = exports.ONE_OBJECT_PER_SUBJECT.has(edge.p);
274
337
  const anchor = bySubject ? edge.s : edge.o;
275
- const key = `${entityKey(anchor)}|${edge.p}`;
338
+ const key = `${keyOf(graph, anchor)}|${edge.p}`;
276
339
  if (!current.has(key))
277
- current.set(key, { anchor, predicate: edge.p, values: new Set() });
278
- current.get(key).values.add(bySubject ? edge.o : edge.s);
340
+ current.set(key, { anchor, predicate: edge.p, values: new Map() });
341
+ const value = bySubject ? edge.o : edge.s;
342
+ // Keyed through aliases, so "Acme" and its alias "Acme Inc" are one value.
343
+ const values = current.get(key).values;
344
+ if (!values.has(keyOf(graph, value)))
345
+ values.set(keyOf(graph, value), value);
279
346
  }
280
347
  for (const [key, { anchor, predicate, values }] of current) {
281
348
  if (values.size < 2)
@@ -283,10 +350,12 @@ function graphHealth(graph, memories, at = Date.now()) {
283
350
  contradictions.push({
284
351
  subject: graph.entities.get(key.split('|')[0])?.name ?? anchor,
285
352
  predicate,
286
- objects: [...values],
353
+ objects: [...values.values()],
287
354
  });
288
355
  }
289
- return { unlinked, possibleDuplicates, contradictions };
356
+ // Names nothing types yet, for the untyped review.
357
+ const untyped = untypedEntities(graph).map((e) => e.name);
358
+ return { unlinked, possibleDuplicates, contradictions, untyped };
290
359
  }
291
360
  /**
292
361
  * A memory's links with one name swapped for another: how "Dana" becomes
@@ -337,14 +406,70 @@ function uniqueNames(names) {
337
406
  byKey.set(entityKey(n), n);
338
407
  return [...byKey.values()];
339
408
  }
409
+ /**
410
+ * What an entity is. `organization` was spelled `organisation` before the
411
+ * registry existed; readers accept both (see normalizeKind) and writers use
412
+ * this spelling. `other` is the fallback when nothing says.
413
+ */
414
+ exports.ENTITY_KINDS = [
415
+ 'person',
416
+ 'organization',
417
+ 'project',
418
+ 'tool',
419
+ 'place',
420
+ 'event',
421
+ 'topic',
422
+ 'product',
423
+ 'media',
424
+ 'pet',
425
+ 'group',
426
+ 'goal',
427
+ 'other',
428
+ ];
429
+ const KIND_SET = new Set(exports.ENTITY_KINDS);
430
+ /** Other words a person or a model uses for a kind. */
431
+ const KIND_SYNONYMS = {
432
+ organisation: 'organization',
433
+ org: 'organization',
434
+ company: 'organization',
435
+ people: 'person',
436
+ human: 'person',
437
+ location: 'place',
438
+ habit: 'goal',
439
+ book: 'media',
440
+ document: 'media',
441
+ documents: 'media',
442
+ brand: 'product',
443
+ animal: 'pet',
444
+ software: 'tool',
445
+ };
446
+ /** A kind as stored or typed, as one of ENTITY_KINDS; undefined when it is
447
+ * not one. Case and a plural "s" do not matter; `organisation` reads as
448
+ * `organization`. */
449
+ function normalizeKind(value) {
450
+ if (typeof value !== 'string')
451
+ return undefined;
452
+ const v = value.trim().toLowerCase();
453
+ if (KIND_SET.has(v))
454
+ return v;
455
+ if (KIND_SYNONYMS[v])
456
+ return KIND_SYNONYMS[v];
457
+ const singular = v.endsWith('s') ? v.slice(0, -1) : v;
458
+ if (KIND_SET.has(singular))
459
+ return singular;
460
+ return KIND_SYNONYMS[singular];
461
+ }
340
462
  /**
341
463
  * What each end of a predicate usually is. "Dana works_at Acme" says Dana
342
- * is a person and Acme an organisation; "Atlas uses Postgres" says Atlas is
464
+ * is a person and Acme an organization; "Atlas uses Postgres" says Atlas is
343
465
  * a project and Postgres a tool. Unknown predicates say nothing.
344
466
  */
345
- const ROLES = {
346
- works_at: ['person', 'organisation'],
347
- employed_by: ['person', 'organisation'],
467
+ exports.ROLES = {
468
+ works_at: ['person', 'organization'],
469
+ works_for: ['person', 'organization'],
470
+ employed_by: ['person', 'organization'],
471
+ founded: ['person', 'organization'],
472
+ ceo_of: ['person', 'organization'],
348
473
  works_on: ['person', 'project'],
349
474
  contributes_to: ['person', 'project'],
350
475
  leads: ['person', 'project'],
@@ -352,9 +477,18 @@ const ROLES = {
352
477
  owns: ['person', null],
353
478
  reports_to: ['person', 'person'],
354
479
  knows: ['person', 'person'],
480
+ friend_of: ['person', 'person'],
481
+ married_to: ['person', 'person'],
482
+ partner_of: ['person', 'person'],
483
+ sibling_of: ['person', 'person'],
484
+ parent_of: ['person', 'person'],
485
+ child_of: ['person', 'person'],
355
486
  lives_in: ['person', 'place'],
487
+ born_in: ['person', 'place'],
488
+ visited: ['person', 'place'],
356
489
  based_in: [null, 'place'],
357
490
  located_in: [null, 'place'],
491
+ headquartered_in: ['organization', 'place'],
358
492
  prefers: ['person', null],
359
493
  role: ['person', null],
360
494
  title: ['person', null],
@@ -367,20 +501,70 @@ const ROLES = {
367
501
  deployed_on: ['project', 'tool'],
368
502
  depends_on: ['project', 'tool'],
369
503
  part_of: [null, 'project'],
504
+ // Events
505
+ attends: ['person', 'event'],
506
+ attended: ['person', 'event'],
507
+ attending: ['person', 'event'],
508
+ spoke_at: ['person', 'event'],
509
+ organizes: ['person', 'event'],
510
+ organized: ['person', 'event'],
511
+ held_in: ['event', 'place'],
512
+ held_at: ['event', 'place'],
513
+ // Topics
514
+ about: [null, 'topic'],
515
+ interested_in: ['person', 'topic'],
516
+ studies: ['person', 'topic'],
517
+ studied: ['person', 'topic'],
518
+ learning: ['person', 'topic'],
519
+ expert_in: ['person', 'topic'],
520
+ // Products
521
+ bought: ['person', 'product'],
522
+ uses_product: ['person', 'product'],
523
+ owns_product: ['person', 'product'],
524
+ // Documents and media
525
+ reading: ['person', 'media'],
526
+ read: ['person', 'media'],
527
+ reads: ['person', 'media'],
528
+ wrote: ['person', 'media'],
529
+ authored: ['person', 'media'],
530
+ written_by: ['media', 'person'],
531
+ watched: ['person', 'media'],
532
+ listens_to: ['person', 'media'],
533
+ // Pets
534
+ owns_pet: ['person', 'pet'],
535
+ has_pet: ['person', 'pet'],
536
+ // Groups
537
+ member_of: ['person', 'group'],
538
+ belongs_to: ['person', 'group'],
539
+ // Goals and habits
540
+ goal: ['person', 'goal'],
541
+ has_goal: ['person', 'goal'],
542
+ working_toward: ['person', 'goal'],
543
+ working_towards: ['person', 'goal'],
544
+ practices: ['person', 'goal'],
370
545
  };
371
546
  /** Ties go to the kind a person most likely means. */
372
- const KIND_ORDER = ['person', 'project', 'organisation', 'place', 'tool'];
373
- /**
374
- * A best guess at what each entity is, from the roles it plays in its
375
- * relations; "other" when nothing says. A guess for filtering and colour,
376
- * never written back.
377
- */
378
- function entityKinds(graph) {
547
+ const KIND_ORDER = [
548
+ 'person',
549
+ 'project',
550
+ 'organization',
551
+ 'place',
552
+ 'tool',
553
+ 'event',
554
+ 'topic',
555
+ 'group',
556
+ 'product',
557
+ 'media',
558
+ 'pet',
559
+ 'goal',
560
+ ];
561
+ /** Kinds voted for each entity by the roles it plays in its relations. */
562
+ function roleVotes(graph) {
379
563
  const votes = new Map();
380
564
  const vote = (name, kind) => {
381
565
  if (!kind)
382
566
  return;
383
- const key = entityKey(name);
567
+ const key = keyOf(graph, name);
384
568
  if (!graph.entities.has(key))
385
569
  return;
386
570
  const tally = votes.get(key) ?? new Map();
@@ -388,19 +572,142 @@ function entityKinds(graph) {
388
572
  votes.set(key, tally);
389
573
  };
390
574
  for (const edge of graph.edges) {
391
- const [s, o] = ROLES[edge.p] ?? [null, null];
575
+ const [s, o] = exports.ROLES[edge.p] ?? [null, null];
392
576
  vote(edge.s, s);
393
577
  vote(edge.o, o);
394
578
  }
579
+ return votes;
580
+ }
581
+ function bestVote(tally) {
582
+ return [...tally.entries()].sort((a, b) => b[1] - a[1] || KIND_ORDER.indexOf(a[0]) - KIND_ORDER.indexOf(b[0]))[0][0];
583
+ }
584
+ /**
585
+ * What each entity is. A kind stored in the entity registry (set by the
586
+ * person or by an AI with describe_entity or add_memory) wins; otherwise a
587
+ * best guess from the roles the entity plays in its relations; "other" when
588
+ * nothing says. The guess is never written back.
589
+ */
590
+ function entityKinds(graph) {
591
+ const votes = roleVotes(graph);
395
592
  const kinds = new Map();
396
593
  for (const key of graph.entities.keys()) {
397
- const tally = votes.get(key);
398
- if (!tally) {
399
- kinds.set(key, 'other');
594
+ const stored = graph.described?.get(key)?.kind;
595
+ if (stored) {
596
+ kinds.set(key, stored);
400
597
  continue;
401
598
  }
402
- const best = [...tally.entries()].sort((a, b) => b[1] - a[1] || KIND_ORDER.indexOf(a[0]) - KIND_ORDER.indexOf(b[0]))[0];
403
- kinds.set(key, best[0]);
599
+ const tally = votes.get(key);
600
+ kinds.set(key, tally ? bestVote(tally) : 'other');
404
601
  }
405
602
  return kinds;
406
603
  }
604
+ /** The kind the registry stores for an entity, if any. */
605
+ function storedKind(graph, name) {
606
+ return graph.described?.get(keyOf(graph, name))?.kind;
607
+ }
608
+ /**
609
+ * Entities nothing types: no kind in the registry and no relation role to
610
+ * guess one from. These are what a review asks the person (or an AI) to
611
+ * sort out. Most known first.
612
+ */
613
+ function untypedEntities(graph) {
614
+ const kinds = entityKinds(graph);
615
+ return [...graph.entities.values()]
616
+ .filter((e) => kinds.get(e.key) === 'other' && !graph.described?.get(e.key)?.kind)
617
+ .sort((a, b) => b.memoryIds.size - a.memoryIds.size || a.name.localeCompare(b.name));
618
+ }
619
+ /** Company suffixes that make a name an organization and nothing else. */
620
+ const ORG_SUFFIX = /(?:^|[\s,])(inc\.?|incorporated|llc|l\.l\.c\.|ltd\.?|limited|gmbh|corp\.?|corporation|plc|llp|s\.a\.|ag|& co\.?)$/i;
621
+ /**
622
+ * A suggested kind for an untyped entity, from signals on this device only
623
+ * (memory content is end-to-end encrypted, so no server can suggest
624
+ * anything). Only signals that point one way:
625
+ * - relation roles (ROLES), when the entity plays one;
626
+ * - how typed entities are used: if every typed subject (or object) of a
627
+ * predicate is one kind, an untyped one in the same place is that kind
628
+ * too ("Dana mentors Sam", with Dana a person, makes Lee in "Lee mentors
629
+ * Kim" a person), with at least two typed examples;
630
+ * - the name: a company suffix (Inc, LLC, Ltd, GmbH...) is an organization,
631
+ * and a single name that is the first name of exactly one typed person
632
+ * ("Dana" beside the person "Dana Whitfield") is a person.
633
+ * Null when nothing points one way; a wrong guess costs more than none.
634
+ */
635
+ function suggestKind(graph, name) {
636
+ const key = keyOf(graph, name);
637
+ const entity = graph.entities.get(key);
638
+ if (!entity)
639
+ return null;
640
+ const kinds = entityKinds(graph);
641
+ const tally = roleVotes(graph).get(key);
642
+ if (tally) {
643
+ const kind = bestVote(tally);
644
+ const edge = entity.edges.find((e) => {
645
+ const [s, o] = exports.ROLES[e.p] ?? [null, null];
646
+ return (keyOf(graph, e.s) === key && s === kind) || (keyOf(graph, e.o) === key && o === kind);
647
+ });
648
+ const n = tally.get(kind);
649
+ return {
650
+ kind,
651
+ reason: `${edge ? edge.p.replace(/_/g, ' ') : 'its relations'}${n > 1 ? ` in ${n} relations` : ''}`,
652
+ };
653
+ }
654
+ if (ORG_SUFFIX.test(entity.name.trim())) {
655
+ return { kind: 'organization', reason: 'company name' };
656
+ }
657
+ // Learned roles: how typed entities are used with the same predicates.
658
+ const typed = (n) => {
659
+ const k = keyOf(graph, n);
660
+ return graph.described?.get(k)?.kind;
661
+ };
662
+ const learned = new Map();
663
+ for (const edge of graph.edges) {
664
+ for (const [side, end] of [
665
+ ['s', edge.s],
666
+ ['o', edge.o],
667
+ ]) {
668
+ const kind = typed(end);
669
+ if (!kind || kind === 'other')
670
+ continue;
671
+ const slot = `${edge.p}|${side}`;
672
+ const m = learned.get(slot) ?? new Map();
673
+ m.set(kind, (m.get(kind) ?? 0) + 1);
674
+ learned.set(slot, m);
675
+ }
676
+ }
677
+ const agree = new Map();
678
+ let conflicted = false;
679
+ for (const edge of entity.edges) {
680
+ for (const [side, end] of [
681
+ ['s', edge.s],
682
+ ['o', edge.o],
683
+ ]) {
684
+ if (keyOf(graph, end) !== key)
685
+ continue;
686
+ const m = learned.get(`${edge.p}|${side}`);
687
+ if (!m)
688
+ continue;
689
+ if (m.size !== 1) {
690
+ conflicted = true;
691
+ continue;
692
+ }
693
+ const [[kind, count]] = [...m.entries()];
694
+ if (count < 2)
695
+ continue;
696
+ if (!agree.has(kind))
697
+ agree.set(kind, edge.p.replace(/_/g, ' '));
698
+ }
699
+ }
700
+ if (agree.size === 1 && !conflicted) {
701
+ const [[kind, predicate]] = [...agree.entries()];
702
+ return { kind, reason: `used like other ${kind === 'person' ? 'people' : `${kind}s`} with "${predicate}"` };
703
+ }
704
+ // A first name beside exactly one typed person who has it.
705
+ if (!entity.key.includes(' ')) {
706
+ const people = [...graph.entities.values()].filter((e) => e.key !== key && e.key.startsWith(`${entity.key} `) && kinds.get(e.key) === 'person');
707
+ const others = [...graph.entities.values()].filter((e) => e.key !== key && e.key.startsWith(`${entity.key} `) && kinds.get(e.key) !== 'person');
708
+ if (people.length === 1 && others.length === 0) {
709
+ return { kind: 'person', reason: `first name of ${people[0].name}` };
710
+ }
711
+ }
712
+ return null;
713
+ }
package/dist/cjs/notes.js CHANGED
@@ -5,6 +5,7 @@ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
5
5
  const exposure_js_1 = require("./exposure.js");
6
6
  const attrs_js_1 = require("./attrs.js");
7
7
  const attrs_extract_js_1 = require("./attrs-extract.js");
8
+ const entity_registry_js_1 = require("./entity-registry.js");
8
9
  const vault_js_1 = require("./vault.js");
9
10
  /** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
10
11
  * single call. Keeps a single search bounded even for very large accounts;
@@ -248,7 +249,7 @@ class NotesService {
248
249
  // surface in list/search results. readNote() still allows fetching one
249
250
  // by exact id — that's a deliberate, explicit request.
250
251
  const exposure = await this.exposure();
251
- const notHidden = notes.filter((row) => row.hidden !== true);
252
+ const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
252
253
  // Folders the user has not opened to AI tools are filtered here rather
253
254
  // than at the API, so the count of what was withheld can be reported.
254
255
  const visible = notHidden.filter((row) => exposure.allows(row.folderId));
@@ -298,7 +299,7 @@ class NotesService {
298
299
  deleted: false,
299
300
  });
300
301
  const exposure = await this.exposure();
301
- const notHidden = notes.filter((row) => row.hidden !== true);
302
+ const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
302
303
  const visible = notHidden.filter((row) => exposure.allows(row.folderId));
303
304
  const withheld = {
304
305
  inClosedFolders: notHidden.length - visible.length,
@@ -383,6 +384,9 @@ class NotesService {
383
384
  // sent encrypted under a key the vault has since revoked.
384
385
  const startEpoch = this.epoch;
385
386
  const existingRow = await this.api.getNote(id);
387
+ if (isSystemNote(existingRow)) {
388
+ throw new Error('That note is the entity registry. Change entities with describe_entity.');
389
+ }
386
390
  const exposure = await this.exposure();
387
391
  this.requireExposed(exposure, existingRow.folderId, id);
388
392
  const current = await this.decryptRow(key, existingRow);
@@ -661,7 +665,7 @@ class NotesService {
661
665
  const { notes, pagination } = await this.api.listNotes({ page, limit: SEARCH_PAGE_LIMIT, deleted: false });
662
666
  total = pagination.total;
663
667
  for (const row of notes) {
664
- if (row.hidden === true)
668
+ if (row.hidden === true || isSystemNote(row))
665
669
  continue;
666
670
  if (!exposure.allows(row.folderId))
667
671
  continue;
@@ -849,7 +853,7 @@ class NotesService {
849
853
  for (;;) {
850
854
  const { notes, pagination } = await this.api.listNotes({ page, limit: SEARCH_PAGE_LIMIT, deleted: false });
851
855
  for (const row of notes) {
852
- if (row.hidden === true)
856
+ if (row.hidden === true || isSystemNote(row))
853
857
  continue;
854
858
  // Archived notes are out of the way by the user's own choice, so an
855
859
  // archived note is not a backlink source and not a mention target.
@@ -982,3 +986,8 @@ class NotesService {
982
986
  }
983
987
  }
984
988
  exports.NotesService = NotesService;
989
+ /** A note the app keeps for itself, never listed or searched as a note:
990
+ * the entity registry (see entity-registry.ts). */
991
+ function isSystemNote(row) {
992
+ return row.type === entity_registry_js_1.ENTITY_REGISTRY_NOTE_TYPE;
993
+ }