import type { ISdk } from "iii-sdk"; import type { GraphNode, GraphEdge, GraphEdgeType, EdgeContext, TemporalState, MemoryProvider, } from "../types.js"; import { KV, generateId } from "../state/schema.js"; import type { StateKV } from "../state/kv.js"; import { logger } from "../logger.js"; const TEMPORAL_EXTRACTION_SYSTEM = `You are a temporal knowledge extraction engine. Given observations, extract entities AND their temporal relationships with full context metadata. For each relationship, you MUST provide: 1. Semantic relation type 2. Temporal validity (when this fact became true in the real world) 3. Context metadata: WHY this relationship exists, what reasoning led to it, what alternatives were considered Output EXACTLY this XML: value alternate name WHY this relationship exists positive|negative|neutral alternative that was considered Rules: - NEVER overwrite existing relationships — always create new versioned edges - Extract temporal validity from context clues ("since last month", "in 2024", "currently") - Capture reasoning/motivation behind each relationship - Weight relationships by directness: 1.0 = explicit statement, 0.5 = inferred, 0.1 = speculative`; function parseTemporalGraphXml( xml: string, observationIds: string[], ): { nodes: GraphNode[]; edges: GraphEdge[] } { const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; const now = new Date().toISOString(); const entityRegex = /]*>([\s\S]*?)<\/entity>/g; let match; while ((match = entityRegex.exec(xml)) !== null) { const type = match[1] as GraphNode["type"]; const name = match[2]; const propsBlock = match[3]; const properties: Record = {}; const aliases: string[] = []; const propRegex = /([^<]*)<\/property>/g; let propMatch; while ((propMatch = propRegex.exec(propsBlock)) !== null) { properties[propMatch[1]] = propMatch[2]; } const aliasRegex = /([^<]+)<\/alias>/g; while ((propMatch = aliasRegex.exec(propsBlock)) !== null) { aliases.push(propMatch[1]); } nodes.push({ id: generateId("gn"), type, name, properties, sourceObservationIds: observationIds, createdAt: now, aliases: aliases.length > 0 ? aliases : undefined, }); } const relRegex = /]*>([\s\S]*?)<\/relationship>/g; while ((match = relRegex.exec(xml)) !== null) { const type = match[1] as GraphEdgeType; const sourceName = match[2]; const targetName = match[3]; const parsedWeight = parseFloat(match[4]); const weight = Number.isNaN(parsedWeight) ? 0.5 : parsedWeight; const validFrom = match[5] || undefined; const validTo = match[6] || undefined; const metaBlock = match[7] || ""; const sourceNode = nodes.find( (n) => n.name === sourceName || (n.aliases && n.aliases.includes(sourceName)), ); const targetNode = nodes.find( (n) => n.name === targetName || (n.aliases && n.aliases.includes(targetName)), ); if (sourceNode && targetNode) { const reasoning = metaBlock.match(/([^<]*)<\/reasoning>/)?.[1] || undefined; const sentiment = metaBlock.match(/([^<]*)<\/sentiment>/)?.[1] || undefined; const alternatives: string[] = []; const altRegex = /([^<]+)<\/alt>/g; let altMatch; while ((altMatch = altRegex.exec(metaBlock)) !== null) { alternatives.push(altMatch[1]); } const context: EdgeContext = {}; if (reasoning) context.reasoning = reasoning; if (sentiment) context.sentiment = sentiment; if (alternatives.length > 0) context.alternatives = alternatives; context.confidence = Math.max(0, Math.min(1, weight)); edges.push({ id: generateId("ge"), type, sourceNodeId: sourceNode.id, targetNodeId: targetNode.id, weight: Math.max(0, Math.min(1, weight)), sourceObservationIds: observationIds, createdAt: now, tcommit: now, tvalid: validFrom && validFrom !== "unknown" ? validFrom : undefined, tvalidEnd: validTo && validTo !== "current" ? validTo : undefined, context: Object.keys(context).length > 0 ? context : undefined, version: 1, isLatest: true, }); } } return { nodes, edges }; } export function registerTemporalGraphFunctions( sdk: ISdk, kv: StateKV, provider: MemoryProvider, ): void { sdk.registerFunction("mem::temporal-graph-extract", async (data: { observations: Array<{ id: string; title: string; narrative: string; concepts: string[]; files: string[]; type: string; timestamp: string; }>; }) => { if (!data.observations || data.observations.length === 0) { return { success: false, error: "No observations provided" }; } const items = data.observations .map( (o, i) => `[${i + 1}] Type: ${o.type}\nTimestamp: ${o.timestamp}\nTitle: ${o.title}\nNarrative: ${o.narrative}\nConcepts: ${(o.concepts ?? []).join(", ")}\nFiles: ${(o.files ?? []).join(", ")}`, ) .join("\n\n"); try { const response = await provider.compress( TEMPORAL_EXTRACTION_SYSTEM, `Extract temporal knowledge graph from:\n\n${items}`, ); const obsIds = data.observations.map((o) => o.id); const { nodes, edges } = parseTemporalGraphXml(response, obsIds); const existingNodes = await kv.list(KV.graphNodes); const existingEdges = await kv.list(KV.graphEdges); const idRemap = new Map(); for (const node of nodes) { const existing = existingNodes.find( (n) => n.name === node.name && n.type === node.type, ); if (existing) { const oldId = node.id; const merged = { ...existing, sourceObservationIds: [ ...new Set([ ...existing.sourceObservationIds, ...obsIds, ]), ], properties: { ...existing.properties, ...node.properties }, updatedAt: new Date().toISOString(), aliases: [ ...new Set([ ...(existing.aliases || []), ...(node.aliases || []), ]), ], }; if (merged.aliases.length === 0) delete (merged as any).aliases; await kv.set(KV.graphNodes, existing.id, merged); node.id = existing.id; idRemap.set(oldId, existing.id); } else { await kv.set(KV.graphNodes, node.id, node); existingNodes.push(node); } } for (const edge of edges) { if (idRemap.has(edge.sourceNodeId)) { edge.sourceNodeId = idRemap.get(edge.sourceNodeId)!; } if (idRemap.has(edge.targetNodeId)) { edge.targetNodeId = idRemap.get(edge.targetNodeId)!; } const existingKey = `${edge.sourceNodeId}|${edge.targetNodeId}|${edge.type}`; const existingEdge = existingEdges.find( (e) => `${e.sourceNodeId}|${e.targetNodeId}|${e.type}` === existingKey, ); if (existingEdge) { const updatedOld = { ...existingEdge, isLatest: false, tvalidEnd: existingEdge.tvalidEnd || new Date().toISOString(), supersededBy: edge.id, }; await kv.set(KV.graphEdges, existingEdge.id, updatedOld); await kv.set(KV.graphEdgeHistory, existingEdge.id, updatedOld); edge.version = (existingEdge.version || 1) + 1; } await kv.set(KV.graphEdges, edge.id, edge); existingEdges.push(edge); } logger.info("Temporal graph extraction complete", { nodes: nodes.length, edges: edges.length, }); return { success: true, nodesAdded: nodes.length, edgesAdded: edges.length, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.error("Temporal graph extraction failed", { error: msg }); return { success: false, error: msg }; } }, ); sdk.registerFunction("mem::temporal-query", async (data: { entityName: string; asOf?: string; includeHistory?: boolean; }): Promise => { const allNodes = await kv.list(KV.graphNodes); const allEdges = await kv.list(KV.graphEdges); const entity = allNodes.find( (n) => n.name.toLowerCase() === data.entityName.toLowerCase() || (n.aliases && n.aliases.some( (a) => a.toLowerCase() === data.entityName.toLowerCase(), )), ); if (!entity) { return { error: `Entity "${data.entityName}" not found` } as any; } const relatedEdges = allEdges.filter( (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, ); const historicalEdges = await kv .list(KV.graphEdgeHistory) .catch(() => [] as GraphEdge[]); const entityHistory = historicalEdges.filter( (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, ); const allEntityEdges = [...relatedEdges, ...entityHistory]; if (data.asOf) { const asOfTime = new Date(data.asOf).getTime(); const validEdges = allEntityEdges.filter((e) => { const commitTime = new Date( e.tcommit || e.createdAt, ).getTime(); if (commitTime > asOfTime) return false; if (e.tvalid) { const validTime = new Date(e.tvalid).getTime(); if (validTime > asOfTime) return false; } if (e.tvalidEnd) { const endTime = new Date(e.tvalidEnd).getTime(); if (endTime < asOfTime) return false; } return true; }); const currentEdges = getLatestByKey(validEdges); const historical = data.includeHistory ? validEdges : []; return { entity, currentEdges, historicalEdges: historical, timeline: buildTimeline(allEntityEdges), }; } const currentEdges = relatedEdges.filter( (e) => e.isLatest !== false, ); return { entity, currentEdges, historicalEdges: data.includeHistory ? entityHistory : [], timeline: buildTimeline(allEntityEdges), }; }, ); sdk.registerFunction("mem::differential-state", async (data: { entityName: string; from?: string; to?: string; }) => { const allNodes = await kv.list(KV.graphNodes); const allEdges = await kv.list(KV.graphEdges); const historicalEdges = await kv .list(KV.graphEdgeHistory) .catch(() => [] as GraphEdge[]); const entity = allNodes.find( (n) => n.name.toLowerCase() === data.entityName.toLowerCase(), ); if (!entity) return { error: "Entity not found" }; const allEntityEdges = [ ...allEdges.filter( (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, ), ...historicalEdges.filter( (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, ), ]; allEntityEdges.sort( (a, b) => new Date(a.tcommit || a.createdAt).getTime() - new Date(b.tcommit || b.createdAt).getTime(), ); const fromTime = data.from ? new Date(data.from).getTime() : 0; const toTime = data.to ? new Date(data.to).getTime() : Date.now(); const filtered = allEntityEdges.filter((e) => { const t = new Date(e.tcommit || e.createdAt).getTime(); return t >= fromTime && t <= toTime; }); const changes = filtered.map((e) => ({ type: e.type, target: e.sourceNodeId === entity.id ? e.targetNodeId : e.sourceNodeId, validFrom: e.tvalid || e.createdAt, validTo: e.tvalidEnd, reasoning: e.context?.reasoning, sentiment: e.context?.sentiment, version: e.version || 1, isLatest: e.isLatest !== false, })); return { entity: entity.name, totalChanges: changes.length, changes, }; }, ); } function getLatestByKey(edges: GraphEdge[]): GraphEdge[] { const byKey = new Map(); for (const e of edges) { const key = `${e.sourceNodeId}|${e.targetNodeId}|${e.type}`; const existing = byKey.get(key); if ( !existing || new Date(e.tcommit || e.createdAt).getTime() > new Date(existing.tcommit || existing.createdAt).getTime() ) { byKey.set(key, e); } } return Array.from(byKey.values()); } function buildTimeline( edges: GraphEdge[], ): Array<{ edge: GraphEdge; validFrom: string; validTo?: string; context?: EdgeContext; }> { const sorted = [...edges].sort( (a, b) => new Date(a.tcommit || a.createdAt).getTime() - new Date(b.tcommit || b.createdAt).getTime(), ); return sorted.map((e) => ({ edge: e, validFrom: e.tvalid || e.createdAt, validTo: e.tvalidEnd, context: e.context, })); }