@zergai/ztc 0.2.2-dev.35680553693 → 0.2.3-dev.35760740177

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.
@@ -1,1305 +0,0 @@
1
- /**
2
- * SimGraph — FalkorDB Graph Backbone for Simulation
3
- *
4
- * Property graph storing entities, temporal facts, relationships,
5
- * feed posts, and events. Replaces flat in-memory stores with
6
- * structured, queryable graph data.
7
- *
8
- * Inspired by MiroFish/Graphiti's temporal knowledge graph but
9
- * implemented natively in TypeScript with FalkorDBLite (embedded,
10
- * no Docker, no Python).
11
- */
12
-
13
- // falkordblite (embedded graph DB + native binding) is imported lazily in
14
- // SimGraph.open() so that merely importing this module stays cheap.
15
- import type { FalkorDB, Graph } from 'falkordblite';
16
-
17
- // ── Types ─────────────────────────────────────────────────────────
18
-
19
- /**
20
- * Scalar + nested shape accepted by FalkorDB parameter binding. Mirrors
21
- * the internal `QueryParam` type from the `falkordb` package (which is
22
- * not exported) so consumers of `rawQuery` get the same strictness.
23
- */
24
- export type QueryParamValue =
25
- | null
26
- | string
27
- | number
28
- | boolean
29
- | QueryParams
30
- | QueryParamValue[];
31
- export interface QueryParams {
32
- [key: string]: QueryParamValue;
33
- }
34
-
35
- export interface GraphFact {
36
- id: string;
37
- content: string;
38
- category: string;
39
- sourceTick: number;
40
- validFrom: number;
41
- invalidAt: number | null;
42
- }
43
-
44
- export interface GraphRelationship {
45
- agentIdA: string;
46
- agentIdB: string;
47
- nameA: string;
48
- nameB: string;
49
- sentiment: number;
50
- trust: number;
51
- interactionCount: number;
52
- lastNote: string | null;
53
- }
54
-
55
- export interface GraphFeedPost {
56
- id: string;
57
- content: string;
58
- authorId: string;
59
- authorName: string;
60
- authorRole: string;
61
- source: string;
62
- createdAt: number;
63
- replyTo: string | null;
64
- reactionCount: number;
65
- topicTags: string[];
66
- }
67
-
68
- export interface GraphContext {
69
- facts: GraphFact[];
70
- agents: Array<{ id: string; name: string; role: string }>;
71
- relationships: GraphRelationship[];
72
- }
73
-
74
- export interface WorldView {
75
- ownFacts: GraphFact[];
76
- peerFacts: Array<{ peerName: string; peerRole: string; facts: GraphFact[] }>;
77
- topicFacts: GraphFact[];
78
- }
79
-
80
- export interface SimulationGraphFact {
81
- id: string;
82
- subjectId: string;
83
- predicate: string;
84
- objectId: string;
85
- content: string;
86
- validFrom: number;
87
- invalidAt: number | null;
88
- sourceEventId: string;
89
- data: Record<string, unknown>;
90
- }
91
-
92
- export interface SimulationGraphFactInput {
93
- id: string;
94
- subjectId: string;
95
- predicate: string;
96
- objectId?: string;
97
- content: string;
98
- validFrom: number;
99
- invalidAt?: number | null;
100
- sourceEventId?: string;
101
- supersedesIds?: string[];
102
- data?: Record<string, unknown>;
103
- }
104
-
105
- export interface SimulationGraphEdge {
106
- sourceId: string;
107
- targetId: string;
108
- type: string;
109
- tick: number;
110
- data: Record<string, unknown>;
111
- }
112
-
113
- export interface SimulationFactGraph {
114
- schemaVersion: 1;
115
- facts: SimulationGraphFact[];
116
- edges: SimulationGraphEdge[];
117
- }
118
-
119
- export interface RecordSimulationFactInput {
120
- graph?: SimulationFactGraph;
121
- fact: SimulationGraphFactInput;
122
- }
123
-
124
- export interface ProjectActiveFactsInput {
125
- graph: SimulationFactGraph;
126
- atTick: number;
127
- subjectIds?: string[];
128
- predicates?: string[];
129
- }
130
-
131
- export interface SimulationNeighborhoodInput {
132
- graph: SimulationFactGraph;
133
- startIds: string[];
134
- maxDepth?: number;
135
- edgeTypes?: string[];
136
- }
137
-
138
- export interface SimulationNeighborhood {
139
- schemaVersion: 1;
140
- factIds: string[];
141
- facts: SimulationGraphFact[];
142
- edges: SimulationGraphEdge[];
143
- depthByFactId: Record<string, number>;
144
- }
145
-
146
- export type SimulationFocusGraphDirection = 'in' | 'out' | 'both';
147
-
148
- export interface SimulationFocusGraphNode {
149
- id: string;
150
- kind?: string;
151
- label?: string;
152
- data?: Record<string, unknown>;
153
- }
154
-
155
- export interface SimulationFocusGraphEdge {
156
- sourceId: string;
157
- targetId: string;
158
- type: string;
159
- createdAt?: string;
160
- data: Record<string, unknown>;
161
- }
162
-
163
- export interface SimulationFocusRelationSpec {
164
- name: string;
165
- edgeTypes: string[];
166
- direction?: SimulationFocusGraphDirection;
167
- limit?: number;
168
- }
169
-
170
- export interface SimulationFocusLineageSpec {
171
- edgeTypes: string[];
172
- direction?: Exclude<SimulationFocusGraphDirection, 'both'>;
173
- includeFocus?: boolean;
174
- maxDepth?: number;
175
- }
176
-
177
- export interface BuildSimulationFocusGraphContextInput {
178
- nodes?: SimulationFocusGraphNode[];
179
- edges?: SimulationFocusGraphEdge[];
180
- focusIds?: string[];
181
- relations?: SimulationFocusRelationSpec[];
182
- lineage?: SimulationFocusLineageSpec;
183
- maxFocusIds?: number;
184
- }
185
-
186
- export interface SimulationFocusLineage {
187
- focusId: string;
188
- nodeIds: string[];
189
- edgeTypes: string[];
190
- edges: SimulationFocusGraphEdge[];
191
- }
192
-
193
- export interface SimulationFocusRelationGroup {
194
- name: string;
195
- focusId: string;
196
- nodeIds: string[];
197
- edges: SimulationFocusGraphEdge[];
198
- }
199
-
200
- export interface SimulationFocusGraphContext {
201
- version: 1;
202
- focusIds: string[];
203
- lineages: SimulationFocusLineage[];
204
- relationGroups: SimulationFocusRelationGroup[];
205
- }
206
-
207
- export interface SimulationSteeringEvent {
208
- id: string;
209
- eventType?: string;
210
- acceptedSignalKinds?: string[];
211
- subjectId?: string;
212
- verdict?: string;
213
- selectionStatus?: string;
214
- createdAt?: string;
215
- data?: Record<string, unknown>;
216
- }
217
-
218
- export interface SimulationSteeringSignal {
219
- kind?: string;
220
- subjectId?: string;
221
- verdict?: string;
222
- selectionStatus?: string;
223
- priority?: number;
224
- data?: Record<string, unknown>;
225
- }
226
-
227
- export interface SimulationSteeringTask {
228
- id: string;
229
- taskType?: string;
230
- source?: string;
231
- createdAt?: string;
232
- signals?: SimulationSteeringSignal[];
233
- }
234
-
235
- export interface SimulationSteeringOutput {
236
- id: string;
237
- taskId: string;
238
- outputType?: string;
239
- createdAt?: string;
240
- data?: Record<string, unknown>;
241
- }
242
-
243
- export interface SimulationSteeringInfluenceEdge {
244
- sourceId: string;
245
- targetId: string;
246
- type: 'steering_influenced_task' | 'steering_influenced_output';
247
- createdAt: string;
248
- data: Record<string, unknown>;
249
- }
250
-
251
- export interface BuildSimulationSteeringInfluenceGraphInput {
252
- steeringEvents?: SimulationSteeringEvent[];
253
- tasks?: SimulationSteeringTask[];
254
- outputs?: SimulationSteeringOutput[];
255
- ignoreVerdictKinds?: string[];
256
- }
257
-
258
- export interface SimulationSteeringInfluenceGraph {
259
- version: 1;
260
- taskEdges: SimulationSteeringInfluenceEdge[];
261
- outputEdges: SimulationSteeringInfluenceEdge[];
262
- }
263
-
264
- export interface SimulationRestartMemoryGraphStats {
265
- nodes: number;
266
- edges: number;
267
- activeFactCount: number;
268
- }
269
-
270
- export interface SimulationRestartMemoryPacket<TFact = Record<string, unknown>> {
271
- version: 1;
272
- runId: string;
273
- focusIds: string[];
274
- activeFacts: TFact[];
275
- graphStats: SimulationRestartMemoryGraphStats;
276
- }
277
-
278
- export interface BuildSimulationRestartMemoryInput<TFact = Record<string, unknown>> {
279
- runId?: string;
280
- focusIds?: string[];
281
- activeFacts?: TFact[];
282
- graphStats?: Partial<SimulationRestartMemoryGraphStats>;
283
- maxFocusIds?: number;
284
- }
285
-
286
- export function recordSimulationFact(input: RecordSimulationFactInput): SimulationFactGraph {
287
- const graph = normalizeSimulationFactGraph(input.graph);
288
- const fact = normalizeSimulationGraphFact(input.fact);
289
- const existingFacts = graph.facts.filter((item) => item.id !== fact.id);
290
- const supersedesEdges = uniqueText(input.fact.supersedesIds ?? [])
291
- .filter((targetId) => targetId !== fact.id)
292
- .map((targetId) => ({
293
- sourceId: fact.id,
294
- targetId,
295
- type: 'supersedes',
296
- tick: fact.validFrom,
297
- data: {
298
- sourceEventId: fact.sourceEventId,
299
- },
300
- }));
301
-
302
- return {
303
- schemaVersion: 1,
304
- facts: [...existingFacts, fact],
305
- edges: dedupeSimulationEdges([...graph.edges, ...supersedesEdges]),
306
- };
307
- }
308
-
309
- export function projectActiveFacts(input: ProjectActiveFactsInput): SimulationGraphFact[] {
310
- const graph = normalizeSimulationFactGraph(input.graph);
311
- const atTick = finiteNumber(input.atTick, Number.POSITIVE_INFINITY);
312
- const subjectIds = new Set(uniqueText(input.subjectIds ?? []));
313
- const predicates = new Set(uniqueText(input.predicates ?? []));
314
- const supersededIds = new Set(
315
- graph.edges
316
- .filter((edge) => edge.type === 'supersedes' && edge.tick <= atTick)
317
- .map((edge) => edge.targetId),
318
- );
319
-
320
- return graph.facts
321
- .filter((fact) => fact.validFrom <= atTick)
322
- .filter((fact) => fact.invalidAt === null || fact.invalidAt > atTick)
323
- .filter((fact) => !supersededIds.has(fact.id))
324
- .filter((fact) => subjectIds.size === 0 || subjectIds.has(fact.subjectId))
325
- .filter((fact) => predicates.size === 0 || predicates.has(fact.predicate))
326
- .sort((a, b) => {
327
- if (a.validFrom !== b.validFrom) return a.validFrom - b.validFrom;
328
- return a.id.localeCompare(b.id);
329
- });
330
- }
331
-
332
- export function traverseSimulationNeighborhood(
333
- input: SimulationNeighborhoodInput,
334
- ): SimulationNeighborhood {
335
- const graph = normalizeSimulationFactGraph(input.graph);
336
- const factById = new Map(graph.facts.map((fact) => [fact.id, fact]));
337
- const maxDepth = Math.max(0, Math.floor(finiteNumber(input.maxDepth, 1)));
338
- const allowedEdgeTypes = new Set(uniqueText(input.edgeTypes ?? []));
339
- const eligibleEdges = graph.edges.filter((edge) => (
340
- allowedEdgeTypes.size === 0 || allowedEdgeTypes.has(edge.type)
341
- ));
342
- const depthByFactId: Record<string, number> = {};
343
- const factIds: string[] = [];
344
- const selectedEdges: SimulationGraphEdge[] = [];
345
- const queue: Array<{ id: string; depth: number }> = [];
346
-
347
- for (const id of uniqueText(input.startIds)) {
348
- if (!factById.has(id) || depthByFactId[id] !== undefined) continue;
349
- depthByFactId[id] = 0;
350
- factIds.push(id);
351
- queue.push({ id, depth: 0 });
352
- }
353
-
354
- while (queue.length > 0) {
355
- const current = queue.shift()!;
356
- if (current.depth >= maxDepth) continue;
357
- for (const edge of eligibleEdges) {
358
- const neighborId = edge.sourceId === current.id
359
- ? edge.targetId
360
- : edge.targetId === current.id
361
- ? edge.sourceId
362
- : '';
363
- if (!neighborId || !factById.has(neighborId)) continue;
364
- addUniqueSimulationEdge(selectedEdges, edge);
365
- if (depthByFactId[neighborId] !== undefined) continue;
366
- depthByFactId[neighborId] = current.depth + 1;
367
- factIds.push(neighborId);
368
- queue.push({ id: neighborId, depth: current.depth + 1 });
369
- }
370
- }
371
-
372
- return {
373
- schemaVersion: 1,
374
- factIds,
375
- facts: factIds.map((id) => factById.get(id)).filter((fact): fact is SimulationGraphFact => fact !== undefined),
376
- edges: selectedEdges.filter((edge) => (
377
- depthByFactId[edge.sourceId] !== undefined &&
378
- depthByFactId[edge.targetId] !== undefined
379
- )),
380
- depthByFactId,
381
- };
382
- }
383
-
384
- export function buildSimulationFocusGraphContext(
385
- input: BuildSimulationFocusGraphContextInput,
386
- ): SimulationFocusGraphContext {
387
- const focusIds = uniqueText(input.focusIds ?? [])
388
- .slice(0, Math.max(0, Math.floor(finiteNumber(input.maxFocusIds, 20))));
389
- const nodes = new Map(
390
- (input.nodes ?? [])
391
- .map((node) => normalizeFocusGraphNode(node))
392
- .filter((node) => node.id)
393
- .map((node) => [node.id, node]),
394
- );
395
- const edges = (input.edges ?? [])
396
- .map((edge) => normalizeFocusGraphEdge(edge))
397
- .filter((edge) => edge.sourceId && edge.targetId && edge.type);
398
-
399
- return {
400
- version: 1,
401
- focusIds,
402
- lineages: input.lineage
403
- ? focusIds.map((focusId) => buildSimulationFocusLineage(focusId, nodes, edges, input.lineage!))
404
- : [],
405
- relationGroups: (input.relations ?? []).flatMap((relation) => (
406
- focusIds.map((focusId) => buildSimulationFocusRelationGroup(focusId, edges, relation))
407
- )),
408
- };
409
- }
410
-
411
- export function buildSimulationSteeringInfluenceGraph(
412
- input: BuildSimulationSteeringInfluenceGraphInput,
413
- ): SimulationSteeringInfluenceGraph {
414
- const events = (input.steeringEvents ?? [])
415
- .map((event) => normalizeSteeringEvent(event))
416
- .filter((event) => event.id);
417
- const tasks = (input.tasks ?? [])
418
- .map((task) => normalizeSteeringTask(task))
419
- .filter((task) => task.id);
420
- const outputs = (input.outputs ?? [])
421
- .map((output) => normalizeSteeringOutput(output))
422
- .filter((output) => output.id && output.taskId);
423
- const ignoreVerdictKinds = new Set(uniqueText(input.ignoreVerdictKinds ?? ['hypothesis_submitted']));
424
- const taskEdges: SimulationSteeringInfluenceEdge[] = [];
425
- const outputEdges: SimulationSteeringInfluenceEdge[] = [];
426
-
427
- for (const event of events) {
428
- for (const task of tasks) {
429
- for (const signal of task.signals ?? []) {
430
- if (!steeringSignalMatchesEvent(event, task, signal, ignoreVerdictKinds)) continue;
431
- const taskEdge = steeringTaskEdge(event, task, signal);
432
- addUniqueSteeringInfluenceEdge(taskEdges, taskEdge);
433
- for (const output of outputs.filter((candidate) => candidate.taskId === task.id)) {
434
- addUniqueSteeringInfluenceEdge(outputEdges, steeringOutputEdge(event, task, signal, output));
435
- }
436
- }
437
- }
438
- }
439
-
440
- return {
441
- version: 1,
442
- taskEdges,
443
- outputEdges,
444
- };
445
- }
446
-
447
- export function buildSimulationRestartMemory<TFact = Record<string, unknown>>(
448
- input: BuildSimulationRestartMemoryInput<TFact>,
449
- ): SimulationRestartMemoryPacket<TFact> {
450
- const activeFacts = Array.isArray(input.activeFacts) ? [...input.activeFacts] : [];
451
- const graphStats = input.graphStats ?? {};
452
- const maxFocusIds = Math.max(0, Math.floor(finiteNumber(input.maxFocusIds, 20)));
453
-
454
- return {
455
- version: 1,
456
- runId: textValue(input.runId),
457
- focusIds: uniqueText(input.focusIds ?? []).slice(0, maxFocusIds),
458
- activeFacts,
459
- graphStats: {
460
- nodes: wholeNumber(graphStats.nodes, 0),
461
- edges: wholeNumber(graphStats.edges, 0),
462
- activeFactCount: wholeNumber(graphStats.activeFactCount, activeFacts.length),
463
- },
464
- };
465
- }
466
-
467
- function steeringTaskEdge(
468
- event: SimulationSteeringEvent,
469
- task: SimulationSteeringTask,
470
- signal: SimulationSteeringSignal,
471
- ): SimulationSteeringInfluenceEdge {
472
- return {
473
- sourceId: event.id,
474
- targetId: task.id,
475
- type: 'steering_influenced_task',
476
- createdAt: textValue(task.createdAt),
477
- data: steeringInfluenceData(task, signal),
478
- };
479
- }
480
-
481
- function steeringOutputEdge(
482
- event: SimulationSteeringEvent,
483
- task: SimulationSteeringTask,
484
- signal: SimulationSteeringSignal,
485
- output: SimulationSteeringOutput,
486
- ): SimulationSteeringInfluenceEdge {
487
- const { priority: _priority, ...influenceData } = steeringInfluenceData(task, signal);
488
- return {
489
- sourceId: event.id,
490
- targetId: output.id,
491
- type: 'steering_influenced_output',
492
- createdAt: textValue(output.createdAt),
493
- data: {
494
- ...influenceData,
495
- ...(textValue(output.outputType) ? { outputType: textValue(output.outputType) } : {}),
496
- ...(isRecord(output.data) ? { ...output.data } : {}),
497
- },
498
- };
499
- }
500
-
501
- function steeringInfluenceData(
502
- task: SimulationSteeringTask,
503
- signal: SimulationSteeringSignal,
504
- ): Record<string, unknown> {
505
- return {
506
- taskId: task.id,
507
- taskType: textValue(task.taskType),
508
- source: textValue(task.source),
509
- steeringKind: textValue(signal.kind),
510
- subjectId: textValue(signal.subjectId),
511
- verdict: textValue(signal.verdict),
512
- selectionStatus: textValue(signal.selectionStatus),
513
- priority: finiteNumber(signal.priority, 0),
514
- };
515
- }
516
-
517
- function steeringSignalMatchesEvent(
518
- event: SimulationSteeringEvent,
519
- task: SimulationSteeringTask,
520
- signal: SimulationSteeringSignal,
521
- ignoreVerdictKinds: Set<string>,
522
- ): boolean {
523
- const eventTime = Date.parse(textValue(event.createdAt));
524
- const taskTime = Date.parse(textValue(task.createdAt));
525
- if (Number.isFinite(eventTime) && Number.isFinite(taskTime) && taskTime < eventTime) return false;
526
-
527
- const signalKind = textValue(signal.kind);
528
- const acceptedKinds = new Set(uniqueText(event.acceptedSignalKinds ?? []));
529
- if (acceptedKinds.size > 0 && !acceptedKinds.has(signalKind)) return false;
530
- if (!matchingTextWhenBothPresent(event.subjectId, signal.subjectId)) return false;
531
- if (!ignoreVerdictKinds.has(signalKind) && !matchingTextWhenBothPresent(event.verdict, signal.verdict)) return false;
532
- if (!matchingTextWhenBothPresent(event.selectionStatus, signal.selectionStatus)) return false;
533
- return true;
534
- }
535
-
536
- function matchingTextWhenBothPresent(left: unknown, right: unknown): boolean {
537
- const leftText = textValue(left);
538
- const rightText = textValue(right);
539
- return !leftText || !rightText || leftText === rightText;
540
- }
541
-
542
- function addUniqueSteeringInfluenceEdge(
543
- edges: SimulationSteeringInfluenceEdge[],
544
- edge: SimulationSteeringInfluenceEdge,
545
- ): void {
546
- const key = `${edge.sourceId}\0${edge.targetId}\0${edge.type}`;
547
- if (edges.some((candidate) => `${candidate.sourceId}\0${candidate.targetId}\0${candidate.type}` === key)) {
548
- return;
549
- }
550
- edges.push(edge);
551
- }
552
-
553
- function normalizeSteeringEvent(event: SimulationSteeringEvent): SimulationSteeringEvent {
554
- return {
555
- id: textValue(event.id),
556
- eventType: textValue(event.eventType),
557
- acceptedSignalKinds: uniqueText(event.acceptedSignalKinds ?? []),
558
- subjectId: textValue(event.subjectId),
559
- verdict: textValue(event.verdict),
560
- selectionStatus: textValue(event.selectionStatus),
561
- createdAt: textValue(event.createdAt),
562
- data: isRecord(event.data) ? { ...event.data } : {},
563
- };
564
- }
565
-
566
- function normalizeSteeringTask(task: SimulationSteeringTask): SimulationSteeringTask {
567
- return {
568
- id: textValue(task.id),
569
- taskType: textValue(task.taskType),
570
- source: textValue(task.source),
571
- createdAt: textValue(task.createdAt),
572
- signals: (task.signals ?? []).map((signal) => normalizeSteeringSignal(signal)),
573
- };
574
- }
575
-
576
- function normalizeSteeringSignal(signal: SimulationSteeringSignal): SimulationSteeringSignal {
577
- return {
578
- kind: textValue(signal.kind),
579
- subjectId: textValue(signal.subjectId),
580
- verdict: textValue(signal.verdict),
581
- selectionStatus: textValue(signal.selectionStatus),
582
- priority: finiteNumber(signal.priority, 0),
583
- data: isRecord(signal.data) ? { ...signal.data } : {},
584
- };
585
- }
586
-
587
- function normalizeSteeringOutput(output: SimulationSteeringOutput): SimulationSteeringOutput {
588
- return {
589
- id: textValue(output.id),
590
- taskId: textValue(output.taskId),
591
- outputType: textValue(output.outputType),
592
- createdAt: textValue(output.createdAt),
593
- data: isRecord(output.data) ? { ...output.data } : {},
594
- };
595
- }
596
-
597
- function buildSimulationFocusLineage(
598
- focusId: string,
599
- nodes: Map<string, SimulationFocusGraphNode>,
600
- edges: SimulationFocusGraphEdge[],
601
- lineage: SimulationFocusLineageSpec,
602
- ): SimulationFocusLineage {
603
- const edgeTypes = new Set(uniqueText(lineage.edgeTypes));
604
- const direction = lineage.direction ?? 'out';
605
- const maxDepth = Math.max(0, Math.floor(finiteNumber(lineage.maxDepth, 12)));
606
- const pathIds: string[] = [];
607
- const pathEdges: SimulationFocusGraphEdge[] = [];
608
- const visited = new Set<string>();
609
- let currentId = focusId;
610
-
611
- for (let depth = 0; currentId && !visited.has(currentId) && depth <= maxDepth; depth++) {
612
- visited.add(currentId);
613
- if (nodes.has(currentId)) pathIds.push(currentId);
614
- const nextEdge = edges.find((edge) => (
615
- edgeTypes.has(edge.type) &&
616
- (direction === 'out' ? edge.sourceId === currentId : edge.targetId === currentId)
617
- ));
618
- if (!nextEdge) break;
619
- pathEdges.push(nextEdge);
620
- currentId = direction === 'out' ? nextEdge.targetId : nextEdge.sourceId;
621
- }
622
-
623
- const nodeIds = lineage.includeFocus === false
624
- ? pathIds.slice(1).reverse()
625
- : pathIds.reverse();
626
-
627
- return {
628
- focusId,
629
- nodeIds,
630
- edgeTypes: uniqueText(pathEdges.map((edge) => edge.type)),
631
- edges: pathEdges,
632
- };
633
- }
634
-
635
- function buildSimulationFocusRelationGroup(
636
- focusId: string,
637
- edges: SimulationFocusGraphEdge[],
638
- relation: SimulationFocusRelationSpec,
639
- ): SimulationFocusRelationGroup {
640
- const edgeTypes = new Set(uniqueText(relation.edgeTypes));
641
- const direction = relation.direction ?? 'both';
642
- const limit = Math.max(0, Math.floor(finiteNumber(relation.limit, Number.MAX_SAFE_INTEGER)));
643
- const selectedEdges = edges
644
- .filter((edge) => edgeTypes.has(edge.type))
645
- .filter((edge) => focusRelationEdgeMatches(edge, focusId, direction))
646
- .slice(0, limit);
647
-
648
- return {
649
- name: textValue(relation.name),
650
- focusId,
651
- nodeIds: uniqueText(selectedEdges.map((edge) => focusRelationOtherNodeId(edge, focusId, direction))),
652
- edges: selectedEdges,
653
- };
654
- }
655
-
656
- function focusRelationEdgeMatches(
657
- edge: SimulationFocusGraphEdge,
658
- focusId: string,
659
- direction: SimulationFocusGraphDirection,
660
- ): boolean {
661
- if (direction === 'out') return edge.sourceId === focusId;
662
- if (direction === 'in') return edge.targetId === focusId;
663
- return edge.sourceId === focusId || edge.targetId === focusId;
664
- }
665
-
666
- function focusRelationOtherNodeId(
667
- edge: SimulationFocusGraphEdge,
668
- focusId: string,
669
- direction: SimulationFocusGraphDirection,
670
- ): string {
671
- if (direction === 'out') return edge.targetId;
672
- if (direction === 'in') return edge.sourceId;
673
- return edge.sourceId === focusId ? edge.targetId : edge.sourceId;
674
- }
675
-
676
- function normalizeFocusGraphNode(node: SimulationFocusGraphNode): SimulationFocusGraphNode {
677
- return {
678
- id: textValue(node.id),
679
- kind: textValue(node.kind),
680
- label: textValue(node.label),
681
- data: isRecord(node.data) ? { ...node.data } : {},
682
- };
683
- }
684
-
685
- function normalizeFocusGraphEdge(edge: SimulationFocusGraphEdge): SimulationFocusGraphEdge {
686
- return {
687
- sourceId: textValue(edge.sourceId),
688
- targetId: textValue(edge.targetId),
689
- type: textValue(edge.type),
690
- createdAt: textValue(edge.createdAt),
691
- data: isRecord(edge.data) ? { ...edge.data } : {},
692
- };
693
- }
694
-
695
- function normalizeSimulationFactGraph(graph: SimulationFactGraph | undefined): SimulationFactGraph {
696
- return {
697
- schemaVersion: 1,
698
- facts: (graph?.facts ?? []).map((fact) => normalizeSimulationGraphFact(fact)),
699
- edges: dedupeSimulationEdges(graph?.edges ?? []),
700
- };
701
- }
702
-
703
- function normalizeSimulationGraphFact(fact: SimulationGraphFactInput | SimulationGraphFact): SimulationGraphFact {
704
- const id = textValue(fact.id);
705
- if (!id) {
706
- throw new Error('simulation fact id is required');
707
- }
708
- const subjectId = textValue(fact.subjectId);
709
- if (!subjectId) {
710
- throw new Error('simulation fact subject id is required');
711
- }
712
- const predicate = textValue(fact.predicate);
713
- if (!predicate) {
714
- throw new Error('simulation fact predicate is required');
715
- }
716
-
717
- return {
718
- id,
719
- subjectId,
720
- predicate,
721
- objectId: textValue(fact.objectId),
722
- content: textValue(fact.content),
723
- validFrom: finiteNumber(fact.validFrom, 0),
724
- invalidAt: fact.invalidAt === null || fact.invalidAt === undefined
725
- ? null
726
- : finiteNumber(fact.invalidAt, 0),
727
- sourceEventId: textValue(fact.sourceEventId),
728
- data: isRecord(fact.data) ? { ...fact.data } : {},
729
- };
730
- }
731
-
732
- function dedupeSimulationEdges(edges: SimulationGraphEdge[]): SimulationGraphEdge[] {
733
- const output: SimulationGraphEdge[] = [];
734
- for (const edge of edges) {
735
- addUniqueSimulationEdge(output, {
736
- sourceId: textValue(edge.sourceId),
737
- targetId: textValue(edge.targetId),
738
- type: textValue(edge.type),
739
- tick: finiteNumber(edge.tick, 0),
740
- data: isRecord(edge.data) ? { ...edge.data } : {},
741
- });
742
- }
743
- return output;
744
- }
745
-
746
- function addUniqueSimulationEdge(
747
- output: SimulationGraphEdge[],
748
- edge: SimulationGraphEdge,
749
- ): void {
750
- if (!edge.sourceId || !edge.targetId || !edge.type) return;
751
- const key = `${edge.sourceId}\0${edge.targetId}\0${edge.type}\0${edge.tick}`;
752
- if (output.some((candidate) => (
753
- `${candidate.sourceId}\0${candidate.targetId}\0${candidate.type}\0${candidate.tick}` === key
754
- ))) {
755
- return;
756
- }
757
- output.push(edge);
758
- }
759
-
760
- function textValue(value: unknown): string {
761
- if (typeof value === 'string') return value.trim();
762
- if (typeof value === 'number' || typeof value === 'boolean') return String(value);
763
- return '';
764
- }
765
-
766
- function finiteNumber(value: unknown, fallback: number): number {
767
- const parsed = Number(value);
768
- return Number.isFinite(parsed) ? parsed : fallback;
769
- }
770
-
771
- function wholeNumber(value: unknown, fallback: number): number {
772
- return Math.max(0, Math.floor(finiteNumber(value, fallback)));
773
- }
774
-
775
- function uniqueText(values: string[]): string[] {
776
- const output: string[] = [];
777
- for (const value of values) {
778
- const text = textValue(value);
779
- if (!text || output.includes(text)) continue;
780
- output.push(text);
781
- }
782
- return output;
783
- }
784
-
785
- function isRecord(value: unknown): value is Record<string, unknown> {
786
- return typeof value === 'object' && value !== null && !Array.isArray(value);
787
- }
788
-
789
- // ── SimGraph ──────────────────────────────────────────────────────
790
-
791
- export class SimGraph {
792
- private db: FalkorDB;
793
- private graph: Graph;
794
- /** Per-instance counter (review #8: avoid module-level mutable state) */
795
- private factCounter = 0;
796
-
797
- private constructor(db: FalkorDB, graph: Graph) {
798
- this.db = db;
799
- this.graph = graph;
800
- }
801
-
802
- /**
803
- * Open an embedded FalkorDB instance and select the simulation graph.
804
- * If dataPath is provided, data persists to disk.
805
- */
806
- static async open(dataPath?: string): Promise<SimGraph> {
807
- const { FalkorDB } = await import('falkordblite');
808
- const db = await FalkorDB.open(
809
- dataPath ? { path: dataPath, logLevel: 'warning' } : { logLevel: 'warning' },
810
- );
811
- const graph = db.selectGraph('sim');
812
- const sg = new SimGraph(db, graph);
813
- await sg.initSchema();
814
- return sg;
815
- }
816
-
817
- /** Close the graph database and stop the embedded server. */
818
- async close(): Promise<void> {
819
- await this.db.close();
820
- }
821
-
822
- /**
823
- * Escape hatch for domain-specific adapters that want to use labels/
824
- * relationships outside the built-in schema (e.g. cybersim's `:CS*`
825
- * nodes). Returned shape matches FalkorDB's `graph.query` result.
826
- */
827
- async rawQuery<T = Record<string, unknown>>(
828
- cypher: string,
829
- params?: QueryParams,
830
- ): Promise<{ data?: T[] }> {
831
- const result = await this.graph.query<[T]>(cypher, params ? { params } : undefined);
832
- return { data: result.data as unknown as T[] | undefined };
833
- }
834
-
835
- /**
836
- * Register a range index on a node label/property pair. Safe to call
837
- * for already-indexed pairs — creation errors are swallowed.
838
- */
839
- async ensureNodeIndex(label: string, property: string): Promise<void> {
840
- try {
841
- await this.graph.createNodeRangeIndex(label, property);
842
- } catch {
843
- // Index may already exist — ignore
844
- }
845
- }
846
-
847
- /** Create indices for common query patterns. */
848
- private async initSchema(): Promise<void> {
849
- // Node indices
850
- const indices = [
851
- ['Agent', 'id'], ['Fact', 'id'], ['Fact', 'category'],
852
- ['FeedPost', 'id'], ['Topic', 'name'], ['Event', 'id'],
853
- ['Company', 'id'],
854
- ];
855
- for (const [label, prop] of indices) {
856
- try {
857
- await this.graph.createNodeRangeIndex(label, prop);
858
- } catch {
859
- // Index may already exist — ignore
860
- }
861
- }
862
- }
863
-
864
- // ── Entity Management ───────────────────────────────────────────
865
-
866
- async addAgent(agent: {
867
- id: string; name: string; role: string; organization: string; type: 'internal' | 'external';
868
- }): Promise<void> {
869
- await this.graph.query(
870
- `MERGE (a:Agent {id: $id})
871
- SET a.name = $name, a.role = $role, a.organization = $organization, a.type = $type`,
872
- { params: { id: agent.id, name: agent.name, role: agent.role, organization: agent.organization, type: agent.type } },
873
- );
874
- }
875
-
876
- async addCompany(company: {
877
- id: string; name: string; industry: string; stage: string;
878
- }): Promise<void> {
879
- await this.graph.query(
880
- `MERGE (c:Company {id: $id})
881
- SET c.name = $name, c.industry = $industry, c.stage = $stage`,
882
- { params: { id: company.id, name: company.name, industry: company.industry, stage: company.stage } },
883
- );
884
- }
885
-
886
- async addCompetitor(companyIdA: string, companyIdB: string): Promise<void> {
887
- await this.graph.query(
888
- `MATCH (a:Company {id: $a}), (b:Company {id: $b})
889
- MERGE (a)-[:COMPETES_WITH]->(b)
890
- MERGE (b)-[:COMPETES_WITH]->(a)`,
891
- { params: { a: companyIdA, b: companyIdB } },
892
- );
893
- }
894
-
895
- async addWorksAt(agentId: string, companyId: string): Promise<void> {
896
- await this.graph.query(
897
- `MATCH (a:Agent {id: $agentId}), (c:Company {id: $companyId})
898
- MERGE (a)-[:WORKS_AT]->(c)`,
899
- { params: { agentId, companyId } },
900
- );
901
- }
902
-
903
- // ── Fact Management (Temporal) ──────────────────────────────────
904
-
905
- /** Add a fact stated by an agent. Returns the fact ID. */
906
- async addFact(
907
- agentId: string,
908
- content: string,
909
- category: string,
910
- tick: number,
911
- aboutEntityId?: string,
912
- ): Promise<string> {
913
- const factId = `fact-${++this.factCounter}`;
914
- await this.graph.query(
915
- `MATCH (a:Agent {id: $agentId})
916
- CREATE (f:Fact {id: $factId, content: $content, category: $category,
917
- source_tick: $tick, valid_from: $tick, invalid_at: -1})
918
- CREATE (a)-[:STATED {tick: $tick}]->(f)`,
919
- { params: { agentId, factId, content, category, tick } },
920
- );
921
-
922
- // Link fact to entity if specified
923
- if (aboutEntityId) {
924
- await this.graph.query(
925
- `MATCH (f:Fact {id: $factId})
926
- OPTIONAL MATCH (e:Company {id: $entityId})
927
- OPTIONAL MATCH (a:Agent {id: $entityId})
928
- OPTIONAL MATCH (t:Topic {name: $entityId})
929
- WITH f, coalesce(e, a, t) AS target
930
- WHERE target IS NOT NULL
931
- CREATE (f)-[:ABOUT]->(target)`,
932
- { params: { factId, entityId: aboutEntityId } },
933
- );
934
- }
935
-
936
- return factId;
937
- }
938
-
939
- /** Invalidate a fact at the given tick. */
940
- async invalidateFact(factId: string, tick: number): Promise<void> {
941
- await this.graph.query(
942
- `MATCH (f:Fact {id: $factId}) SET f.invalid_at = $tick`,
943
- { params: { factId, tick } },
944
- );
945
- }
946
-
947
- /**
948
- * Add a fact, checking for contradictions. If the same agent has
949
- * a current fact in the same category about the same entity,
950
- * invalidate the old fact and create a SUPERSEDES edge.
951
- */
952
- async addFactWithSupersede(
953
- agentId: string,
954
- content: string,
955
- category: string,
956
- tick: number,
957
- aboutEntityId?: string,
958
- ): Promise<string> {
959
- // Find existing current facts by this agent in this category
960
- if (aboutEntityId) {
961
- const existing = await this.graph.query<[{ id: string }]>(
962
- `MATCH (a:Agent {id: $agentId})-[:STATED]->(f:Fact)-[:ABOUT]->(e {id: $entityId})
963
- WHERE f.category = $category AND f.invalid_at = -1
964
- RETURN f.id AS id`,
965
- { params: { agentId, category, entityId: aboutEntityId } },
966
- );
967
- if (existing.data && existing.data.length > 0) {
968
- for (const row of existing.data) {
969
- const oldId = (row as unknown as Record<string, string>).id;
970
- if (oldId) await this.invalidateFact(oldId, tick);
971
- }
972
- }
973
- }
974
-
975
- const newFactId = await this.addFact(agentId, content, category, tick, aboutEntityId);
976
-
977
- // Create SUPERSEDES edges
978
- if (aboutEntityId) {
979
- await this.graph.query(
980
- `MATCH (newF:Fact {id: $newId})
981
- MATCH (a:Agent {id: $agentId})-[:STATED]->(oldF:Fact)-[:ABOUT]->(e {id: $entityId})
982
- WHERE oldF.category = $category AND oldF.invalid_at = $tick AND oldF.id <> $newId
983
- MERGE (newF)-[:SUPERSEDES]->(oldF)`,
984
- { params: { newId: newFactId, agentId, category, entityId: aboutEntityId, tick } },
985
- );
986
- }
987
-
988
- return newFactId;
989
- }
990
-
991
- // ── Fact Queries ────────────────────────────────────────────────
992
-
993
- /** Get all facts stated by an agent. */
994
- async getAgentFacts(agentId: string, currentOnly: boolean = true): Promise<GraphFact[]> {
995
- const whereClause = currentOnly ? 'AND f.invalid_at = -1' : '';
996
- const result = await this.graph.query<[Record<string, unknown>]>(
997
- `MATCH (a:Agent {id: $agentId})-[:STATED]->(f:Fact)
998
- WHERE 1=1 ${whereClause}
999
- RETURN f.id AS id, f.content AS content, f.category AS category,
1000
- f.source_tick AS sourceTick, f.valid_from AS validFrom, f.invalid_at AS invalidAt
1001
- ORDER BY f.source_tick DESC`,
1002
- { params: { agentId } },
1003
- );
1004
- return this.parseFactRows(result.data);
1005
- }
1006
-
1007
- /** Get all facts about an entity. */
1008
- async getFactsAbout(entityId: string, currentOnly: boolean = true): Promise<GraphFact[]> {
1009
- const whereClause = currentOnly ? 'AND f.invalid_at = -1' : '';
1010
- const result = await this.graph.query<[Record<string, unknown>]>(
1011
- `MATCH (f:Fact)-[:ABOUT]->(e {id: $entityId})
1012
- WHERE 1=1 ${whereClause}
1013
- RETURN f.id AS id, f.content AS content, f.category AS category,
1014
- f.source_tick AS sourceTick, f.valid_from AS validFrom, f.invalid_at AS invalidAt
1015
- ORDER BY f.source_tick DESC`,
1016
- { params: { entityId } },
1017
- );
1018
- return this.parseFactRows(result.data);
1019
- }
1020
-
1021
- /** Get facts matching a keyword in content. */
1022
- async getFactsByKeyword(keyword: string, currentOnly: boolean = true): Promise<GraphFact[]> {
1023
- const whereClause = currentOnly ? 'AND f.invalid_at = -1' : '';
1024
- const result = await this.graph.query<[Record<string, unknown>]>(
1025
- `MATCH (f:Fact)
1026
- WHERE f.content CONTAINS $keyword ${whereClause}
1027
- RETURN f.id AS id, f.content AS content, f.category AS category,
1028
- f.source_tick AS sourceTick, f.valid_from AS validFrom, f.invalid_at AS invalidAt
1029
- ORDER BY f.source_tick DESC
1030
- LIMIT 20`,
1031
- { params: { keyword } },
1032
- );
1033
- return this.parseFactRows(result.data);
1034
- }
1035
-
1036
- // ── Relationship Management ─────────────────────────────────────
1037
-
1038
- async setRelationship(
1039
- agentIdA: string, agentIdB: string,
1040
- sentiment: number, trust: number, interactionCount: number,
1041
- note?: string,
1042
- ): Promise<void> {
1043
- await this.graph.query(
1044
- `MATCH (a:Agent {id: $a}), (b:Agent {id: $b})
1045
- MERGE (a)-[r:KNOWS]->(b)
1046
- SET r.sentiment = $sentiment, r.trust = $trust,
1047
- r.interaction_count = $interactionCount,
1048
- r.last_note = $note`,
1049
- { params: { a: agentIdA, b: agentIdB, sentiment, trust, interactionCount, note: note ?? '' } },
1050
- );
1051
- }
1052
-
1053
- /** Review #12: Query both directions for KNOWS relationship. */
1054
- async getRelationship(agentIdA: string, agentIdB: string): Promise<GraphRelationship | null> {
1055
- const result = await this.graph.query<[Record<string, unknown>]>(
1056
- `MATCH (a:Agent {id: $a})-[r:KNOWS]-(b:Agent {id: $b})
1057
- RETURN a.id AS agentIdA, b.id AS agentIdB, a.name AS nameA, b.name AS nameB,
1058
- r.sentiment AS sentiment, r.trust AS trust,
1059
- r.interaction_count AS interactionCount, r.last_note AS lastNote
1060
- LIMIT 1`,
1061
- { params: { a: agentIdA, b: agentIdB } },
1062
- );
1063
- if (!result.data || result.data.length === 0) return null;
1064
- const row = result.data[0] as unknown as Record<string, unknown>;
1065
- return {
1066
- agentIdA: String(row.agentIdA),
1067
- agentIdB: String(row.agentIdB),
1068
- nameA: String(row.nameA),
1069
- nameB: String(row.nameB),
1070
- sentiment: Number(row.sentiment ?? 0),
1071
- trust: Number(row.trust ?? 0),
1072
- interactionCount: Number(row.interactionCount ?? 0),
1073
- lastNote: row.lastNote ? String(row.lastNote) : null,
1074
- };
1075
- }
1076
-
1077
- async getRelationshipsFor(agentId: string): Promise<GraphRelationship[]> {
1078
- // Review #12: Query both directions
1079
- const result = await this.graph.query<[Record<string, unknown>]>(
1080
- `MATCH (a:Agent {id: $id})-[r:KNOWS]-(b:Agent)
1081
- RETURN a.id AS agentIdA, b.id AS agentIdB, a.name AS nameA, b.name AS nameB,
1082
- r.sentiment AS sentiment, r.trust AS trust,
1083
- r.interaction_count AS interactionCount, r.last_note AS lastNote
1084
- ORDER BY r.interaction_count DESC`,
1085
- { params: { id: agentId } },
1086
- );
1087
- return (result.data ?? []).map((row: unknown) => {
1088
- const r = row as Record<string, unknown>;
1089
- return {
1090
- agentIdA: String(r.agentIdA),
1091
- agentIdB: String(r.agentIdB),
1092
- nameA: String(r.nameA),
1093
- nameB: String(r.nameB),
1094
- sentiment: Number(r.sentiment ?? 0),
1095
- trust: Number(r.trust ?? 0),
1096
- interactionCount: Number(r.interactionCount ?? 0),
1097
- lastNote: r.lastNote ? String(r.lastNote) : null,
1098
- };
1099
- });
1100
- }
1101
-
1102
- // ── Feed Posts ──────────────────────────────────────────────────
1103
-
1104
- async addPost(post: {
1105
- id: string; content: string; authorId: string; authorName: string;
1106
- authorRole: string; source: string; tick: number; replyTo?: string;
1107
- topicTags?: string[];
1108
- }): Promise<void> {
1109
- await this.graph.query(
1110
- `MATCH (a:Agent {id: $authorId})
1111
- CREATE (p:FeedPost {id: $id, content: $content, author_id: $authorId,
1112
- author_name: $authorName, author_role: $authorRole,
1113
- source: $source, created_at: $tick,
1114
- reply_to: $replyTo})
1115
- CREATE (a)-[:POSTED]->(p)`,
1116
- {
1117
- params: {
1118
- id: post.id, content: post.content, authorId: post.authorId,
1119
- authorName: post.authorName, authorRole: post.authorRole,
1120
- source: post.source, tick: post.tick, replyTo: post.replyTo ?? '',
1121
- },
1122
- },
1123
- );
1124
-
1125
- // Create reply edge
1126
- if (post.replyTo) {
1127
- await this.graph.query(
1128
- `MATCH (child:FeedPost {id: $childId}), (parent:FeedPost {id: $parentId})
1129
- CREATE (child)-[:REPLY_TO]->(parent)`,
1130
- { params: { childId: post.id, parentId: post.replyTo } },
1131
- );
1132
- }
1133
-
1134
- // Tag topics
1135
- if (post.topicTags && post.topicTags.length > 0) {
1136
- for (const tag of post.topicTags) {
1137
- await this.graph.query(
1138
- `MATCH (p:FeedPost {id: $postId})
1139
- MERGE (t:Topic {name: $tag})
1140
- CREATE (p)-[:TAGGED]->(t)`,
1141
- { params: { postId: post.id, tag } },
1142
- );
1143
- }
1144
- }
1145
- }
1146
-
1147
- async addReaction(agentId: string, postId: string): Promise<void> {
1148
- await this.graph.query(
1149
- `MATCH (a:Agent {id: $agentId}), (p:FeedPost {id: $postId})
1150
- MERGE (a)-[:REACTED_TO]->(p)`,
1151
- { params: { agentId, postId } },
1152
- );
1153
- }
1154
-
1155
- async getRecentPosts(limit: number = 50, sinceTick?: number): Promise<GraphFeedPost[]> {
1156
- const whereClause = sinceTick !== undefined ? `WHERE p.created_at > $since` : '';
1157
- const result = await this.graph.query<[Record<string, unknown>]>(
1158
- `MATCH (p:FeedPost)
1159
- ${whereClause}
1160
- OPTIONAL MATCH (p)<-[:REACTED_TO]-(reactor:Agent)
1161
- OPTIONAL MATCH (p)-[:TAGGED]->(t:Topic)
1162
- RETURN p.id AS id, p.content AS content, p.author_id AS authorId,
1163
- p.author_name AS authorName, p.author_role AS authorRole,
1164
- p.source AS source, p.created_at AS createdAt,
1165
- p.reply_to AS replyTo,
1166
- count(DISTINCT reactor) AS reactionCount,
1167
- collect(DISTINCT t.name) AS topicTags
1168
- ORDER BY p.created_at DESC
1169
- LIMIT $limit`,
1170
- { params: { limit, since: sinceTick ?? 0 } },
1171
- );
1172
- return this.parsePostRows(result.data);
1173
- }
1174
-
1175
- // ── Events ──────────────────────────────────────────────────────
1176
-
1177
- async addEvent(event: {
1178
- id: string; type: string; description: string; tick: number;
1179
- participantIds: string[];
1180
- }): Promise<void> {
1181
- await this.graph.query(
1182
- `CREATE (e:Event {id: $id, type: $type, description: $description, tick: $tick})`,
1183
- { params: { id: event.id, type: event.type, description: event.description, tick: event.tick } },
1184
- );
1185
- for (const pid of event.participantIds) {
1186
- await this.graph.query(
1187
- `MATCH (e:Event {id: $eventId}), (a:Agent {id: $agentId})
1188
- CREATE (e)-[:INVOLVED]->(a)`,
1189
- { params: { eventId: event.id, agentId: pid } },
1190
- );
1191
- }
1192
- }
1193
-
1194
- // ── Graph Traversal ─────────────────────────────────────────────
1195
-
1196
- /**
1197
- * Get an agent's full world view at a given tick:
1198
- * - Facts they've stated (current)
1199
- * - Facts their peers have stated (current)
1200
- * - Facts about topics they've discussed
1201
- */
1202
- async getAgentWorldView(agentId: string, tick: number): Promise<WorldView> {
1203
- // Own facts
1204
- const ownFacts = await this.getAgentFacts(agentId, true);
1205
-
1206
- // Peer facts (from agents they KNOW — both directions, review #12)
1207
- const peerResult = await this.graph.query<[Record<string, unknown>]>(
1208
- `MATCH (a:Agent {id: $agentId})-[:KNOWS]-(peer:Agent)-[:STATED]->(f:Fact)
1209
- WHERE f.invalid_at = -1
1210
- RETURN peer.name AS peerName, peer.role AS peerRole,
1211
- f.id AS id, f.content AS content, f.category AS category,
1212
- f.source_tick AS sourceTick, f.valid_from AS validFrom, f.invalid_at AS invalidAt
1213
- ORDER BY f.source_tick DESC
1214
- LIMIT 30`,
1215
- { params: { agentId } },
1216
- );
1217
-
1218
- // Group peer facts by peer
1219
- const peerMap = new Map<string, { peerName: string; peerRole: string; facts: GraphFact[] }>();
1220
- for (const row of (peerResult.data ?? []) as unknown as Record<string, unknown>[]) {
1221
- const peerName = String(row.peerName);
1222
- if (!peerMap.has(peerName)) {
1223
- peerMap.set(peerName, { peerName, peerRole: String(row.peerRole), facts: [] });
1224
- }
1225
- peerMap.get(peerName)!.facts.push({
1226
- id: String(row.id), content: String(row.content), category: String(row.category),
1227
- sourceTick: Number(row.sourceTick), validFrom: Number(row.validFrom),
1228
- invalidAt: Number(row.invalidAt) === -1 ? null : Number(row.invalidAt),
1229
- });
1230
- }
1231
-
1232
- return {
1233
- ownFacts,
1234
- peerFacts: [...peerMap.values()],
1235
- topicFacts: [], // populated by caller if needed
1236
- };
1237
- }
1238
-
1239
- /**
1240
- * Get context around any entity — all facts, agents, and relationships.
1241
- * Review #9: removed unused depth param (queries are always 1-hop).
1242
- */
1243
- async getEntityContext(entityId: string): Promise<GraphContext> {
1244
- const factsResult = await this.graph.query<[Record<string, unknown>]>(
1245
- `MATCH (e {id: $entityId})<-[:ABOUT]-(f:Fact)
1246
- WHERE f.invalid_at = -1
1247
- RETURN f.id AS id, f.content AS content, f.category AS category,
1248
- f.source_tick AS sourceTick, f.valid_from AS validFrom, f.invalid_at AS invalidAt
1249
- ORDER BY f.source_tick DESC
1250
- LIMIT 20`,
1251
- { params: { entityId } },
1252
- );
1253
-
1254
- const agentsResult = await this.graph.query<[Record<string, unknown>]>(
1255
- `MATCH (a:Agent)-[:STATED]->(f:Fact)-[:ABOUT]->(e {id: $entityId})
1256
- RETURN DISTINCT a.id AS id, a.name AS name, a.role AS role`,
1257
- { params: { entityId } },
1258
- );
1259
-
1260
- return {
1261
- facts: this.parseFactRows(factsResult.data),
1262
- agents: (agentsResult.data ?? []).map((r: unknown) => {
1263
- const row = r as Record<string, unknown>;
1264
- return { id: String(row.id), name: String(row.name), role: String(row.role) };
1265
- }),
1266
- relationships: [],
1267
- };
1268
- }
1269
-
1270
- // ── Helpers ─────────────────────────────────────────────────────
1271
-
1272
- private parseFactRows(data: unknown[] | undefined): GraphFact[] {
1273
- if (!data) return [];
1274
- return data.map(row => {
1275
- const r = row as unknown as Record<string, unknown>;
1276
- return {
1277
- id: String(r.id ?? ''),
1278
- content: String(r.content ?? ''),
1279
- category: String(r.category ?? ''),
1280
- sourceTick: Number(r.sourceTick ?? 0),
1281
- validFrom: Number(r.validFrom ?? 0),
1282
- invalidAt: Number(r.invalidAt) === -1 ? null : Number(r.invalidAt ?? null),
1283
- };
1284
- });
1285
- }
1286
-
1287
- private parsePostRows(data: unknown[] | undefined): GraphFeedPost[] {
1288
- if (!data) return [];
1289
- return data.map(row => {
1290
- const r = row as unknown as Record<string, unknown>;
1291
- return {
1292
- id: String(r.id ?? ''),
1293
- content: String(r.content ?? ''),
1294
- authorId: String(r.authorId ?? ''),
1295
- authorName: String(r.authorName ?? ''),
1296
- authorRole: String(r.authorRole ?? ''),
1297
- source: String(r.source ?? ''),
1298
- createdAt: Number(r.createdAt ?? 0),
1299
- replyTo: r.replyTo ? String(r.replyTo) : null,
1300
- reactionCount: Number(r.reactionCount ?? 0),
1301
- topicTags: Array.isArray(r.topicTags) ? r.topicTags.map(String) : [],
1302
- };
1303
- });
1304
- }
1305
- }