@engram-mem/graph 0.1.1

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.
@@ -0,0 +1,893 @@
1
+ import neo4j from 'neo4j-driver';
2
+ import { ALL_SCHEMA_STATEMENTS } from './schema.js';
3
+ import { SpreadingActivation } from './spreading-activation.js';
4
+ import { extractPersons, classifyEmotion } from './context-extractors.js';
5
+ // ============================================================================
6
+ // Neo4j Integer Conversion
7
+ // ============================================================================
8
+ function toNativeProperties(props) {
9
+ const result = {};
10
+ for (const [key, value] of Object.entries(props)) {
11
+ if (neo4j.isInt(value)) {
12
+ result[key] = value.toNumber();
13
+ }
14
+ else {
15
+ result[key] = value;
16
+ }
17
+ }
18
+ return result;
19
+ }
20
+ // ============================================================================
21
+ // ID Generation Helpers
22
+ // ============================================================================
23
+ function normalizeForId(name) {
24
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/(^_|_$)/g, '');
25
+ }
26
+ function getYearWeek(date) {
27
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
28
+ const dayNum = d.getUTCDay() || 7;
29
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
30
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
31
+ const weekNum = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
32
+ return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
33
+ }
34
+ function getDayOfWeek(date) {
35
+ const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
36
+ return days[date.getDay()];
37
+ }
38
+ function getTimeOfDay(date) {
39
+ const hour = date.getHours();
40
+ if (hour >= 6 && hour < 12)
41
+ return 'morning';
42
+ if (hour >= 12 && hour < 17)
43
+ return 'afternoon';
44
+ if (hour >= 17 && hour < 21)
45
+ return 'evening';
46
+ return 'night';
47
+ }
48
+ // ============================================================================
49
+ // NeuralGraph
50
+ // ============================================================================
51
+ export class NeuralGraph {
52
+ driver;
53
+ constructor(config) {
54
+ this.driver = neo4j.driver(config.neo4jUri, neo4j.auth.basic(config.neo4jUser, config.neo4jPassword));
55
+ }
56
+ // --------------------------------------------------------------------------
57
+ // Lifecycle
58
+ // --------------------------------------------------------------------------
59
+ async initialize() {
60
+ await this.driver.verifyConnectivity();
61
+ const session = this.driver.session();
62
+ try {
63
+ for (const statement of ALL_SCHEMA_STATEMENTS) {
64
+ await session.run(statement);
65
+ }
66
+ }
67
+ finally {
68
+ await session.close();
69
+ }
70
+ }
71
+ async dispose() {
72
+ await this.driver.close();
73
+ }
74
+ // --------------------------------------------------------------------------
75
+ // Node Operations (all use MERGE for idempotency)
76
+ // --------------------------------------------------------------------------
77
+ async addMemoryNode(input) {
78
+ const now = new Date().toISOString();
79
+ const session = this.driver.session();
80
+ try {
81
+ await session.executeWrite(async (tx) => {
82
+ await tx.run(`MERGE (m:Memory {id: $id})
83
+ ON CREATE SET
84
+ m.memoryType = $memoryType,
85
+ m.label = $label,
86
+ m.projectId = $projectId,
87
+ m.createdAt = $now,
88
+ m.lastAccessed = $now,
89
+ m.activationCount = 0
90
+ ON MATCH SET
91
+ m.lastAccessed = $now,
92
+ m.activationCount = m.activationCount + 1`, {
93
+ id: input.id,
94
+ memoryType: input.memoryType,
95
+ label: input.label.slice(0, 100),
96
+ projectId: input.projectId ?? null,
97
+ now,
98
+ });
99
+ });
100
+ return input.id;
101
+ }
102
+ finally {
103
+ await session.close();
104
+ }
105
+ }
106
+ async addPersonNode(input) {
107
+ const id = `person:${normalizeForId(input.name)}`;
108
+ const now = new Date().toISOString();
109
+ const aliases = JSON.stringify(input.aliases ?? []);
110
+ const session = this.driver.session();
111
+ try {
112
+ await session.executeWrite(async (tx) => {
113
+ await tx.run(`MERGE (p:Person {id: $id})
114
+ ON CREATE SET
115
+ p.name = $name,
116
+ p.aliases = $aliases,
117
+ p.firstSeen = $now,
118
+ p.lastSeen = $now,
119
+ p.createdAt = $now,
120
+ p.lastAccessed = $now,
121
+ p.activationCount = 0
122
+ ON MATCH SET
123
+ p.lastSeen = $now,
124
+ p.lastAccessed = $now,
125
+ p.activationCount = p.activationCount + 1`, { id, name: input.name, aliases, now });
126
+ });
127
+ return id;
128
+ }
129
+ finally {
130
+ await session.close();
131
+ }
132
+ }
133
+ async addTopicNode(input) {
134
+ const id = `topic:${normalizeForId(input.name)}`;
135
+ const now = new Date().toISOString();
136
+ const session = this.driver.session();
137
+ try {
138
+ await session.executeWrite(async (tx) => {
139
+ await tx.run(`MERGE (t:Topic {id: $id})
140
+ ON CREATE SET
141
+ t.name = $name,
142
+ t.description = $description,
143
+ t.createdAt = $now,
144
+ t.lastAccessed = $now,
145
+ t.activationCount = 0
146
+ ON MATCH SET
147
+ t.lastAccessed = $now,
148
+ t.activationCount = t.activationCount + 1`, { id, name: input.name, description: input.description ?? null, now });
149
+ });
150
+ return id;
151
+ }
152
+ finally {
153
+ await session.close();
154
+ }
155
+ }
156
+ async addEntityNode(input) {
157
+ const id = `entity:${normalizeForId(input.name)}`;
158
+ const now = new Date().toISOString();
159
+ const session = this.driver.session();
160
+ try {
161
+ await session.executeWrite(async (tx) => {
162
+ await tx.run(`MERGE (e:Entity {id: $id})
163
+ ON CREATE SET
164
+ e.name = $name,
165
+ e.entityType = $entityType,
166
+ e.createdAt = $now,
167
+ e.lastAccessed = $now,
168
+ e.activationCount = 0
169
+ ON MATCH SET
170
+ e.lastAccessed = $now,
171
+ e.activationCount = e.activationCount + 1`, { id, name: input.name, entityType: input.entityType, now });
172
+ });
173
+ return id;
174
+ }
175
+ finally {
176
+ await session.close();
177
+ }
178
+ }
179
+ async addEmotionNode(input) {
180
+ const scoped = input.sessionScoped !== false;
181
+ const id = scoped
182
+ ? `emotion:${input.sessionId}:${input.label}`
183
+ : `emotion:global:${input.label}`;
184
+ const now = new Date().toISOString();
185
+ const session = this.driver.session();
186
+ try {
187
+ await session.executeWrite(async (tx) => {
188
+ await tx.run(`MERGE (e:Emotion {id: $id})
189
+ ON CREATE SET
190
+ e.label = $label,
191
+ e.intensity = $intensity,
192
+ e.sessionScoped = $sessionScoped,
193
+ e.sessionId = $sessionId,
194
+ e.createdAt = $now,
195
+ e.lastAccessed = $now,
196
+ e.activationCount = 0
197
+ ON MATCH SET
198
+ e.intensity = CASE WHEN $intensity > e.intensity THEN $intensity ELSE e.intensity END,
199
+ e.lastAccessed = $now,
200
+ e.activationCount = e.activationCount + 1`, {
201
+ id,
202
+ label: input.label,
203
+ intensity: input.intensity,
204
+ sessionScoped: scoped,
205
+ sessionId: scoped ? input.sessionId : null,
206
+ now,
207
+ });
208
+ });
209
+ return id;
210
+ }
211
+ finally {
212
+ await session.close();
213
+ }
214
+ }
215
+ async addIntentNode(input) {
216
+ const scoped = input.sessionScoped !== false;
217
+ const id = scoped
218
+ ? `intent:${input.sessionId}:${input.intentType}`
219
+ : `intent:global:${input.intentType}`;
220
+ const now = new Date().toISOString();
221
+ const session = this.driver.session();
222
+ try {
223
+ await session.executeWrite(async (tx) => {
224
+ await tx.run(`MERGE (i:Intent {id: $id})
225
+ ON CREATE SET
226
+ i.intentType = $intentType,
227
+ i.sessionScoped = $sessionScoped,
228
+ i.sessionId = $sessionId,
229
+ i.createdAt = $now,
230
+ i.lastAccessed = $now,
231
+ i.activationCount = 0
232
+ ON MATCH SET
233
+ i.lastAccessed = $now,
234
+ i.activationCount = i.activationCount + 1`, {
235
+ id,
236
+ intentType: input.intentType,
237
+ sessionScoped: scoped,
238
+ sessionId: scoped ? input.sessionId : null,
239
+ now,
240
+ });
241
+ });
242
+ return id;
243
+ }
244
+ finally {
245
+ await session.close();
246
+ }
247
+ }
248
+ async addSessionNode(input) {
249
+ const now = new Date().toISOString();
250
+ const session = this.driver.session();
251
+ try {
252
+ await session.executeWrite(async (tx) => {
253
+ await tx.run(`MERGE (s:Session {sessionId: $sessionId})
254
+ ON CREATE SET
255
+ s.id = $sessionId,
256
+ s.startTime = $startTime,
257
+ s.endTime = $endTime,
258
+ s.createdAt = $now,
259
+ s.lastAccessed = $now,
260
+ s.activationCount = 0
261
+ ON MATCH SET
262
+ s.endTime = COALESCE($endTime, s.endTime),
263
+ s.lastAccessed = $now,
264
+ s.activationCount = s.activationCount + 1`, {
265
+ sessionId: input.sessionId,
266
+ startTime: input.startTime,
267
+ endTime: input.endTime ?? null,
268
+ now,
269
+ });
270
+ });
271
+ return input.sessionId;
272
+ }
273
+ finally {
274
+ await session.close();
275
+ }
276
+ }
277
+ async addTimeContextNode(input) {
278
+ const yearWeek = getYearWeek(input.timestamp);
279
+ const dayOfWeek = getDayOfWeek(input.timestamp);
280
+ const timeOfDay = getTimeOfDay(input.timestamp);
281
+ const id = `time:${yearWeek}:${dayOfWeek}:${timeOfDay}`;
282
+ const now = new Date().toISOString();
283
+ const session = this.driver.session();
284
+ try {
285
+ await session.executeWrite(async (tx) => {
286
+ await tx.run(`MERGE (t:TimeContext {id: $id})
287
+ ON CREATE SET
288
+ t.yearWeek = $yearWeek,
289
+ t.dayOfWeek = $dayOfWeek,
290
+ t.timeOfDay = $timeOfDay,
291
+ t.createdAt = $now,
292
+ t.lastAccessed = $now,
293
+ t.activationCount = 0
294
+ ON MATCH SET
295
+ t.lastAccessed = $now,
296
+ t.activationCount = t.activationCount + 1`, { id, yearWeek, dayOfWeek, timeOfDay, now });
297
+ });
298
+ return id;
299
+ }
300
+ finally {
301
+ await session.close();
302
+ }
303
+ }
304
+ // --------------------------------------------------------------------------
305
+ // Edge Operations
306
+ // --------------------------------------------------------------------------
307
+ async addEdge(sourceId, targetId, type, weight) {
308
+ const now = new Date().toISOString();
309
+ const session = this.driver.session();
310
+ try {
311
+ await session.executeWrite(async (tx) => {
312
+ await tx.run(`MATCH (source) WHERE source.id = $sourceId
313
+ MATCH (target) WHERE target.id = $targetId
314
+ MERGE (source)-[r:${type}]->(target)
315
+ ON CREATE SET
316
+ r.weight = $weight,
317
+ r.createdAt = $now,
318
+ r.lastTraversed = $now,
319
+ r.traversalCount = 1
320
+ ON MATCH SET
321
+ r.weight = CASE WHEN $weight > r.weight THEN $weight ELSE r.weight END,
322
+ r.lastTraversed = $now,
323
+ r.traversalCount = r.traversalCount + 1`, { sourceId, targetId, weight, now });
324
+ });
325
+ }
326
+ finally {
327
+ await session.close();
328
+ }
329
+ }
330
+ // --------------------------------------------------------------------------
331
+ // Query Operations
332
+ // --------------------------------------------------------------------------
333
+ async getNode(id) {
334
+ const session = this.driver.session();
335
+ try {
336
+ const result = await session.executeRead(async (tx) => {
337
+ return tx.run(`MATCH (n) WHERE n.id = $id
338
+ RETURN n, labels(n)[0] AS label`, { id });
339
+ });
340
+ if (result.records.length === 0)
341
+ return null;
342
+ const record = result.records[0];
343
+ const node = record.get('n');
344
+ return {
345
+ id,
346
+ label: record.get('label'),
347
+ properties: toNativeProperties(node.properties),
348
+ };
349
+ }
350
+ finally {
351
+ await session.close();
352
+ }
353
+ }
354
+ async getNeighbors(id, opts) {
355
+ const direction = opts?.direction ?? 'both';
356
+ const limit = opts?.limit ?? 50;
357
+ let pattern;
358
+ if (direction === 'out') {
359
+ pattern = opts?.edgeType
360
+ ? `(source)-[r:${opts.edgeType}]->(neighbor)`
361
+ : '(source)-[r]->(neighbor)';
362
+ }
363
+ else if (direction === 'in') {
364
+ pattern = opts?.edgeType
365
+ ? `(source)<-[r:${opts.edgeType}]-(neighbor)`
366
+ : '(source)<-[r]-(neighbor)';
367
+ }
368
+ else {
369
+ pattern = opts?.edgeType
370
+ ? `(source)-[r:${opts.edgeType}]-(neighbor)`
371
+ : '(source)-[r]-(neighbor)';
372
+ }
373
+ const session = this.driver.session();
374
+ try {
375
+ const result = await session.executeRead(async (tx) => {
376
+ return tx.run(`MATCH (source) WHERE source.id = $id
377
+ MATCH ${pattern}
378
+ RETURN neighbor, labels(neighbor)[0] AS label, r.weight AS weight
379
+ ORDER BY r.weight DESC
380
+ LIMIT $limit`, { id, limit: neo4j.int(limit) });
381
+ });
382
+ return result.records.map(record => {
383
+ const weight = record.get('weight');
384
+ return {
385
+ id: record.get('neighbor').properties.id,
386
+ label: record.get('label'),
387
+ properties: toNativeProperties(record.get('neighbor').properties),
388
+ edgeWeight: neo4j.isInt(weight) ? weight.toNumber() : weight ?? 0,
389
+ };
390
+ });
391
+ }
392
+ finally {
393
+ await session.close();
394
+ }
395
+ }
396
+ // --------------------------------------------------------------------------
397
+ // Bulk Operations
398
+ // --------------------------------------------------------------------------
399
+ async decomposeEpisode(input) {
400
+ const now = new Date().toISOString();
401
+ const yearWeek = getYearWeek(input.timestamp);
402
+ const dayOfWeek = getDayOfWeek(input.timestamp);
403
+ const timeOfDay = getTimeOfDay(input.timestamp);
404
+ const timeContextId = `time:${yearWeek}:${dayOfWeek}:${timeOfDay}`;
405
+ const session = this.driver.session();
406
+ try {
407
+ await session.executeWrite(async (tx) => {
408
+ // 1. Memory node
409
+ await tx.run(`MERGE (m:Memory {id: $id})
410
+ ON CREATE SET
411
+ m.memoryType = $memoryType,
412
+ m.label = $label,
413
+ m.projectId = $projectId,
414
+ m.createdAt = $now,
415
+ m.lastAccessed = $now,
416
+ m.activationCount = 0
417
+ ON MATCH SET
418
+ m.lastAccessed = $now,
419
+ m.activationCount = m.activationCount + 1`, {
420
+ id: input.episodeId,
421
+ memoryType: input.memoryType,
422
+ label: input.label.slice(0, 100),
423
+ projectId: input.projectId ?? null,
424
+ now,
425
+ });
426
+ // 2. Session node + OCCURRED_IN edge
427
+ await tx.run(`MERGE (s:Session {sessionId: $sessionId})
428
+ ON CREATE SET
429
+ s.id = $sessionId,
430
+ s.startTime = $now,
431
+ s.createdAt = $now,
432
+ s.lastAccessed = $now,
433
+ s.activationCount = 0
434
+ ON MATCH SET
435
+ s.lastAccessed = $now
436
+ WITH s
437
+ MATCH (m:Memory {id: $memoryId})
438
+ MERGE (m)-[r:OCCURRED_IN]->(s)
439
+ ON CREATE SET
440
+ r.weight = 1.0,
441
+ r.createdAt = $now,
442
+ r.lastTraversed = $now,
443
+ r.traversalCount = 1`, { sessionId: input.sessionId, memoryId: input.episodeId, now });
444
+ // 3. TimeContext node + OCCURRED_AT edge
445
+ await tx.run(`MERGE (t:TimeContext {id: $timeContextId})
446
+ ON CREATE SET
447
+ t.yearWeek = $yearWeek,
448
+ t.dayOfWeek = $dayOfWeek,
449
+ t.timeOfDay = $timeOfDay,
450
+ t.createdAt = $now,
451
+ t.lastAccessed = $now,
452
+ t.activationCount = 0
453
+ ON MATCH SET
454
+ t.lastAccessed = $now
455
+ WITH t
456
+ MATCH (m:Memory {id: $memoryId})
457
+ MERGE (m)-[r:OCCURRED_AT]->(t)
458
+ ON CREATE SET
459
+ r.weight = 0.5,
460
+ r.createdAt = $now,
461
+ r.lastTraversed = $now,
462
+ r.traversalCount = 1`, { timeContextId, yearWeek, dayOfWeek, timeOfDay, memoryId: input.episodeId, now });
463
+ // 4. Person nodes + SPOKE edges
464
+ for (const personName of input.persons) {
465
+ const personId = `person:${normalizeForId(personName)}`;
466
+ await tx.run(`MERGE (p:Person {id: $personId})
467
+ ON CREATE SET
468
+ p.name = $name,
469
+ p.aliases = '[]',
470
+ p.firstSeen = $now,
471
+ p.lastSeen = $now,
472
+ p.createdAt = $now,
473
+ p.lastAccessed = $now,
474
+ p.activationCount = 0
475
+ ON MATCH SET
476
+ p.lastSeen = $now,
477
+ p.lastAccessed = $now
478
+ WITH p
479
+ MATCH (m:Memory {id: $memoryId})
480
+ MERGE (m)-[r:SPOKE]->(p)
481
+ ON CREATE SET
482
+ r.weight = 0.7,
483
+ r.createdAt = $now,
484
+ r.lastTraversed = $now,
485
+ r.traversalCount = 1
486
+ ON MATCH SET
487
+ r.lastTraversed = $now,
488
+ r.traversalCount = r.traversalCount + 1`, { personId, name: personName, memoryId: input.episodeId, now });
489
+ }
490
+ // 5. Entity nodes + CONTEXTUAL edges
491
+ for (const entity of input.entities) {
492
+ const entityId = `entity:${normalizeForId(entity.name)}`;
493
+ await tx.run(`MERGE (e:Entity {id: $entityId})
494
+ ON CREATE SET
495
+ e.name = $name,
496
+ e.entityType = $entityType,
497
+ e.createdAt = $now,
498
+ e.lastAccessed = $now,
499
+ e.activationCount = 0
500
+ ON MATCH SET
501
+ e.lastAccessed = $now
502
+ WITH e
503
+ MATCH (m:Memory {id: $memoryId})
504
+ MERGE (m)-[r:CONTEXTUAL]->(e)
505
+ ON CREATE SET
506
+ r.weight = 0.6,
507
+ r.createdAt = $now,
508
+ r.lastTraversed = $now,
509
+ r.traversalCount = 1
510
+ ON MATCH SET
511
+ r.lastTraversed = $now,
512
+ r.traversalCount = r.traversalCount + 1`, { entityId, name: entity.name, entityType: entity.entityType, memoryId: input.episodeId, now });
513
+ }
514
+ // 6. Emotion node + EMOTIONAL edge
515
+ if (input.emotion) {
516
+ const emotionId = `emotion:${input.sessionId}:${input.emotion.label}`;
517
+ await tx.run(`MERGE (e:Emotion {id: $emotionId})
518
+ ON CREATE SET
519
+ e.label = $label,
520
+ e.intensity = $intensity,
521
+ e.sessionScoped = true,
522
+ e.sessionId = $sessionId,
523
+ e.createdAt = $now,
524
+ e.lastAccessed = $now,
525
+ e.activationCount = 0
526
+ ON MATCH SET
527
+ e.intensity = CASE WHEN $intensity > e.intensity THEN $intensity ELSE e.intensity END,
528
+ e.lastAccessed = $now
529
+ WITH e
530
+ MATCH (m:Memory {id: $memoryId})
531
+ MERGE (m)-[r:EMOTIONAL]->(e)
532
+ ON CREATE SET
533
+ r.weight = $intensity,
534
+ r.createdAt = $now,
535
+ r.lastTraversed = $now,
536
+ r.traversalCount = 1`, {
537
+ emotionId,
538
+ label: input.emotion.label,
539
+ intensity: input.emotion.intensity,
540
+ sessionId: input.sessionId,
541
+ memoryId: input.episodeId,
542
+ now,
543
+ });
544
+ }
545
+ // 7. Intent node + INTENTIONAL edge
546
+ if (input.intent) {
547
+ const intentId = `intent:${input.sessionId}:${input.intent}`;
548
+ await tx.run(`MERGE (i:Intent {id: $intentId})
549
+ ON CREATE SET
550
+ i.intentType = $intentType,
551
+ i.sessionScoped = true,
552
+ i.sessionId = $sessionId,
553
+ i.createdAt = $now,
554
+ i.lastAccessed = $now,
555
+ i.activationCount = 0
556
+ ON MATCH SET
557
+ i.lastAccessed = $now
558
+ WITH i
559
+ MATCH (m:Memory {id: $memoryId})
560
+ MERGE (m)-[r:INTENTIONAL]->(i)
561
+ ON CREATE SET
562
+ r.weight = 0.5,
563
+ r.createdAt = $now,
564
+ r.lastTraversed = $now,
565
+ r.traversalCount = 1`, {
566
+ intentId,
567
+ intentType: input.intent,
568
+ sessionId: input.sessionId,
569
+ memoryId: input.episodeId,
570
+ now,
571
+ });
572
+ }
573
+ });
574
+ }
575
+ finally {
576
+ await session.close();
577
+ }
578
+ }
579
+ // --------------------------------------------------------------------------
580
+ // Schema Management
581
+ // --------------------------------------------------------------------------
582
+ async ensureSchema() {
583
+ const session = this.driver.session();
584
+ try {
585
+ for (const statement of ALL_SCHEMA_STATEMENTS) {
586
+ await session.run(statement);
587
+ }
588
+ }
589
+ finally {
590
+ await session.close();
591
+ }
592
+ }
593
+ // --------------------------------------------------------------------------
594
+ // Health
595
+ // --------------------------------------------------------------------------
596
+ async ping() {
597
+ try {
598
+ await this.driver.verifyConnectivity();
599
+ return true;
600
+ }
601
+ catch {
602
+ return false;
603
+ }
604
+ }
605
+ // --------------------------------------------------------------------------
606
+ // Stats
607
+ // --------------------------------------------------------------------------
608
+ async stats() {
609
+ const session = this.driver.session();
610
+ try {
611
+ const nodeCountResult = await session.run(`MATCH (n)
612
+ UNWIND labels(n) AS label
613
+ RETURN label, count(n) AS count
614
+ ORDER BY label`);
615
+ const relCountResult = await session.run(`MATCH ()-[r]->()
616
+ RETURN type(r) AS type, count(r) AS count
617
+ ORDER BY type`);
618
+ const nodes = {};
619
+ for (const record of nodeCountResult.records) {
620
+ const label = record.get('label');
621
+ const count = record.get('count').toNumber();
622
+ nodes[label] = count;
623
+ }
624
+ const relationships = {};
625
+ for (const record of relCountResult.records) {
626
+ const type = record.get('type');
627
+ const count = record.get('count').toNumber();
628
+ relationships[type] = count;
629
+ }
630
+ const totalNodes = Object.values(nodes).reduce((a, b) => a + b, 0);
631
+ const totalRels = Object.values(relationships).reduce((a, b) => a + b, 0);
632
+ return { nodes, relationships, total: { nodes: totalNodes, relationships: totalRels } };
633
+ }
634
+ finally {
635
+ await session.close();
636
+ }
637
+ }
638
+ // --------------------------------------------------------------------------
639
+ // Cleanup (for testing)
640
+ // --------------------------------------------------------------------------
641
+ async clearAll() {
642
+ const session = this.driver.session();
643
+ try {
644
+ let deleted = 1;
645
+ while (deleted > 0) {
646
+ const result = await session.run(`MATCH (n) WITH n LIMIT 10000
647
+ DETACH DELETE n
648
+ RETURN count(*) AS deleted`);
649
+ deleted = result.records[0]?.get('deleted')?.toNumber() ?? 0;
650
+ }
651
+ }
652
+ finally {
653
+ await session.close();
654
+ }
655
+ }
656
+ // --------------------------------------------------------------------------
657
+ // Wave 2 Facade: connectivity, entity lookup, spreading activation,
658
+ // simplified ingestion, edge strengthening. These wrap lower-level methods
659
+ // with the shape Wave 2's retrieval pipeline expects.
660
+ // --------------------------------------------------------------------------
661
+ /**
662
+ * Availability check — alias for ping(). Returns true iff Neo4j is reachable
663
+ * and responsive. Does NOT throw.
664
+ */
665
+ async isAvailable() {
666
+ return this.ping();
667
+ }
668
+ /**
669
+ * Look up Person, Entity, and Topic nodes by name. Case-insensitive match
670
+ * on Entity and Topic; exact match on Person.name.
671
+ *
672
+ * Used by Wave 2's entity-based seed injection: extract names from the
673
+ * query, pass them here, feed the resulting node IDs into spreading
674
+ * activation as independent graph seeds.
675
+ */
676
+ async lookupEntityNodes(names) {
677
+ if (names.length === 0)
678
+ return [];
679
+ const session = this.driver.session();
680
+ try {
681
+ const result = await session.executeRead(async (tx) => {
682
+ return tx.run(`UNWIND $names AS name
683
+ MATCH (n)
684
+ WHERE (n:Person AND n.name = name)
685
+ OR (n:Entity AND toLower(n.name) = toLower(name))
686
+ OR (n:Topic AND toLower(n.name) = toLower(name))
687
+ RETURN DISTINCT n.id AS nodeId,
688
+ labels(n)[0] AS nodeType,
689
+ n.name AS name`, { names });
690
+ });
691
+ return result.records.map((record) => ({
692
+ nodeId: record.get('nodeId'),
693
+ nodeType: record.get('nodeType'),
694
+ name: record.get('name'),
695
+ }));
696
+ }
697
+ catch (err) {
698
+ // Non-fatal: caller treats an empty result the same as "no entities"
699
+ return [];
700
+ }
701
+ finally {
702
+ await session.close();
703
+ }
704
+ }
705
+ /**
706
+ * Wave 2 spreading activation facade. Wraps the standalone
707
+ * SpreadingActivation class with the option shape Wave 2 uses.
708
+ *
709
+ * Note: seedActivations is accepted for forward compatibility but the
710
+ * underlying Cypher query uses uniform activation (1.0) per seed.
711
+ * Per-seed initial weights are a Wave 3 enhancement.
712
+ */
713
+ async spreadActivation(opts) {
714
+ const sa = new SpreadingActivation(this.driver);
715
+ const params = {
716
+ maxHops: opts.maxHops ?? 2,
717
+ decayPerHop: opts.decay ?? 0.6,
718
+ minActivation: opts.threshold ?? 0.05,
719
+ maxNodes: opts.budget ?? 100,
720
+ edgeTypeFilter: (opts.edgeFilter ?? []),
721
+ };
722
+ const results = await sa.activate(opts.seedNodeIds, params);
723
+ return results.map((r) => ({
724
+ nodeId: r.nodeId,
725
+ nodeType: r.nodeType,
726
+ activation: r.activation,
727
+ depth: r.hops,
728
+ properties: r.properties,
729
+ }));
730
+ }
731
+ /**
732
+ * Strengthen a set of directed edge pairs (sourceId, targetId).
733
+ * For each pair, increments weight by 0.02 (capped at 1.0), bumps
734
+ * traversalCount, and updates lastTraversed.
735
+ *
736
+ * This is the reconsolidation analog: edges we actually traversed
737
+ * during recall get slightly stronger, making future retrieval faster.
738
+ */
739
+ async strengthenTraversedEdges(pairs) {
740
+ if (pairs.length === 0)
741
+ return;
742
+ const now = new Date().toISOString();
743
+ const session = this.driver.session();
744
+ try {
745
+ await session.executeWrite(async (tx) => {
746
+ await tx.run(`UNWIND $pairs AS pair
747
+ MATCH (a)-[r]->(b)
748
+ WHERE a.id = pair[0] AND b.id = pair[1]
749
+ SET r.weight = CASE
750
+ WHEN r.weight + 0.02 > 1.0 THEN 1.0
751
+ ELSE r.weight + 0.02
752
+ END,
753
+ r.lastTraversed = $now,
754
+ r.traversalCount = coalesce(r.traversalCount, 0) + 1`, { pairs, now });
755
+ });
756
+ }
757
+ finally {
758
+ await session.close();
759
+ }
760
+ }
761
+ /**
762
+ * Simplified ingestion path for Wave 2. Takes a core-shaped episode,
763
+ * extracts persons/emotion internally (or uses LLM-extracted entities
764
+ * when provided), and delegates to decomposeEpisode().
765
+ *
766
+ * Also creates the TEMPORAL edge from previousEpisodeId when provided.
767
+ */
768
+ async ingestEpisode(input) {
769
+ const timestamp = input.createdAt instanceof Date
770
+ ? input.createdAt
771
+ : new Date(input.createdAt);
772
+ const emotionResult = classifyEmotion(input.content);
773
+ const emotion = emotionResult.label !== 'neutral'
774
+ ? { label: emotionResult.label, intensity: emotionResult.intensity }
775
+ : null;
776
+ const label = input.content.slice(0, 100);
777
+ // Decide the entity source: LLM-extracted (precise) or regex fallback.
778
+ let persons;
779
+ let typedEntities;
780
+ if (input.llmEntities && input.llmEntities.length > 0) {
781
+ // LLM path: use structured typed entities. Higher precision; no
782
+ // regex extraction runs.
783
+ persons = input.llmEntities
784
+ .filter((e) => e.type === 'person')
785
+ .map((e) => e.name);
786
+ typedEntities = input.llmEntities
787
+ .filter((e) => e.type !== 'person')
788
+ .map((e) => {
789
+ // Map LLM types to Wave 1's EntityNodeInput.entityType enum.
790
+ // 'org' collapses to 'concept' because Wave 1 has no 'org' type.
791
+ const entityType = e.type === 'tech' ? 'tech'
792
+ : e.type === 'project' ? 'project'
793
+ : 'concept';
794
+ return { name: e.name, entityType };
795
+ });
796
+ }
797
+ else {
798
+ // Regex fallback path: the old heuristic extractors.
799
+ persons = extractPersons(input.content).map((p) => p.name);
800
+ typedEntities = input.entities.map((name) => ({
801
+ name,
802
+ entityType: 'tech',
803
+ }));
804
+ }
805
+ await this.decomposeEpisode({
806
+ episodeId: input.id,
807
+ memoryType: 'episode',
808
+ label,
809
+ sessionId: input.sessionId,
810
+ timestamp,
811
+ persons,
812
+ entities: typedEntities,
813
+ emotion,
814
+ intent: null,
815
+ });
816
+ // PROJECT edge: tag this memory with its project scope.
817
+ // Uses MERGE on Project.id = 'project:<name>' so project nodes are
818
+ // singletons across the whole graph — all memories from the same
819
+ // project share the node, which means Wave 2 spreading activation
820
+ // from a Project seed naturally pulls in all project memories.
821
+ if (input.project && input.project !== 'global') {
822
+ const projectId = `project:${normalizeForId(input.project)}`;
823
+ const now = new Date().toISOString();
824
+ const session = this.driver.session();
825
+ try {
826
+ await session.executeWrite(async (tx) => {
827
+ await tx.run(`MERGE (p:Project {id: $projectId})
828
+ ON CREATE SET
829
+ p.name = $name,
830
+ p.createdAt = $now,
831
+ p.lastAccessed = $now,
832
+ p.activationCount = 0
833
+ ON MATCH SET
834
+ p.lastAccessed = $now
835
+ WITH p
836
+ MATCH (m:Memory {id: $memoryId})
837
+ MERGE (m)-[r:PROJECT]->(p)
838
+ ON CREATE SET
839
+ r.weight = 1.0,
840
+ r.createdAt = $now,
841
+ r.lastTraversed = $now,
842
+ r.traversalCount = 1
843
+ ON MATCH SET
844
+ r.lastTraversed = $now,
845
+ r.traversalCount = r.traversalCount + 1`, { projectId, name: input.project, memoryId: input.id, now });
846
+ });
847
+ }
848
+ finally {
849
+ await session.close();
850
+ }
851
+ }
852
+ // TEMPORAL edge from previous episode in the same session.
853
+ //
854
+ // Race condition fix: Memory.ingest() calls ingestEpisode in a
855
+ // fire-and-forget manner, which means episode N+1's decomposition can
856
+ // start before episode N's Memory node finishes being written. The
857
+ // previous implementation used MATCH (prev:Memory {id: ...}) which
858
+ // silently produced no edge when prev didn't exist yet, dropping ~18%
859
+ // of TEMPORAL edges in practice.
860
+ //
861
+ // The fix uses MERGE for BOTH endpoints. If the previous Memory node
862
+ // doesn't exist yet, we create a stub with just the id; its properties
863
+ // will be filled in by ON MATCH when its own decomposeEpisode arrives.
864
+ // This is safe because decomposeEpisode also uses MERGE on Memory {id},
865
+ // so a stub will be populated rather than duplicated.
866
+ if (input.previousEpisodeId) {
867
+ const session = this.driver.session();
868
+ try {
869
+ await session.executeWrite(async (tx) => {
870
+ await tx.run(`MERGE (prev:Memory {id: $prevId})
871
+ MERGE (curr:Memory {id: $currId})
872
+ MERGE (prev)-[r:TEMPORAL]->(curr)
873
+ ON CREATE SET
874
+ r.weight = 0.8,
875
+ r.createdAt = $now,
876
+ r.lastTraversed = $now,
877
+ r.traversalCount = 1
878
+ ON MATCH SET
879
+ r.lastTraversed = $now,
880
+ r.traversalCount = r.traversalCount + 1`, {
881
+ prevId: input.previousEpisodeId,
882
+ currId: input.id,
883
+ now: new Date().toISOString(),
884
+ });
885
+ });
886
+ }
887
+ finally {
888
+ await session.close();
889
+ }
890
+ }
891
+ }
892
+ }
893
+ //# sourceMappingURL=neural-graph.js.map