Repository navigation
Expand file tree
/
Copy pathserver.ts
More file actions
2202 lines (2047 loc) · 94.8 KB
/
Copy pathserver.ts
File metadata and controls
2202 lines (2047 loc) · 94.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { context, propagation, SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { randomBytes } from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { Store, DIR_FORWARD, DIR_BACKWARD, type NativeEntity } from './src/store.js';
import { ensureV3 } from './src/migrate.js';
import { validateExtension, loadDocument, type KbLoadResult } from './src/kb_load.js';
import { toolDurationHistogram, traced, tracer } from './src/tracing.js';
/**
* Result envelope for a single tool dispatch. Mirrors the MCP CallToolResult
* shape we return from every case; `isError: true` signals a tool-level error
* that the LLM should see. Caller-actionable failures are thrown as
* {@link ToolError} and converted into that visible shape by the request
* handler; any other exception propagates as a protocol error and is reserved
* for genuine server faults.
*/
type ToolDispatchResult = {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
};
/**
* Stable, machine-readable error codes carried in the tool-error envelope.
* This list is part of the public contract (docs/api-error-policy.md).
*/
export type ToolErrorCode =
| 'ENTITY_NOT_FOUND'
| 'TYPE_NOT_FOUND'
| 'ENDPOINT_MISSING'
| 'COLLISION'
| 'LIMIT_EXCEEDED'
| 'INVALID_REGEX'
| 'INVALID_FILE'
| 'NO_MATCHES'
| 'CURSOR_STALE'
| 'VALIDATION_FAILED';
/**
* A failure the CALLER can act on. The request handler converts these into a
* VISIBLE, structured tool error (`isError: true` with a JSON envelope) —
* never a protocol error. Protocol errors stay reserved for genuine server
* faults. See docs/api-error-policy.md (R1).
*/
export class ToolError extends Error {
constructor(
public code: ToolErrorCode,
message: string,
public details?: unknown,
) {
super(message);
this.name = 'ToolError';
}
}
/**
* True if the query contains any regex metacharacter. Used to gate the
* `search_nodes` natural-language warning: a pure literal that returns no
* matches is the signal we want to flag, since regex queries (anchors,
* quantifiers, alternation, classes, etc.) are deliberate and may legitimately
* match nothing.
*/
const HAS_REGEX_META = /[\\^$.*+?()[\]{}|]/;
// =============================================================================
// find_path memory budget
//
// `findPath` runs a BFS whose state size scales with reachable-within-maxDepth.
// To keep it bounded as the KB grows (or as adversarial maxDepth values come
// in), we cap the BFS at a real byte budget derived from the host's available
// RAM at call time. Hard upper bound by request: never claim more than 80%
// of available memory.
//
// The budget VALUE is `0.80 * MemAvailable` (or the `KB_FIND_PATH_BUDGET_BYTES`
// env override, used by tests to force exhaustion). It is passed to the C BFS,
// which enforces it against its own per-node footprint (graph_find_path_ex).
// =============================================================================
const FIND_PATH_MEMORY_FRACTION = 0.80;
/**
* Bytes of RAM the host considers "available" — i.e., free + reclaimable
* cache, the right number for "how much can I grow into without forcing
* swap." On Linux this is `MemAvailable` from /proc/meminfo, which is the
* kernel's own answer. On other platforms we fall back to `os.freemem()`,
* which under-counts (it's `MemFree`, no reclaimable accounting) but is
* portable. Under-counting is safe — we'll just budget less than we could.
*/
function availableMemoryBytes(): number {
try {
const text = fs.readFileSync('/proc/meminfo', 'utf-8');
const m = /^MemAvailable:\s+(\d+)\s+kB/m.exec(text);
if (m) return Number(m[1]) * 1024;
} catch {
// /proc/meminfo unavailable (macOS, Windows, weird container) — fall through.
}
return os.freemem();
}
/**
* Byte budget for a single `findPath` BFS call. Either:
* - `KB_FIND_PATH_BUDGET_BYTES` env override (used by tests + ops), or
* - `0.80 * available_memory` from {@link availableMemoryBytes}.
*
* Floor of 1 byte to keep arithmetic well-defined; in practice
* `availableMemoryBytes` is at least tens of MB on any host that can
* actually run V8.
*/
function findPathBudgetBytes(): number {
const override = Number(process.env.KB_FIND_PATH_BUDGET_BYTES);
if (Number.isFinite(override) && override >= 0) {
return Math.floor(override);
}
const bytes = availableMemoryBytes();
if (!Number.isFinite(bytes) || bytes <= 0) return 1;
return Math.max(1, Math.floor(bytes * FIND_PATH_MEMORY_FRACTION));
}
// Define memory file path using environment variable with fallback
const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
// If MEMORY_FILE_PATH is just a filename, put it in the same directory as the script
const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
? path.isAbsolute(process.env.MEMORY_FILE_PATH)
? process.env.MEMORY_FILE_PATH
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
: defaultMemoryPath;
// We are storing our memory using entities, relations, and observations in a graph structure
export interface Entity {
name: string;
entityType: string;
observations: string[];
mtime?: number; // General modification time (any change)
obsMtime?: number; // Observation-specific modification time
}
export interface Relation {
from: string;
to: string;
relationType: string;
mtime?: number;
}
export interface KnowledgeGraph {
entities: Entity[];
relations: Relation[];
}
export interface PaginatedResult<T> {
items: T[];
/** Opaque "<index>:<fingerprint>" token, or null when the drain is complete. */
nextCursor: string | null;
totalCount: number;
}
export type EntitySortField = "mtime" | "obsMtime" | "name" | "pagerank" | "llmrank";
export type SortDirection = "asc" | "desc";
/**
* Random-walk transition policy.
*
* 'merw' — Maximum-Entropy Random Walk: probability of stepping to
* neighbor j is proportional to ψ_j (the cached MERW
* eigenvector entry), which biases the walk toward
* structurally important nodes. This is the default and
* matches the historical behavior. Falls back to uniform
* if ψ has not been computed yet (all zeros).
* 'uniform' — Plain uniform random walk: every eligible neighbor is
* equally likely. Useful for unbiased structural sampling
* or for comparing against MERW.
*/
export type RandomWalkMode = "merw" | "uniform";
export interface Neighbor {
name: string;
mtime?: number;
obsMtime?: number;
}
/**
* Sort entities by the specified field and direction.
* Returns a new array (does not mutate input).
* Defaults to 'llmrank' when sortBy is undefined.
*
* For 'pagerank' sort: uses structural rank (desc by default).
* For 'llmrank' sort: uses walker rank, falls back to structural rank on tie, then random.
* Both rank sorts require rankMaps parameter.
*/
function sortEntities(
entities: Entity[],
sortBy: EntitySortField = "llmrank",
sortDir?: SortDirection,
rankMaps?: { structural: Map<string, number>; walker: Map<string, number> }
): Entity[] {
const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
const mult = dir === "asc" ? 1 : -1;
return [...entities].sort((a, b) => {
if (sortBy === "name") {
return mult * a.name.localeCompare(b.name);
}
if (sortBy === "pagerank") {
const aRank = rankMaps?.structural.get(a.name) ?? 0;
const bRank = rankMaps?.structural.get(b.name) ?? 0;
const diff = aRank - bRank;
if (diff !== 0) return mult * diff;
return a.name.localeCompare(b.name); // deterministic tiebreak (R5: total order keeps pagination stable)
}
if (sortBy === "llmrank") {
// Primary: walker rank
const aWalker = rankMaps?.walker.get(a.name) ?? 0;
const bWalker = rankMaps?.walker.get(b.name) ?? 0;
const walkerDiff = aWalker - bWalker;
if (walkerDiff !== 0) return mult * walkerDiff;
// Fallback: structural rank
const aStruct = rankMaps?.structural.get(a.name) ?? 0;
const bStruct = rankMaps?.structural.get(b.name) ?? 0;
const structDiff = aStruct - bStruct;
if (structDiff !== 0) return mult * structDiff;
// Final: deterministic tiebreak — a total order keeps pagination stable (R5)
return a.name.localeCompare(b.name);
}
// For timestamps, treat undefined as 0 (oldest)
const aVal = a[sortBy] ?? 0;
const bVal = b[sortBy] ?? 0;
return mult * (aVal - bVal);
});
}
/**
* Sort neighbors by the specified field and direction.
* Defaults to 'llmrank' when sortBy is undefined.
*/
function sortNeighbors(
neighbors: Neighbor[],
sortBy: EntitySortField = "llmrank",
sortDir?: SortDirection,
rankMaps?: { structural: Map<string, number>; walker: Map<string, number> }
): Neighbor[] {
const dir = sortDir ?? (sortBy === "name" ? "asc" : "desc");
const mult = dir === "asc" ? 1 : -1;
return [...neighbors].sort((a, b) => {
if (sortBy === "name") {
return mult * a.name.localeCompare(b.name);
}
if (sortBy === "pagerank") {
const aRank = rankMaps?.structural.get(a.name) ?? 0;
const bRank = rankMaps?.structural.get(b.name) ?? 0;
const diff = aRank - bRank;
if (diff !== 0) return mult * diff;
return a.name.localeCompare(b.name);
}
if (sortBy === "llmrank") {
const aWalker = rankMaps?.walker.get(a.name) ?? 0;
const bWalker = rankMaps?.walker.get(b.name) ?? 0;
const walkerDiff = aWalker - bWalker;
if (walkerDiff !== 0) return mult * walkerDiff;
const aStruct = rankMaps?.structural.get(a.name) ?? 0;
const bStruct = rankMaps?.structural.get(b.name) ?? 0;
const structDiff = aStruct - bStruct;
if (structDiff !== 0) return mult * structDiff;
return a.name.localeCompare(b.name);
}
const aVal = a[sortBy] ?? 0;
const bVal = b[sortBy] ?? 0;
return mult * (aVal - bVal);
});
}
export const MAX_CHARS = 4096;
/**
* FNV-1a (32-bit) over a string; `seed` lets callers stream multiple strings
* into one hash. Used for result-set fingerprints.
*/
function fnv1a32(s: string, seed = 0x811c9dc5): number {
let h = seed;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/**
* Fingerprint of a result set: FNV-1a over every item's serialized form — the
* exact bytes pagination boundaries are computed over — so any change to the
* set's content or order changes the fingerprint.
*/
function resultSetFingerprint<T>(items: T[]): string {
let h = 0x811c9dc5;
for (const item of items) {
h = fnv1a32(JSON.stringify(item), h);
h = fnv1a32('\u001f', h);
}
return h.toString(16).padStart(8, '0');
}
/**
* Paginate `items` starting at `cursor`, producing a page no larger than
* `maxChars` (best-effort).
*
* Policy R5 (docs/api-error-policy.md): a cursor is `"<index>:<fingerprint>"`
* for the result set it came from. If the set has changed since the cursor was
* issued (content or order), the page boundaries have silently shifted, so the
* call errors with CURSOR_STALE rather than serving a wrong page. Bare numbers
* are accepted as legacy cursors and are NOT staleness-checked.
*
* Forward-progress invariant: every call advances the cursor by at least one
* item, even when that item's JSON exceeds `maxChars`. Without this guarantee,
* an oversized item at position `cursor` would cause `nextCursor === cursor`,
* which the model follows back into an identical call → infinite cycle. (See
* the obsMtime-desc / long-name regression we hit in production.)
*
* The byte budget is a soft cap, not a hard one: an oversized lead item is
* emitted alone, blowing past `maxChars` for that single page only. The
* alternative (skipping the item, or returning an error) hides data from the
* caller and makes downstream pagination inconsistent.
*/
function paginateItems<T>(items: T[], cursor: number | string = 0, maxChars: number = MAX_CHARS): PaginatedResult<T> {
// Parse the cursor first; a malformed one is a stale/invalid cursor.
let i = 0;
let expected: string | null = null;
if (typeof cursor === 'string') {
const sep = cursor.indexOf(':');
const idx = sep >= 0 ? Number(cursor.slice(0, sep)) : NaN;
if (!Number.isInteger(idx) || idx < 0) {
throw new ToolError('CURSOR_STALE', `Malformed cursor ${JSON.stringify(cursor)}; re-run the query and page from the start.`);
}
i = idx;
expected = cursor.slice(sep + 1);
} else if (typeof cursor === 'number' && Number.isFinite(cursor) && cursor >= 0) {
i = Math.floor(cursor);
} else {
throw new ToolError('CURSOR_STALE', 'Invalid cursor value; re-run the query and page from the start.');
}
const fingerprint = resultSetFingerprint(items);
if (expected !== null && expected !== fingerprint) {
throw new ToolError(
'CURSOR_STALE',
`Result set changed since this cursor was issued (fingerprint ${expected} -> ${fingerprint}); re-run the query and page from the start.`,
{ cursorIndex: i, totalCount: items.length },
);
}
const result: T[] = [];
// Calculate overhead for wrapper: {"items":[],"nextCursor":null,"totalCount":123}
const wrapperTemplate = { items: [] as T[], nextCursor: null as string | null, totalCount: items.length };
const overhead = JSON.stringify(wrapperTemplate).length;
let charCount = overhead;
while (i < items.length) {
const itemJson = JSON.stringify(items[i]);
const addedChars = itemJson.length + (result.length > 0 ? 1 : 0); // +1 for comma
if (charCount + addedChars > maxChars) {
// Forward-progress guarantee: if we have NOTHING in the page yet, emit
// this oversized item anyway and advance. Otherwise stop and let the
// next page (which now has a fresh budget) handle it.
if (result.length === 0) {
result.push(items[i]);
charCount += addedChars;
i++;
}
break;
}
result.push(items[i]);
charCount += addedChars;
i++;
}
const nextCursor = i < items.length ? `${i}:${fingerprint}` : null;
return {
items: result,
nextCursor,
totalCount: items.length
};
}
function paginateGraph(graph: KnowledgeGraph, entityCursor: number | string = 0, relationCursor: number | string = 0): { entities: PaginatedResult<Entity>; relations: PaginatedResult<Relation> } {
// Entities and relations have independent cursors, so paginate them
// independently — each gets the full budget. The caller already has
// previously-returned pages and only needs the next page of whichever
// section it is advancing.
return {
entities: paginateItems(graph.entities, entityCursor),
relations: paginateItems(graph.relations, relationCursor),
};
}
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
export class KnowledgeGraphManager {
private db: Store;
constructor(memoryFilePath: string = DEFAULT_MEMORY_FILE_PATH) {
// Derive binary file paths from the base path
const dir = path.dirname(memoryFilePath);
const base = path.basename(memoryFilePath, path.extname(memoryFilePath));
const graphPath = path.join(dir, `${base}.graph`);
const strPath = path.join(dir, `${base}.strings`);
// Auto-migrate an old (v1/v2) KB to v3 in place before opening — the same
// transparent-on-open contract the old code used for v1->v2. ensureV3 holds
// a migration flock across detection AND migration, so concurrent startups
// serialize (no empty-clobber, no double-migrate). The migrator and the old
// addon load only if a migration actually fires.
ensureV3(graphPath, strPath);
// The C store opens both files and self-locks around its own init. It owns
// the memfile, string table, and name index.
this.db = new Store(graphPath, strPath);
// Initial structural sampling + MERW under an exclusive lock (the C ops
// mutate the graph file; withWriteLock syncs on exit).
this.withWriteLock(() => {
if (this.db.entityCount() > 0) {
this.db.structuralSample(1, 0.85);
this.db.computeMerwPsi(0.85, 200, 1e-8);
}
});
}
/**
* Run the loadDocument pipeline under an exclusive (write) lock.
*
* `loadDocument` calls `st.intern(...)` for every word it sees, which
* invokes `mf.alloc()` on the strings file whenever a new string is added.
* That is a write to shared state, so we need an exclusive lock — a shared
* (read) lock would let multiple processes hammer the strings allocator
* simultaneously and corrupt its free list (manifesting as a SIGSEGV in
* `memfile_alloc`). The lock now covers BOTH files (see
* {@link GraphFile.lockExclusive}).
*
* The full pipeline is wrapped in a `kb.load_document` span; the inner
* `loadDocument` emits per-stage child spans (normalize → chunking →
* IDF → TextRank → assemble) so users can attribute time to specific
* stages without spelunking flame graphs.
*/
prepareDocumentLoad(text: string, title: string, topK: number): KbLoadResult {
return traced(
'kb.load_document',
{
'kb.load.title': title,
'kb.load.input_chars': text.length,
'kb.load.top_k': topK,
},
(span) => this.withWriteLock(() => {
const result = loadDocument(text, title, this.corpusShim() as never, topK);
span.setAttribute('kb.load.chunks', result.stats.chunks);
span.setAttribute('kb.load.sentences', result.stats.sentences);
span.setAttribute('kb.load.unique_words', result.stats.uniqueWords);
span.setAttribute('kb.load.index_highlights', result.stats.indexHighlights);
span.setAttribute('kb.load.entities', result.entities.length);
span.setAttribute('kb.load.relations', result.relations.length);
return result;
}),
);
}
// --- Locking helpers ---
/**
* Acquire a shared (read) lock on BOTH the graph and string-table files
* (via {@link GraphFile.lockShared}), refresh mappings (in case another
* process grew the files), rebuild the name index, run the callback, then
* release the lock.
*
* The name index is rebuilt unconditionally on every acquisition. The
* previous "skip rebuild if entity count is unchanged" optimization was
* incorrect under multi-process load: if another process did
* delete-then-create, the entity count can be unchanged while the freed
* slot has been recycled to a different name. The stale nameIndex entry
* would then dereference into reused free-list memory and downstream
* derivations (e.g. {@link AdjEntry.targetOffset}) could yield wildly
* out-of-bounds offsets.
*
* Wrapped in a `kb.lock.read` span. The `kb.lock.wait_ms` attribute records
* how long we blocked acquiring the OS-level shared lock — useful for
* spotting contention from concurrent writers.
*/
private withReadLock<T>(fn: () => T): T {
return traced('kb.lock.read', {}, (span) => {
const acquireStart = process.hrtime.bigint();
this.db.lockShared();
const waitMs = Number(process.hrtime.bigint() - acquireStart) / 1e6;
span.setAttribute('kb.lock.wait_ms', waitMs);
try {
this.db.refresh();
span.setAttribute('kb.entity_count', this.db.entityCount());
return fn();
} finally {
this.db.unlock();
}
});
}
/**
* Acquire an exclusive (write) lock on BOTH the graph and string-table
* files (via {@link GraphFile.lockExclusive}), refresh mappings, rebuild
* name index, run the callback, sync both files, then release the lock.
*
* Required for any callback that mutates the strings file (allocation via
* `st.intern` of a previously-unseen string) as well as for graph
* mutations — withReadLock on the graph fd alone does NOT serialize
* strings-file mutations because flock(2) is per-fd.
*
* Like {@link withReadLock}, the name index is rebuilt unconditionally to
* pick up any concurrent delete+create that another process may have
* performed since this process last held a lock.
*
* Wrapped in a `kb.lock.write` span. The `kb.lock.wait_ms` attribute records
* blocking time waiting for the exclusive lock.
*/
private withWriteLock<T>(fn: () => T): T {
return traced('kb.lock.write', {}, (span) => {
const acquireStart = process.hrtime.bigint();
this.db.lockExclusive();
const waitMs = Number(process.hrtime.bigint() - acquireStart) / 1e6;
span.setAttribute('kb.lock.wait_ms', waitMs);
try {
this.db.refresh();
span.setAttribute('kb.entity_count', this.db.entityCount());
const result = fn();
this.db.sync();
return result;
} finally {
this.db.unlock();
}
});
}
// --- kb_load corpus shim --------------------------------------------------
/**
* Minimal StringTable-shaped shim for loadDocument's IDF corpus pass: yields
* every entity's name/type/observation strings from the C store. The real
* string table now lives in C; loadDocument only consumes `entries()`.
*/
private corpusShim(): { entries: () => Generator<{ id: bigint; text: string; refcount: number }> } {
const db = this.db;
return {
*entries() {
for (const off of db.listEntities()) {
const r = db.readEntity(off);
yield { id: off, text: r.name, refcount: 1 };
yield { id: off, text: r.type, refcount: 1 };
for (const o of r.observations) yield { id: off, text: o, refcount: 1 };
}
},
};
}
/** Build rank maps from the binary store for pagerank/llmrank sorting.
* NOTE: Must be called inside a lock (read or write).
*
* Always-on `kb.rank.read` child span: this is a hot path called from every
* read tool that sorts by rank, and surfaces the cost of reading the
* per-entity rank fields under the active lock.
*/
private getRankMapsUnlocked(): { structural: Map<string, number>; walker: Map<string, number> } {
return traced('kb.rank.read', { 'kb.entity_count': this.db.entityCount() }, () => {
const structural = new Map<string, number>();
const walker = new Map<string, number>();
const structTotal = this.db.structuralTotal();
const walkerTotal = this.db.walkerTotal();
for (const offset of this.db.listEntities()) {
const rec = this.db.readEntity(offset);
structural.set(rec.name, structTotal > 0n ? Number(rec.structuralVisits) / Number(structTotal) : 0);
walker.set(rec.name, walkerTotal > 0n ? Number(rec.walkerVisits) / Number(walkerTotal) : 0);
}
return { structural, walker };
});
}
/** Build rank maps (acquires read lock). */
getRankMaps(): { structural: Map<string, number>; walker: Map<string, number> } {
return this.withReadLock(() => this.getRankMapsUnlocked());
}
/** Increment walker visit count for a list of entity names */
recordWalkerVisits(names: string[]): void {
traced('kb.walker.record', { 'kb.walker.count': names.length }, () => {
this.withWriteLock(() => {
for (const name of names) {
const offset = this.db.lookup(name);
if (offset !== 0n) {
this.db.incWalkerVisit(offset);
}
}
});
});
}
/**
* Re-run structural sampling and MERW eigenvector computation (call after
* graph mutations).
*
* Emits two child spans under the active span: `kb.rank.pagerank`
* (structural sampling — PageRank-style random walks) and `kb.rank.merw`
* (Maximum-Entropy Random Walk eigenvector). Skipped entirely on an empty
* graph so the spans aren't emitted for trivial no-op resamples.
*/
resample(): void {
this.withWriteLock(() => {
if (this.db.entityCount() === 0) return;
traced(
'kb.rank.pagerank',
{ 'kb.entity_count': this.db.entityCount() },
() => this.db.structuralSample(1, 0.85),
);
traced(
'kb.rank.merw',
{ 'kb.entity_count': this.db.entityCount() },
() => this.db.computeMerwPsi(0.85, 200, 1e-8),
);
});
}
/** Convert a native entity record to the public Entity interface */
private recordToEntity(rec: NativeEntity): Entity {
const entity: Entity = { name: rec.name, entityType: rec.type, observations: rec.observations };
const mtime = Number(rec.mtime);
const obsMtime = Number(rec.obsMtime);
if (mtime > 0) entity.mtime = mtime;
if (obsMtime > 0) entity.obsMtime = obsMtime;
return entity;
}
/** Get all entities as Entity objects (preserves node log order = insertion order) */
private getAllEntities(): Entity[] {
return this.db.listEntities().map(o => this.recordToEntity(this.db.readEntity(o)));
}
/** Get all relations by scanning adjacency lists (forward edges only to avoid duplication) */
private getAllRelations(): Relation[] {
const relations: Relation[] = [];
for (const offset of this.db.listEntities()) {
const fromName = this.db.entityName(offset);
for (const edge of this.db.edges(offset)) {
if (edge.direction !== DIR_FORWARD) continue;
const toName = this.db.entityName(edge.target);
const r: Relation = { from: fromName, to: toName, relationType: edge.relType };
const mtime = Number(edge.mtime);
if (mtime > 0) r.mtime = mtime;
relations.push(r);
}
}
return relations;
}
/** Load the full graph (entities + relations) */
private loadGraph(): KnowledgeGraph {
return {
entities: this.getAllEntities(),
relations: this.getAllRelations(),
};
}
async createEntities(entities: Entity[]): Promise<{ created: Entity[]; existing: string[] }> {
// Validate observation limits across the WHOLE batch first, collecting
// every violation rather than stopping at the first (R2: validate-first;
// the caller gets all offenders in one round-trip).
const limitViolations: { entity: string; code: ToolErrorCode; message: string }[] = [];
for (const entity of entities) {
if (entity.observations.length > 2) {
limitViolations.push({
entity: entity.name,
code: 'LIMIT_EXCEEDED',
message: `has ${entity.observations.length} observations; maximum allowed is 2`,
});
}
entity.observations.forEach((obs, index) => {
if (obs.length > 140) {
limitViolations.push({
entity: entity.name,
code: 'LIMIT_EXCEEDED',
message: `observation #${index + 1} is ${obs.length} chars (140 max, ${obs.length - 140} over)`,
});
}
});
}
if (limitViolations.length > 0) {
throw new ToolError(
'LIMIT_EXCEEDED',
`${limitViolations.length} limit violation(s) in the batch; nothing was created (details below).`,
limitViolations,
);
}
return this.withWriteLock(() => {
const now = BigInt(Date.now());
// Pass 1 — classify every entity before writing anything. A collision
// fails the whole batch (R2: atomic) and lists every offender.
const toCreate: Entity[] = [];
const existingNames: string[] = [];
const collisions: { entity: string; code: ToolErrorCode; message: string }[] = [];
for (const e of entities) {
const existingOffset = this.db.lookup(e.name);
if (existingOffset !== 0n) {
const existing = this.recordToEntity(this.db.readEntity(existingOffset));
const sameType = existing.entityType === e.entityType;
const sameObs = existing.observations.length === e.observations.length &&
existing.observations.every((o, i) => o === e.observations[i]);
if (sameType && sameObs) { existingNames.push(e.name); continue; } // exact duplicate: intent satisfied (R3 ledger)
collisions.push({
entity: e.name,
code: 'COLLISION',
message: `exists with different data (type: "${existing.entityType}" vs "${e.entityType}", observations: ${existing.observations.length} vs ${e.observations.length})`,
});
} else {
toCreate.push(e);
}
}
if (collisions.length > 0) {
throw new ToolError(
'COLLISION',
`${collisions.length} entity name collision(s); nothing was created (details below).`,
collisions,
);
}
// Pass 2 — create.
const newEntities: Entity[] = [];
for (const e of toCreate) {
const offset = this.db.createEntity(e.name, e.entityType, now);
for (const obs of e.observations) {
this.db.addObservation(offset, obs, now);
}
const newEntity: Entity = {
...e,
mtime: Number(now),
obsMtime: e.observations.length > 0 ? Number(now) : undefined,
};
newEntities.push(newEntity);
}
return { created: newEntities, existing: existingNames };
});
}
async createRelations(relations: Relation[]): Promise<{ created: Relation[]; skippedDuplicates: Relation[] }> {
return this.withWriteLock(() => {
const now = BigInt(Date.now());
// Pass 1 — every endpoint must exist; missing endpoints fail the whole
// batch (R2/R4: a relation to a nonexistent entity is a state desync,
// never a silent drop).
const missing: { from: string; to: string; relationType: string; code: ToolErrorCode; missing: string[]; message: string }[] = [];
for (const r of relations) {
const missingEnds: string[] = [];
if (this.db.lookup(r.from) === 0n) missingEnds.push('from');
if (this.db.lookup(r.to) === 0n) missingEnds.push('to');
if (missingEnds.length > 0) {
missing.push({
from: r.from,
to: r.to,
relationType: r.relationType,
code: 'ENDPOINT_MISSING',
missing: missingEnds,
message: `endpoint(s) not found: ${missingEnds.join(', ')}`,
});
}
}
if (missing.length > 0) {
throw new ToolError(
'ENDPOINT_MISSING',
`${missing.length} relation(s) reference nonexistent entities; nothing was created (details below).`,
missing,
);
}
// Pass 2 — create. Duplicates are already-satisfied intent and are
// skipped (they become part of the result ledger under policy R3).
const newRelations: Relation[] = [];
const skippedDuplicates: Relation[] = [];
for (const r of relations) {
const fromOffset = this.db.lookup(r.from);
const toOffset = this.db.lookup(r.to);
const isDuplicate = this.db.edges(fromOffset).some(e =>
e.direction === DIR_FORWARD && e.target === toOffset && e.relType === r.relationType
);
if (isDuplicate) { skippedDuplicates.push(r); continue; }
// C owns the bidirectional edges + relType interning/refcounts.
this.db.createRelation(fromOffset, toOffset, r.relationType, now);
newRelations.push({ ...r, mtime: Number(now) });
}
return { created: newRelations, skippedDuplicates };
});
}
async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[]; alreadyPresent: string[] }[]> {
return this.withWriteLock(() => {
// Pass 1 — validate the WHOLE batch before touching state (R2: atomic;
// the old code validated inside the write loop, so a late failure left
// earlier items applied). Collect every violation for one round-trip.
const pending: { entityName: string; offset: bigint; newObservations: string[]; alreadyPresent: string[] }[] = [];
const violations: { entityName: string; code: ToolErrorCode; message: string }[] = [];
for (const o of observations) {
const offset = this.db.lookup(o.entityName);
if (offset === 0n) {
violations.push({ entityName: o.entityName, code: 'ENTITY_NOT_FOUND', message: 'entity does not exist' });
continue;
}
o.contents.forEach((obs, index) => {
if (obs.length > 140) {
violations.push({
entityName: o.entityName,
code: 'LIMIT_EXCEEDED',
message: `observation #${index + 1} is ${obs.length} chars (140 max, ${obs.length - 140} over)`,
});
}
});
const existingObs = this.db.readEntity(offset).observations;
const newObservations = o.contents.filter(content => !existingObs.includes(content));
const alreadyPresent = o.contents.filter(content => existingObs.includes(content));
if (existingObs.length + newObservations.length > 2) {
violations.push({
entityName: o.entityName,
code: 'LIMIT_EXCEEDED',
message: `adding ${newObservations.length} observation(s) would give ${existingObs.length + newObservations.length} total; maximum is 2`,
});
}
pending.push({ entityName: o.entityName, offset, newObservations, alreadyPresent });
}
if (violations.length > 0) {
const codes = new Set(violations.map(v => v.code));
throw new ToolError(
codes.size === 1 ? violations[0].code : 'VALIDATION_FAILED',
`${violations.length} observation(s) failed validation; nothing was written (details below).`,
violations,
);
}
// Pass 2 — apply.
const now = BigInt(Date.now());
const results: { entityName: string; addedObservations: string[]; alreadyPresent: string[] }[] = [];
for (const p of pending) {
for (const obs of p.newObservations) {
this.db.addObservation(p.offset, obs, now);
}
results.push({ entityName: p.entityName, addedObservations: p.newObservations, alreadyPresent: p.alreadyPresent });
}
return results;
});
}
async deleteEntities(entityNames: string[]): Promise<{ deleted: string[]; notFound: string[] }> {
return this.withWriteLock(() => {
const deleted: string[] = [];
const notFound: string[] = [];
for (const name of entityNames) {
const offset = this.db.lookup(name);
if (offset === 0n) { notFound.push(name); continue; }
// C deletes the record + adjacency, drops mirror edges, and releases
// every string ref (name/type/obs + relType per edge + mirror).
this.db.deleteEntity(offset);
deleted.push(name);
}
return { deleted, notFound };
});
}
async deleteObservations(deletions: { entityName: string; observations: string[] }[]): Promise<{
deleted: { entityName: string; observations: string[] }[];
notFound: { entityName: string; observation?: string; reason: 'entity' | 'observation' }[];
}> {
return this.withWriteLock(() => {
const now = BigInt(Date.now());
const deleted: { entityName: string; observations: string[] }[] = [];
const notFound: { entityName: string; observation?: string; reason: 'entity' | 'observation' }[] = [];
for (const d of deletions) {
const offset = this.db.lookup(d.entityName);
if (offset === 0n) { notFound.push({ entityName: d.entityName, reason: 'entity' }); continue; }
const existing = this.db.readEntity(offset).observations;
const removed: string[] = [];
for (const obs of d.observations) {
if (!existing.includes(obs)) { notFound.push({ entityName: d.entityName, observation: obs, reason: 'observation' }); continue; }
this.db.removeObservation(offset, obs, now);
removed.push(obs);
}
if (removed.length > 0) deleted.push({ entityName: d.entityName, observations: removed });
}
return { deleted, notFound };
});
}
async deleteRelations(relations: Relation[]): Promise<{ deleted: Relation[]; notFound: { from: string; to: string; relationType: string; reason: 'entity' | 'relation' }[] }> {
return this.withWriteLock(() => {
const deleted: Relation[] = [];
const notFound: { from: string; to: string; relationType: string; reason: 'entity' | 'relation' }[] = [];
for (const r of relations) {
const fromOffset = this.db.lookup(r.from);
const toOffset = this.db.lookup(r.to);
if (fromOffset === 0n || toOffset === 0n) {
notFound.push({ from: r.from, to: r.to, relationType: r.relationType, reason: 'entity' });
continue;
}
const exists = this.db.edges(fromOffset).some(e =>
e.direction === DIR_FORWARD && e.target === toOffset && e.relType === r.relationType
);
if (!exists) { notFound.push({ from: r.from, to: r.to, relationType: r.relationType, reason: 'relation' }); continue; }
// C removes both directed edges and releases the two relType refs.
this.db.deleteRelation(fromOffset, toOffset, r.relationType);
deleted.push(r);
}
return { deleted, notFound };
});
}
/**
* Regex-based entity search.
*
* Internally tries to extract a trigram filter from the regex source via
* {@link extractRequiredTrigramFilter}. If extraction succeeds we use the
* trigram index to narrow the candidate set before running the regex; if
* not (regex contains metacharacters we don't safely handle), we fall back
* to the linear scan. Same external contract — caller still passes a
* regex; the speedup is invisible.
*/
async searchNodes(
query: string,
sortBy?: EntitySortField,
sortDir?: SortDirection,
direction: 'forward' | 'backward' | 'any' = 'forward',
): Promise<KnowledgeGraph> {
// Validate with the SAME engine that matches (C POSIX ERE), so a valid ERE
// query is never rejected by a divergent JS RegExp dialect, and an invalid one
// is rejected consistently. Preserves the "Invalid regex pattern" contract.
if (!this.db.regexValid(query)) {
throw new ToolError('INVALID_REGEX', `Invalid regex pattern: ${query}`);
}
return traced(
'kb.search_nodes',
{
'kb.search.direction': direction,
'kb.search.query_length': query.length,
...(sortBy ? { 'kb.search.sort_by': sortBy } : {}),
},
(span) => this.withReadLock(() => {
const filteredEntities = this.db.search(query).map(o => this.recordToEntity(this.db.readEntity(o)));
const filteredEntityNames = new Set(filteredEntities.map(e => e.name));
const allRelations = this.getAllRelations();
const filteredRelations = allRelations.filter(r => {
if (direction === 'forward') return filteredEntityNames.has(r.from);
if (direction === 'backward') return filteredEntityNames.has(r.to);
return filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to);
});
span.setAttribute('kb.search.used_trigram', false);
span.setAttribute('kb.search.scanned.entities', this.db.entityCount());
span.setAttribute('kb.search.scanned.relations', allRelations.length);
span.setAttribute('kb.search.matched.entities', filteredEntities.length);
span.setAttribute('kb.search.matched.relations', filteredRelations.length);
const rankMaps = this.getRankMapsUnlocked();
return {
entities: sortEntities(filteredEntities, sortBy, sortDir, rankMaps),
relations: filteredRelations,
};
}),
);
}
async openNodes(names: string[], direction: 'forward' | 'backward' | 'any' = 'forward'): Promise<KnowledgeGraph & { missing: string[] }> {
return this.withReadLock(() => {
const filteredEntities: Entity[] = [];
const missing: string[] = [];
const offsetByName = new Map<string, bigint>();
for (const name of names) {
const offset = this.db.lookup(name);
if (offset === 0n) { missing.push(name); continue; }