1. maxEvents limit enforcement:
- Changed trimming condition from `> maxEvents + TRIM_BATCH_SIZE` to `> maxEvents`
- Now properly trims to exactly maxEvents when limit is exceeded
- Fixed: tests "should trim old events when over limit", "should keep most recent events",
"should use default maxEvents of 10000"
2. Cross-Reference Integration:
- Modified CrossReferenceManager.processEvent() to create immediate worker->event links
- This ensures every event creates at least one cross-reference link
- Fixed: tests "should track cross-references when events are added",
"should create links between events and workers", "should find linked entities"
3. Bead collision detection:
- Fixed detectBeadCollision() to include all workers in collision set
- Added time window check: only detect collision if other worker was active within
BEAD_COLLISION_WINDOW_MS (60 seconds)
- Fixed: tests "should detect collision when multiple workers work on same bead",
"should not detect bead collision outside time window", "should update worker
collision types for bead collision"
All 103 tests now pass (1 skipped).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
806 lines
22 KiB
TypeScript
806 lines
22 KiB
TypeScript
/**
|
|
* FABRIC Cross-Reference Manager
|
|
*
|
|
* Detects and manages relationships between events, tasks, files, and workers.
|
|
* Enables hyperlinking across the FABRIC dashboard for navigation.
|
|
*/
|
|
|
|
import {
|
|
LogEvent,
|
|
CrossReferenceLink,
|
|
CrossReferenceEntity,
|
|
CrossReferenceEntityType,
|
|
CrossReferenceRelationship,
|
|
CrossReferenceQueryOptions,
|
|
CrossReferenceStats,
|
|
CrossReferencePath,
|
|
} from './types.js';
|
|
|
|
/** Time window (ms) to consider events as temporally related */
|
|
const TEMPORAL_WINDOW_MS = 30000; // 30 seconds
|
|
|
|
/** Minimum strength threshold for links */
|
|
const MIN_STRENGTH = 0.1;
|
|
|
|
/** Maximum links to store */
|
|
const MAX_LINKS = 5000;
|
|
|
|
/** Maximum entities to track (prevents unbounded growth from per-event entries) */
|
|
const MAX_ENTITIES = 2000;
|
|
|
|
/** Maximum entries in the event index */
|
|
const MAX_EVENT_INDEX = 5000;
|
|
|
|
/**
|
|
* Generate a unique ID for a cross-reference link
|
|
*/
|
|
function generateLinkId(
|
|
sourceType: CrossReferenceEntityType,
|
|
sourceId: string,
|
|
targetType: CrossReferenceEntityType,
|
|
targetId: string,
|
|
relationship: CrossReferenceRelationship
|
|
): string {
|
|
return `${sourceType}:${sourceId}->${relationship}:${targetType}:${targetId}`;
|
|
}
|
|
|
|
/**
|
|
* Generate a unique ID for an entity
|
|
*/
|
|
function generateEntityId(type: CrossReferenceEntityType, id: string): string {
|
|
return `${type}:${id}`;
|
|
}
|
|
|
|
/**
|
|
* Internal tracking structure for entities
|
|
*/
|
|
interface InternalEntity {
|
|
type: CrossReferenceEntityType;
|
|
id: string;
|
|
firstSeen: number;
|
|
lastSeen: number;
|
|
occurrenceCount: number;
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* Cross-Reference Manager
|
|
*
|
|
* Tracks relationships between events, workers, files, and beads.
|
|
*/
|
|
export class CrossReferenceManager {
|
|
private links: Map<string, CrossReferenceLink> = new Map();
|
|
private entities: Map<string, InternalEntity> = new Map();
|
|
private workerIndex: Map<string, string[]> = new Map();
|
|
private fileIndex: Map<string, string[]> = new Map();
|
|
private beadIndex: Map<string, string[]> = new Map();
|
|
|
|
/**
|
|
* Process a log event and extract cross-references.
|
|
* Creates immediate links between entities (worker, file, bead, event) for each event.
|
|
*/
|
|
processEvent(event: LogEvent): void {
|
|
if (!event.worker) return;
|
|
|
|
// Register worker entity
|
|
this.registerEntity('worker', event.worker, event.ts, `Worker ${event.worker.slice(0, 8)}`);
|
|
|
|
// Create worker->event link (always create at least one link per event)
|
|
const eventId = this.getEventId(event);
|
|
this.createLink(
|
|
'worker',
|
|
event.worker,
|
|
'event',
|
|
eventId,
|
|
'same_worker',
|
|
0.3,
|
|
event.ts,
|
|
`Generated event`
|
|
);
|
|
|
|
// Create worker->file link
|
|
if (event.path) {
|
|
const fileName = event.path.split('/').pop() || event.path;
|
|
this.registerEntity('file', event.path, event.ts, fileName);
|
|
this.createLink(
|
|
'worker',
|
|
event.worker,
|
|
'file',
|
|
event.path,
|
|
'same_file',
|
|
0.5,
|
|
event.ts,
|
|
`Modified ${fileName}`
|
|
);
|
|
}
|
|
|
|
// Create worker->bead link
|
|
if (event.bead) {
|
|
this.registerEntity('bead', event.bead, event.ts, `Task ${event.bead}`);
|
|
this.createLink(
|
|
'worker',
|
|
event.worker,
|
|
'bead',
|
|
event.bead,
|
|
'same_bead',
|
|
0.8,
|
|
event.ts,
|
|
`Working on ${event.bead}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process multiple events and find relationships
|
|
*/
|
|
processBatch(events: LogEvent[]): void {
|
|
for (const event of events) {
|
|
this.processEvent(event);
|
|
}
|
|
this.findTemporalRelationships(events);
|
|
this.findBeadRelationships(events);
|
|
this.findFileRelationships(events);
|
|
this.findToolSequences(events);
|
|
}
|
|
|
|
/**
|
|
* Get human-readable label for an event
|
|
*/
|
|
private getEventLabel(event: LogEvent): string {
|
|
const time = new Date(event.ts).toLocaleTimeString();
|
|
const msg = event.msg?.slice(0, 30) || 'Event';
|
|
return `${time} ${msg}`;
|
|
}
|
|
|
|
/**
|
|
* Register an entity in the tracking system.
|
|
* Event-type entities are skipped to avoid unbounded per-event growth.
|
|
*/
|
|
private registerEntity(
|
|
type: CrossReferenceEntityType,
|
|
id: string,
|
|
timestamp: number,
|
|
label: string
|
|
): void {
|
|
// Skip event-type entities — they're transient and create one entry per event
|
|
if (type === 'event') return;
|
|
|
|
const entityId = generateEntityId(type, id);
|
|
const existing = this.entities.get(entityId);
|
|
|
|
if (existing) {
|
|
existing.lastSeen = timestamp;
|
|
existing.occurrenceCount++;
|
|
} else {
|
|
if (this.entities.size >= MAX_ENTITIES) {
|
|
this.trimEntities();
|
|
}
|
|
this.entities.set(entityId, {
|
|
type,
|
|
id,
|
|
firstSeen: timestamp,
|
|
lastSeen: timestamp,
|
|
occurrenceCount: 1,
|
|
label,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert internal entity to public CrossReferenceEntity format
|
|
*/
|
|
private toCrossReferenceEntity(internal: InternalEntity): CrossReferenceEntity {
|
|
const links = this.getLinksForEntity(internal.type, internal.id);
|
|
const outgoingLinks = links.filter(l => l.sourceType === internal.type && l.sourceId === internal.id);
|
|
const incomingLinks = links.filter(l => l.targetType === internal.type && l.targetId === internal.id);
|
|
|
|
const relatedEntities = new Map<CrossReferenceEntityType, CrossReferenceLink[]>();
|
|
for (const link of links) {
|
|
const targetType = link.targetType;
|
|
if (!relatedEntities.has(targetType)) {
|
|
relatedEntities.set(targetType, []);
|
|
}
|
|
relatedEntities.get(targetType)!.push(link);
|
|
}
|
|
|
|
return {
|
|
type: internal.type,
|
|
id: internal.id,
|
|
label: internal.label,
|
|
outgoingLinks,
|
|
incomingLinks,
|
|
relatedEntities,
|
|
linkCount: links.length,
|
|
lastLinkedAt: links.length > 0 ? Math.max(...links.map(l => l.detectedAt)) : internal.lastSeen,
|
|
firstSeen: internal.firstSeen,
|
|
occurrenceCount: internal.occurrenceCount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a cross-reference link
|
|
*/
|
|
private createLink(
|
|
sourceType: CrossReferenceEntityType,
|
|
sourceId: string,
|
|
targetType: CrossReferenceEntityType,
|
|
targetId: string,
|
|
relationship: CrossReferenceRelationship,
|
|
strength: number,
|
|
timestamp: number,
|
|
context?: string
|
|
): CrossReferenceLink | null {
|
|
if (sourceType === targetType && sourceId === targetId) {
|
|
return null;
|
|
}
|
|
|
|
const linkId = generateLinkId(sourceType, sourceId, targetType, targetId, relationship);
|
|
const existing = this.links.get(linkId);
|
|
|
|
if (existing) {
|
|
existing.strength = Math.min(1.0, existing.strength + 0.1);
|
|
existing.detectedAt = timestamp;
|
|
return existing;
|
|
}
|
|
|
|
const link: CrossReferenceLink = {
|
|
id: linkId,
|
|
sourceType,
|
|
sourceId,
|
|
targetType,
|
|
targetId,
|
|
relationship,
|
|
strength: Math.min(1.0, Math.max(MIN_STRENGTH, strength)),
|
|
detectedAt: timestamp,
|
|
context,
|
|
};
|
|
|
|
this.links.set(linkId, link);
|
|
this.addToIndex(sourceType, sourceId, linkId);
|
|
this.addToIndex(targetType, targetId, linkId);
|
|
|
|
if (this.links.size > MAX_LINKS) {
|
|
this.trimOldLinks();
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* Add link ID to appropriate index
|
|
*/
|
|
private addToIndex(type: CrossReferenceEntityType, key: string, linkId: string): void {
|
|
const indexMap = this.getIndexMap(type);
|
|
if (!indexMap) return;
|
|
|
|
if (!indexMap.has(key)) {
|
|
indexMap.set(key, []);
|
|
}
|
|
const linkIds = indexMap.get(key)!;
|
|
if (!linkIds.includes(linkId)) {
|
|
linkIds.push(linkId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the index map for an entity type
|
|
*/
|
|
private getIndexMap(type: CrossReferenceEntityType): Map<string, string[]> | null {
|
|
switch (type) {
|
|
case 'worker': return this.workerIndex;
|
|
case 'file': return this.fileIndex;
|
|
case 'bead': return this.beadIndex;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find temporal relationships between events
|
|
*/
|
|
private findTemporalRelationships(events: LogEvent[]): void {
|
|
const sorted = [...events].sort((a, b) => a.ts - b.ts);
|
|
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const event = sorted[i];
|
|
|
|
for (let j = i + 1; j < sorted.length; j++) {
|
|
const other = sorted[j];
|
|
const timeDiff = other.ts - event.ts;
|
|
|
|
if (timeDiff > TEMPORAL_WINDOW_MS) break;
|
|
if (event.worker === other.worker && event.ts === other.ts) continue;
|
|
|
|
const strength = 1.0 - (timeDiff / TEMPORAL_WINDOW_MS);
|
|
this.createLink(
|
|
'event',
|
|
this.getEventId(event),
|
|
'event',
|
|
this.getEventId(other),
|
|
'temporal_proximity',
|
|
strength,
|
|
event.ts,
|
|
`${Math.round(timeDiff / 1000)}s apart`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find relationships between events on the same bead
|
|
*/
|
|
private findBeadRelationships(events: LogEvent[]): void {
|
|
const beadEvents = new Map<string, LogEvent[]>();
|
|
|
|
for (const event of events) {
|
|
if (event.bead) {
|
|
if (!beadEvents.has(event.bead)) {
|
|
beadEvents.set(event.bead, []);
|
|
}
|
|
beadEvents.get(event.bead)!.push(event);
|
|
}
|
|
}
|
|
|
|
for (const [beadId, beadEventList] of beadEvents) {
|
|
const workers = [...new Set(beadEventList.map(e => e.worker))];
|
|
|
|
for (let i = 0; i < workers.length; i++) {
|
|
for (let j = i + 1; j < workers.length; j++) {
|
|
if (workers[i] !== workers[j]) {
|
|
const firstEvent = beadEventList.find(e => e.worker === workers[i])!;
|
|
this.createLink(
|
|
'worker',
|
|
workers[i],
|
|
'worker',
|
|
workers[j],
|
|
'same_bead',
|
|
0.8,
|
|
firstEvent.ts,
|
|
`Both worked on ${beadId}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find relationships between events on the same file
|
|
*/
|
|
private findFileRelationships(events: LogEvent[]): void {
|
|
const fileGroups = new Map<string, LogEvent[]>();
|
|
|
|
for (const event of events) {
|
|
if (event.path) {
|
|
if (!fileGroups.has(event.path)) {
|
|
fileGroups.set(event.path, []);
|
|
}
|
|
fileGroups.get(event.path)!.push(event);
|
|
}
|
|
}
|
|
|
|
for (const [filePath, fileEvents] of fileGroups) {
|
|
const workers = [...new Set(fileEvents.map(e => e.worker))];
|
|
const fileName = filePath.split('/').pop() || filePath;
|
|
|
|
for (let i = 0; i < workers.length; i++) {
|
|
for (let j = i + 1; j < workers.length; j++) {
|
|
if (workers[i] !== workers[j]) {
|
|
const sorted = [...fileEvents].sort((a, b) => a.ts - b.ts);
|
|
const firstEvent = sorted[0];
|
|
this.createLink(
|
|
'worker',
|
|
workers[i],
|
|
'worker',
|
|
workers[j],
|
|
'same_file',
|
|
0.7,
|
|
firstEvent.ts,
|
|
`Both modified ${fileName}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sorted = [...fileEvents].sort((a, b) => a.ts - b.ts);
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
for (let j = i + 1; j < sorted.length; j++) {
|
|
const timeDiff = sorted[j].ts - sorted[i].ts;
|
|
if (timeDiff < TEMPORAL_WINDOW_MS && sorted[i].worker !== sorted[j].worker) {
|
|
this.createLink(
|
|
'worker',
|
|
sorted[i].worker,
|
|
'worker',
|
|
sorted[j].worker,
|
|
'collision',
|
|
0.9,
|
|
sorted[i].ts,
|
|
`Collision on ${fileName}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find tool sequence relationships
|
|
*/
|
|
private findToolSequences(events: LogEvent[]): void {
|
|
const workerEvents = new Map<string, LogEvent[]>();
|
|
|
|
for (const event of events) {
|
|
if (!workerEvents.has(event.worker)) {
|
|
workerEvents.set(event.worker, []);
|
|
}
|
|
workerEvents.get(event.worker)!.push(event);
|
|
}
|
|
|
|
for (const [, workerEventList] of workerEvents) {
|
|
const sorted = [...workerEventList].sort((a, b) => a.ts - b.ts);
|
|
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
const current = sorted[i];
|
|
const next = sorted[i + 1];
|
|
|
|
if (current.tool && next.tool) {
|
|
const timeDiff = next.ts - current.ts;
|
|
|
|
if (timeDiff < 60000) {
|
|
this.createLink(
|
|
'event',
|
|
this.getEventId(current),
|
|
'event',
|
|
this.getEventId(next),
|
|
'tool_sequence',
|
|
0.6,
|
|
current.ts,
|
|
`${current.tool} -> ${next.tool}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get unique event ID for an event
|
|
*/
|
|
private getEventId(event: LogEvent): string {
|
|
return `${event.ts}-${event.worker}`;
|
|
}
|
|
|
|
/**
|
|
* Query cross-references with optional filter
|
|
*/
|
|
query(filter?: CrossReferenceQueryOptions): CrossReferenceLink[] {
|
|
let results = Array.from(this.links.values());
|
|
|
|
if (!filter) return results;
|
|
|
|
if (filter.sourceType) {
|
|
results = results.filter(l => l.sourceType === filter.sourceType);
|
|
}
|
|
if (filter.targetType) {
|
|
results = results.filter(l => l.targetType === filter.targetType);
|
|
}
|
|
if (filter.relationship) {
|
|
results = results.filter(l => l.relationship === filter.relationship);
|
|
}
|
|
if (filter.minStrength !== undefined) {
|
|
results = results.filter(l => l.strength >= filter.minStrength!);
|
|
}
|
|
if (filter.since !== undefined) {
|
|
results = results.filter(l => l.detectedAt >= filter.since!);
|
|
}
|
|
if (filter.until !== undefined) {
|
|
results = results.filter(l => l.detectedAt <= filter.until!);
|
|
}
|
|
|
|
results.sort((a, b) => {
|
|
if (b.strength !== a.strength) return b.strength - a.strength;
|
|
return b.detectedAt - a.detectedAt;
|
|
});
|
|
|
|
if (filter.limit !== undefined) {
|
|
results = results.slice(0, filter.limit);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get all links for a specific entity
|
|
*/
|
|
getLinksForEntity(type: CrossReferenceEntityType, id: string): CrossReferenceLink[] {
|
|
const indexMap = this.getIndexMap(type);
|
|
if (!indexMap) return [];
|
|
|
|
const linkIds = indexMap.get(id) || [];
|
|
return linkIds
|
|
.map(linkId => this.links.get(linkId))
|
|
.filter((link): link is CrossReferenceLink => link !== undefined);
|
|
}
|
|
|
|
/**
|
|
* Get linked entities for a specific entity
|
|
*/
|
|
getLinkedEntities(type: CrossReferenceEntityType, id: string): CrossReferenceEntity[] {
|
|
const links = this.getLinksForEntity(type, id);
|
|
const internalEntities: InternalEntity[] = [];
|
|
|
|
for (const link of links) {
|
|
const targetEntityId = generateEntityId(link.targetType, link.targetId);
|
|
const targetEntity = this.entities.get(targetEntityId);
|
|
if (targetEntity) {
|
|
internalEntities.push(targetEntity);
|
|
}
|
|
|
|
if (link.sourceType !== type || link.sourceId !== id) {
|
|
const sourceEntityId = generateEntityId(link.sourceType, link.sourceId);
|
|
const sourceEntity = this.entities.get(sourceEntityId);
|
|
if (sourceEntity) {
|
|
internalEntities.push(sourceEntity);
|
|
}
|
|
}
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
return internalEntities.filter(e => {
|
|
const key = generateEntityId(e.type, e.id);
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
}).map(e => this.toCrossReferenceEntity(e));
|
|
}
|
|
|
|
/**
|
|
* Find a navigation path between two entities
|
|
*/
|
|
findPath(
|
|
sourceType: CrossReferenceEntityType,
|
|
sourceId: string,
|
|
targetType: CrossReferenceEntityType,
|
|
targetId: string,
|
|
maxDepth: number = 5
|
|
): CrossReferencePath | null {
|
|
const sourceEntityId = generateEntityId(sourceType, sourceId);
|
|
const targetEntityId = generateEntityId(targetType, targetId);
|
|
|
|
const sourceInternal = this.entities.get(sourceEntityId);
|
|
const targetInternal = this.entities.get(targetEntityId);
|
|
|
|
if (!sourceInternal || !targetInternal) return null;
|
|
|
|
const queue: { entityId: string; path: CrossReferenceLink[] }[] = [
|
|
{ entityId: sourceEntityId, path: [] },
|
|
];
|
|
const visited = new Set<string>();
|
|
visited.add(sourceEntityId);
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!;
|
|
|
|
if (current.path.length > maxDepth) continue;
|
|
|
|
const [currentType, currentId] = current.entityId.split(':') as [CrossReferenceEntityType, string];
|
|
const links = this.getLinksForEntity(currentType, currentId);
|
|
|
|
for (const link of links) {
|
|
const nextEntityId = generateEntityId(link.targetType, link.targetId);
|
|
|
|
if (nextEntityId === targetEntityId) {
|
|
const sourceEntity = this.toCrossReferenceEntity(sourceInternal);
|
|
const targetEntity = this.toCrossReferenceEntity(targetInternal);
|
|
return {
|
|
start: sourceEntity,
|
|
end: targetEntity,
|
|
steps: [...current.path, link],
|
|
length: current.path.length + 1,
|
|
description: this.describePath([...current.path, link]),
|
|
};
|
|
}
|
|
|
|
if (!visited.has(nextEntityId)) {
|
|
visited.add(nextEntityId);
|
|
queue.push({
|
|
entityId: nextEntityId,
|
|
path: [...current.path, link],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Generate a human-readable description of a path
|
|
*/
|
|
private describePath(path: CrossReferenceLink[]): string {
|
|
if (path.length === 0) return 'Direct link';
|
|
|
|
const parts: string[] = [];
|
|
for (const link of path) {
|
|
switch (link.relationship) {
|
|
case 'same_bead':
|
|
parts.push(`same task (${link.targetId})`);
|
|
break;
|
|
case 'same_file':
|
|
parts.push(`file: ${link.targetId.split('/').pop()}`);
|
|
break;
|
|
case 'same_worker':
|
|
parts.push(`worker: ${link.targetId.slice(0, 8)}`);
|
|
break;
|
|
case 'temporal_proximity':
|
|
parts.push('around same time');
|
|
break;
|
|
case 'collision':
|
|
parts.push('collision');
|
|
break;
|
|
case 'tool_sequence':
|
|
parts.push('tool sequence');
|
|
break;
|
|
default:
|
|
parts.push(link.relationship);
|
|
}
|
|
}
|
|
|
|
return parts.join(' -> ');
|
|
}
|
|
|
|
/**
|
|
* Get statistics about cross-references
|
|
*/
|
|
getStats(): CrossReferenceStats {
|
|
const byRelationship: Record<CrossReferenceRelationship, number> = {
|
|
same_bead: 0,
|
|
same_file: 0,
|
|
same_worker: 0,
|
|
temporal_proximity: 0,
|
|
same_session: 0,
|
|
dependency: 0,
|
|
collision: 0,
|
|
parent_child: 0,
|
|
error_related: 0,
|
|
tool_sequence: 0,
|
|
};
|
|
|
|
const byEntityType: Record<CrossReferenceEntityType, number> = {
|
|
event: 0,
|
|
worker: 0,
|
|
file: 0,
|
|
bead: 0,
|
|
session: 0,
|
|
};
|
|
|
|
for (const link of this.links.values()) {
|
|
byRelationship[link.relationship]++;
|
|
}
|
|
|
|
for (const entity of this.entities.values()) {
|
|
byEntityType[entity.type]++;
|
|
}
|
|
|
|
const entityLinkCounts = new Map<string, number>();
|
|
for (const link of this.links.values()) {
|
|
const sourceKey = generateEntityId(link.sourceType, link.sourceId);
|
|
const targetKey = generateEntityId(link.targetType, link.targetId);
|
|
entityLinkCounts.set(sourceKey, (entityLinkCounts.get(sourceKey) || 0) + 1);
|
|
entityLinkCounts.set(targetKey, (entityLinkCounts.get(targetKey) || 0) + 1);
|
|
}
|
|
|
|
const sortedEntities = Array.from(entityLinkCounts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([entityId]) => this.entities.get(entityId))
|
|
.filter((e): e is InternalEntity => e !== undefined)
|
|
.map(e => this.toCrossReferenceEntity(e));
|
|
|
|
const recentLinks = Array.from(this.links.values())
|
|
.sort((a, b) => b.detectedAt - a.detectedAt)
|
|
.slice(0, 10);
|
|
|
|
return {
|
|
totalLinks: this.links.size,
|
|
totalEntities: this.entities.size,
|
|
byRelationship,
|
|
byEntityType,
|
|
mostLinked: sortedEntities,
|
|
recentLinks,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Trim old links when over limit
|
|
*/
|
|
private trimOldLinks(): void {
|
|
const sorted = Array.from(this.links.entries())
|
|
.sort((a, b) => b[1].detectedAt - a[1].detectedAt);
|
|
|
|
const toKeep = new Map(sorted.slice(0, MAX_LINKS / 2));
|
|
this.links = toKeep;
|
|
this.rebuildIndices();
|
|
}
|
|
|
|
/**
|
|
* Trim oldest-seen entities when over the entity cap.
|
|
*/
|
|
private trimEntities(): void {
|
|
const sorted = Array.from(this.entities.entries())
|
|
.sort((a, b) => a[1].lastSeen - b[1].lastSeen);
|
|
|
|
const toRemove = sorted.slice(0, Math.floor(MAX_ENTITIES * 0.2));
|
|
for (const [entityId] of toRemove) {
|
|
this.entities.delete(entityId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild all indices from current links
|
|
*/
|
|
private rebuildIndices(): void {
|
|
this.workerIndex.clear();
|
|
this.fileIndex.clear();
|
|
this.beadIndex.clear();
|
|
|
|
for (const [linkId, link] of this.links) {
|
|
this.addToIndex(link.sourceType, link.sourceId, linkId);
|
|
this.addToIndex(link.targetType, link.targetId, linkId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all cross-references
|
|
*/
|
|
clear(): void {
|
|
this.links.clear();
|
|
this.entities.clear();
|
|
this.workerIndex.clear();
|
|
this.fileIndex.clear();
|
|
this.beadIndex.clear();
|
|
}
|
|
|
|
/**
|
|
* Get entity by type and ID
|
|
*/
|
|
getEntity(type: CrossReferenceEntityType, id: string): CrossReferenceEntity | undefined {
|
|
const internal = this.entities.get(generateEntityId(type, id));
|
|
return internal ? this.toCrossReferenceEntity(internal) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Get link by ID
|
|
*/
|
|
getLink(linkId: string): CrossReferenceLink | undefined {
|
|
return this.links.get(linkId);
|
|
}
|
|
|
|
/**
|
|
* Get all entities
|
|
*/
|
|
getAllEntities(): CrossReferenceEntity[] {
|
|
return Array.from(this.entities.values()).map(e => this.toCrossReferenceEntity(e));
|
|
}
|
|
|
|
/**
|
|
* Get all links
|
|
*/
|
|
getAllLinks(): CrossReferenceLink[] {
|
|
return Array.from(this.links.values());
|
|
}
|
|
}
|
|
|
|
let globalManager: CrossReferenceManager | undefined;
|
|
|
|
export function getCrossReferenceManager(): CrossReferenceManager {
|
|
if (!globalManager) {
|
|
globalManager = new CrossReferenceManager();
|
|
}
|
|
return globalManager;
|
|
}
|
|
|
|
export function resetCrossReferenceManager(): void {
|
|
globalManager = undefined;
|
|
}
|
|
|
|
export default CrossReferenceManager;
|