Files
waggle-os/packages/agent/src/cognify.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

263 lines
9.6 KiB
TypeScript

import type {
FrameStore,
SessionStore,
KnowledgeGraph,
HybridSearch,
Importance,
FrameSource,
} from '@waggle/core';
import { createCoreLogger } from '@waggle/core';
import { extractEntities, extractRelations, type ExtractedEntity } from './entity-extractor.js';
import { MemoryLinker, type MemoryLink } from './memory-linker.js';
import { logTurnEvent } from './turn-context.js';
const log = createCoreLogger('cognify');
export interface CognifyConfig {
frames: FrameStore;
sessions: SessionStore;
knowledge: KnowledgeGraph;
search: HybridSearch;
enableLinking?: boolean;
}
export interface CognifyResult {
frameId: number;
entitiesExtracted: number;
relationsCreated: number;
relatedFrames?: MemoryLink[];
}
export class CognifyPipeline {
private frames: FrameStore;
private sessions: SessionStore;
private knowledge: KnowledgeGraph;
private search: HybridSearch;
private linker?: MemoryLinker;
constructor(config: CognifyConfig) {
this.frames = config.frames;
this.sessions = config.sessions;
this.knowledge = config.knowledge;
this.search = config.search;
if (config.enableLinking) {
this.linker = new MemoryLinker({ search: config.search });
}
}
/**
* Full cognify pipeline: save frame -> extract entities -> enrich graph -> index for search.
* H-AUDIT-1: optional turnId threads trace propagation through entity + relation extraction.
*/
async cognify(
content: string,
importance: Importance = 'normal',
gopId?: string,
turnId?: string,
/** Provenance class for the written frame (PR3.5 honesty: agent-extracted
* memories must NOT inherit the schema default 'user_stated'). Omit to let
* FrameStore's default apply (back-compat for callers that don't classify). */
source?: FrameSource,
): Promise<CognifyResult> {
logTurnEvent(turnId, { stage: 'cognify.enter', contentChars: content.length, importance, gopId });
// 1. Ensure a session exists
const resolvedGopId = gopId ?? this.ensureSession();
// 2. Save a frame (I-frame if none exists, P-frame otherwise)
const latestI = this.frames.getLatestIFrame(resolvedGopId);
const frame = latestI
? this.frames.createPFrame(resolvedGopId, content, latestI.id, importance, source)
: this.frames.createIFrame(resolvedGopId, content, importance, source);
// 3. Extract entities from content (guard against very long content)
const maxContentLength = 10_000;
const trimmedContent = content.slice(0, maxContentLength);
const extracted = extractEntities(trimmedContent);
// 4. Upsert entities into KnowledgeGraph
const entityIds = this.upsertEntities(extracted, frame.id);
// 5. Create co-occurrence relations between entities found in same text
let relationsCreated = this.createCoOccurrenceRelations(entityIds);
// 5b. Extract semantic relations (led_by, reports_to, depends_on, etc.)
relationsCreated += this.createSemanticRelations(content, extracted);
// 6. Index the frame for vector search
await this.search.indexFrame(frame.id, content);
// 7. Find related frames if linking is enabled
let relatedFrames: MemoryLink[] | undefined;
if (this.linker) {
relatedFrames = await this.linker.findRelated(content);
relatedFrames = relatedFrames.filter(r => r.frameId !== frame.id);
}
const result = {
frameId: frame.id,
entitiesExtracted: entityIds.length,
relationsCreated,
relatedFrames,
};
logTurnEvent(turnId, { stage: 'cognify.exit', frameId: frame.id, entitiesExtracted: entityIds.length, relationsCreated });
return result;
}
/**
* Cognify an existing frame — extract entities, build relations, re-index.
* Used for post-harvest processing of imported frames.
* H-AUDIT-1: optional turnId for trace propagation.
*/
async cognifyFrame(frameId: number, turnId?: string): Promise<CognifyResult | null> {
logTurnEvent(turnId, { stage: 'cognify.frame.enter', frameId });
const frame = this.frames.getById(frameId);
if (!frame) return null;
const maxContentLength = 10_000;
const content = frame.content.slice(0, maxContentLength);
const extracted = extractEntities(content);
const entityIds = this.upsertEntities(extracted, frame.id);
let relationsCreated = this.createCoOccurrenceRelations(entityIds);
relationsCreated += this.createSemanticRelations(content, extracted);
// Re-index for search
try {
await this.search.indexFrame(frame.id, frame.content);
} catch (err) {
log.warn(`indexFrame failed for frame ${frame.id}`, err);
}
const frameResult = {
frameId: frame.id,
entitiesExtracted: entityIds.length,
relationsCreated,
};
logTurnEvent(turnId, { stage: 'cognify.frame.exit', frameId: frame.id, entitiesExtracted: entityIds.length, relationsCreated });
return frameResult;
}
/**
* Cognify a batch of existing frames. Returns summary stats.
* H-AUDIT-1: optional turnId propagates into per-frame cognify calls.
*/
async cognifyBatch(frameIds: number[], turnId?: string): Promise<{ processed: number; entities: number; relations: number }> {
logTurnEvent(turnId, { stage: 'cognify.batch.enter', frameCount: frameIds.length });
let processed = 0;
let entities = 0;
let relations = 0;
// Sequential: each frame's cognify may produce entities used by the next frame's relation linking
for (const id of frameIds) {
const result = await this.cognifyFrame(id, turnId);
if (result) {
processed++;
entities += result.entitiesExtracted;
relations += result.relationsCreated;
}
}
logTurnEvent(turnId, { stage: 'cognify.batch.exit', processed, entities, relations });
return { processed, entities, relations };
}
private ensureSession(): string {
// Review (cognify Major #1): use the transaction-wrapped SessionStore.ensureActive()
// method. The previous getActive() + create() sequence was racy — two concurrent
// callers on a fresh mind both saw no active session and both created one, splitting
// frames across twin sessions. Same fix pattern as autoSaveFromExchange (commit b8ffe8e).
return this.sessions.ensureActive().gop_id;
}
/**
* Upsert entities: if an entity with the same type+name exists, skip it;
* otherwise create it. Returns the entity IDs (existing or new).
*/
private upsertEntities(extracted: ExtractedEntity[], frameId?: number): number[] {
const ids: number[] = [];
// Pre-fetch entities by type to avoid N queries in the loop
const typeCache = new Map<string, { id: number; name: string }[]>();
const types = new Set(extracted.map(e => e.type));
for (const type of types) {
typeCache.set(type, this.knowledge.getEntitiesByType(type).map(ent => ({
id: ent.id,
name: ent.name.toLowerCase(),
})));
}
for (const e of extracted) {
const cached = typeCache.get(e.type) ?? [];
const nameLower = e.name.toLowerCase();
const existing = cached.find(ent => ent.name === nameLower);
if (existing) {
ids.push(existing.id);
} else {
const created = this.knowledge.createEntity(e.type, e.name, {
confidence: e.confidence,
source: 'cognify',
});
ids.push(created.id);
// Add to cache so subsequent dupes in this batch are caught
cached.push({ id: created.id, name: nameLower });
}
}
// Link every extracted entity to its frame (kg_entity_frames bridge — contextual scoring).
if (frameId !== undefined) {
for (const id of ids) this.knowledge.linkEntityToFrame(id, frameId);
}
return ids;
}
/**
* Create co-occurrence relations between all pairs of entities found
* in the same text. Uses "co_occurs_with" relation type.
* Returns the number of new relations created.
*/
private createCoOccurrenceRelations(entityIds: number[]): number {
let count = 0;
for (let i = 0; i < entityIds.length; i++) {
for (let j = i + 1; j < entityIds.length; j++) {
const sourceId = entityIds[i];
const targetId = entityIds[j];
// Check if relation already exists
const existingRels = this.knowledge.getRelationsFrom(sourceId, 'co_occurs_with');
const alreadyExists = existingRels.some(r => r.target_id === targetId);
if (!alreadyExists) {
this.knowledge.createRelation(sourceId, targetId, 'co_occurs_with', 0.8, {
source: 'cognify',
});
count++;
}
}
}
return count;
}
/**
* Extract semantic relations (led_by, reports_to, depends_on, etc.)
* from content and upsert them into the knowledge graph.
* Returns the number of new relations created.
*/
private createSemanticRelations(content: string, extracted: ExtractedEntity[]): number {
let count = 0;
const relations = extractRelations(content, extracted);
for (const rel of relations) {
try {
const srcEntity = this.knowledge.searchEntities(rel.source, 5)
.find(e => e.name.toLowerCase() === rel.source.toLowerCase());
const tgtEntity = this.knowledge.searchEntities(rel.target, 5)
.find(e => e.name.toLowerCase() === rel.target.toLowerCase());
if (srcEntity && tgtEntity) {
const existing = this.knowledge.getRelationsFrom(srcEntity.id, rel.relationType);
if (!existing.some(r => r.target_id === tgtEntity.id)) {
this.knowledge.createRelation(srcEntity.id, tgtEntity.id, rel.relationType, rel.confidence, { source: 'semantic' });
count++;
}
}
} catch (err) {
log.warn('semantic relation extraction failed', err);
}
}
return count;
}
}