@batalabs/virlow-mcp-core 3.82.1 → 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.
- package/dist/api.d.ts +6 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2 -0
- package/dist/api.js.map +1 -1
- package/dist/cjs/api.d.ts +6 -0
- package/dist/cjs/api.js +2 -0
- package/dist/cjs/entity-registry.d.ts +101 -0
- package/dist/cjs/entity-registry.js +270 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +14 -1
- package/dist/cjs/memories.d.ts +61 -0
- package/dist/cjs/memories.js +191 -3
- package/dist/cjs/memory-graph.d.ts +89 -7
- package/dist/cjs/memory-graph.js +351 -44
- package/dist/cjs/notes.js +13 -4
- package/dist/entity-registry.d.ts +102 -0
- package/dist/entity-registry.d.ts.map +1 -0
- package/dist/entity-registry.js +260 -0
- package/dist/entity-registry.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/memories.d.ts +61 -0
- package/dist/memories.d.ts.map +1 -1
- package/dist/memories.js +192 -4
- package/dist/memories.js.map +1 -1
- package/dist/memory-graph.d.ts +89 -7
- package/dist/memory-graph.d.ts.map +1 -1
- package/dist/memory-graph.js +342 -42
- package/dist/memory-graph.js.map +1 -1
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +13 -4
- package/dist/notes.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +150 -15
- package/dist/tools.js.map +1 -1
- package/package.json +9 -3
package/dist/cjs/memory-graph.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
162
|
-
const needle = ` ${
|
|
163
|
-
if (
|
|
224
|
+
for (const [key, entity] of needles) {
|
|
225
|
+
const needle = ` ${key} `;
|
|
226
|
+
if (key.length < 2 || !remaining.includes(needle))
|
|
164
227
|
continue;
|
|
165
|
-
found.
|
|
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 =
|
|
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 =
|
|
226
|
-
const 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 =
|
|
235
|
-
const eo =
|
|
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,
|
|
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 = `${
|
|
338
|
+
const key = `${keyOf(graph, anchor)}|${edge.p}`;
|
|
276
339
|
if (!current.has(key))
|
|
277
|
-
current.set(key, { anchor, predicate: edge.p, values: new
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
346
|
-
works_at: ['person', '
|
|
347
|
-
|
|
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 = [
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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 =
|
|
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
|
|
398
|
-
if (
|
|
399
|
-
kinds.set(key,
|
|
594
|
+
const stored = graph.described?.get(key)?.kind;
|
|
595
|
+
if (stored) {
|
|
596
|
+
kinds.set(key, stored);
|
|
400
597
|
continue;
|
|
401
598
|
}
|
|
402
|
-
const
|
|
403
|
-
kinds.set(key,
|
|
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
|
+
}
|