ava-langgraph-narrative-intelligence 0.1.0

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.
Files changed (58) hide show
  1. package/README.md +268 -0
  2. package/dist/graphs/index.cjs +1511 -0
  3. package/dist/graphs/index.cjs.map +1 -0
  4. package/dist/graphs/index.d.cts +2 -0
  5. package/dist/graphs/index.d.ts +2 -0
  6. package/dist/graphs/index.js +1468 -0
  7. package/dist/graphs/index.js.map +1 -0
  8. package/dist/index-Btxk3nQm.d.cts +430 -0
  9. package/dist/index-CgXXxuIH.d.ts +430 -0
  10. package/dist/index-CweT-D3c.d.cts +122 -0
  11. package/dist/index-D-zWH42e.d.cts +66 -0
  12. package/dist/index-D71kh3nE.d.cts +213 -0
  13. package/dist/index-DApls3w2.d.ts +66 -0
  14. package/dist/index-UamXITgg.d.ts +122 -0
  15. package/dist/index-v9AlRC0M.d.ts +213 -0
  16. package/dist/index.cjs +2753 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +6 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +2654 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integrations/index.cjs +654 -0
  23. package/dist/integrations/index.cjs.map +1 -0
  24. package/dist/integrations/index.d.cts +2 -0
  25. package/dist/integrations/index.d.ts +2 -0
  26. package/dist/integrations/index.js +614 -0
  27. package/dist/integrations/index.js.map +1 -0
  28. package/dist/ncp-tXS9Jr9e.d.cts +132 -0
  29. package/dist/ncp-tXS9Jr9e.d.ts +132 -0
  30. package/dist/nodes/index.cjs +226 -0
  31. package/dist/nodes/index.cjs.map +1 -0
  32. package/dist/nodes/index.d.cts +2 -0
  33. package/dist/nodes/index.d.ts +2 -0
  34. package/dist/nodes/index.js +196 -0
  35. package/dist/nodes/index.js.map +1 -0
  36. package/dist/schemas/index.cjs +550 -0
  37. package/dist/schemas/index.cjs.map +1 -0
  38. package/dist/schemas/index.d.cts +2 -0
  39. package/dist/schemas/index.d.ts +2 -0
  40. package/dist/schemas/index.js +484 -0
  41. package/dist/schemas/index.js.map +1 -0
  42. package/dist/unified_state_bridge-CIDm1kuf.d.cts +266 -0
  43. package/dist/unified_state_bridge-CIDm1kuf.d.ts +266 -0
  44. package/package.json +91 -0
  45. package/src/graphs/coherence_engine.ts +1027 -0
  46. package/src/graphs/index.ts +47 -0
  47. package/src/graphs/three_universe_processor.ts +1136 -0
  48. package/src/index.ts +181 -0
  49. package/src/integrations/index.ts +17 -0
  50. package/src/integrations/redis_state.ts +691 -0
  51. package/src/nodes/emotional_classifier.ts +289 -0
  52. package/src/nodes/index.ts +17 -0
  53. package/src/schemas/index.ts +75 -0
  54. package/src/schemas/ncp.ts +312 -0
  55. package/src/schemas/unified_state_bridge.ts +681 -0
  56. package/src/tests/coherence_engine.test.ts +273 -0
  57. package/src/tests/three_universe_processor.test.ts +309 -0
  58. package/src/tests/unified_state_bridge.test.ts +360 -0
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Redis State Manager for Narrative Intelligence
3
+ *
4
+ * Provides Redis-backed persistence for the UnifiedNarrativeState,
5
+ * enabling cross-system state sharing and mid-story resumption.
6
+ *
7
+ * This integrates with:
8
+ * - Miadi-46's Redis patterns (webhook event storage)
9
+ * - ava-langflow's redis_state.py (session state)
10
+ * - LangGraph checkpointing (for graph state persistence)
11
+ */
12
+
13
+ import {
14
+ UnifiedNarrativeState,
15
+ StoryBeat,
16
+ ThreeUniverseAnalysis,
17
+ RoutingDecision,
18
+ RedisKeys,
19
+ createUnifiedNarrativeState,
20
+ serializeState,
21
+ deserializeState,
22
+ addBeat,
23
+ addRoutingDecision,
24
+ startNewEpisode,
25
+ } from "../schemas/unified_state_bridge.js";
26
+
27
+ /**
28
+ * Configuration for Redis connection.
29
+ */
30
+ export interface RedisConfig {
31
+ host: string;
32
+ port: number;
33
+ db: number;
34
+ password?: string;
35
+ url?: string; // Alternative: full redis URL
36
+
37
+ // TTL settings
38
+ stateTtlHours: number;
39
+ beatTtlHours: number;
40
+ eventCacheTtlHours: number;
41
+
42
+ // Connection pool
43
+ maxConnections: number;
44
+ decodeResponses: boolean;
45
+ }
46
+
47
+ /**
48
+ * Create a RedisConfig with defaults.
49
+ */
50
+ export function createRedisConfig(
51
+ options: Partial<RedisConfig> = {}
52
+ ): RedisConfig {
53
+ return {
54
+ host: options.host ?? "localhost",
55
+ port: options.port ?? 6379,
56
+ db: options.db ?? 0,
57
+ password: options.password,
58
+ url: options.url,
59
+ stateTtlHours: options.stateTtlHours ?? 168, // 1 week
60
+ beatTtlHours: options.beatTtlHours ?? 720, // 30 days
61
+ eventCacheTtlHours: options.eventCacheTtlHours ?? 24, // 1 day
62
+ maxConnections: options.maxConnections ?? 10,
63
+ decodeResponses: options.decodeResponses ?? true,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Redis client interface for compatibility with various Redis clients.
69
+ */
70
+ export interface RedisClient {
71
+ ping(): Promise<string>;
72
+ get(key: string): Promise<string | null>;
73
+ set(key: string, value: string): Promise<unknown>;
74
+ setex(key: string, ttl: number, value: string): Promise<unknown>;
75
+ del(...keys: string[]): Promise<number>;
76
+ keys(pattern: string): Promise<string[]>;
77
+ rpush(key: string, value: string): Promise<number>;
78
+ lrange(key: string, start: number, end: number): Promise<string[]>;
79
+ ltrim(key: string, start: number, end: number): Promise<unknown>;
80
+ expire(key: string, ttl: number): Promise<unknown>;
81
+ quit(): Promise<unknown>;
82
+ }
83
+
84
+ /**
85
+ * Health check result.
86
+ */
87
+ export interface HealthCheckResult {
88
+ status: "healthy" | "unhealthy";
89
+ connected: boolean;
90
+ latencyMs?: number;
91
+ error?: string;
92
+ timestamp: string;
93
+ }
94
+
95
+ /**
96
+ * Redis-backed state management for narrative intelligence.
97
+ *
98
+ * Responsibilities:
99
+ * - Store and retrieve UnifiedNarrativeState
100
+ * - Manage story beat persistence
101
+ * - Cache three-universe analysis results
102
+ * - Track routing decision history
103
+ * - Enable cross-system state sharing
104
+ *
105
+ * @example
106
+ * const manager = new NarrativeRedisManager(createRedisConfig());
107
+ * await manager.connect();
108
+ *
109
+ * // Get or create state
110
+ * const state = await manager.getOrCreateState("story_123", "session_456");
111
+ *
112
+ * // Add a beat
113
+ * await manager.addBeat("session_456", beat);
114
+ *
115
+ * // Get current state
116
+ * const state = await manager.getState("session_456");
117
+ *
118
+ * await manager.disconnect();
119
+ */
120
+ export class NarrativeRedisManager {
121
+ private config: RedisConfig;
122
+ private redis: RedisClient | null = null;
123
+ private connected = false;
124
+
125
+ constructor(config?: RedisConfig) {
126
+ this.config = config ?? createRedisConfig();
127
+ }
128
+
129
+ /**
130
+ * Establish Redis connection.
131
+ */
132
+ async connect(client?: RedisClient): Promise<void> {
133
+ if (client) {
134
+ // Use provided client
135
+ this.redis = client;
136
+ await this.redis.ping();
137
+ this.connected = true;
138
+ return;
139
+ }
140
+
141
+ // Try to use ioredis if available
142
+ try {
143
+ // Dynamic import for optional Redis dependency
144
+ const Redis = await import("ioredis").then((m) => m.default).catch(() => null);
145
+
146
+ if (Redis) {
147
+ const redisUrl =
148
+ this.config.url ??
149
+ `redis://${this.config.password ? `:${this.config.password}@` : ""}${this.config.host}:${this.config.port}/${this.config.db}`;
150
+
151
+ const redis = new Redis(redisUrl);
152
+ await redis.ping();
153
+
154
+ this.redis = {
155
+ ping: () => redis.ping(),
156
+ get: (key) => redis.get(key),
157
+ set: (key, value) => redis.set(key, value),
158
+ setex: (key, ttl, value) => redis.setex(key, ttl, value),
159
+ del: (...keys) => redis.del(...keys),
160
+ keys: (pattern) => redis.keys(pattern),
161
+ rpush: (key, value) => redis.rpush(key, value),
162
+ lrange: (key, start, end) => redis.lrange(key, start, end),
163
+ ltrim: (key, start, end) => redis.ltrim(key, start, end),
164
+ expire: (key, ttl) => redis.expire(key, ttl),
165
+ quit: () => redis.quit(),
166
+ };
167
+ this.connected = true;
168
+ console.log("Connected to Redis for narrative state management");
169
+ return;
170
+ }
171
+ } catch {
172
+ // Redis not available, use mock
173
+ }
174
+
175
+ // Fall back to mock
176
+ console.log("Redis not available, using mock mode");
177
+ this.redis = new MockRedis();
178
+ this.connected = true;
179
+ }
180
+
181
+ /**
182
+ * Close Redis connection.
183
+ */
184
+ async disconnect(): Promise<void> {
185
+ if (this.redis) {
186
+ await this.redis.quit();
187
+ }
188
+ this.connected = false;
189
+ }
190
+
191
+ // =========================================================================
192
+ // State Management
193
+ // =========================================================================
194
+
195
+ /**
196
+ * Retrieve narrative state for a session.
197
+ */
198
+ async getState(sessionId: string): Promise<UnifiedNarrativeState | null> {
199
+ if (!this.redis) return null;
200
+
201
+ const key = RedisKeys.state(sessionId);
202
+ const data = await this.redis.get(key);
203
+
204
+ if (data) {
205
+ try {
206
+ return deserializeState(data);
207
+ } catch (e) {
208
+ console.error(`Failed to deserialize state for ${sessionId}:`, e);
209
+ return null;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * Get existing state or create new one.
217
+ */
218
+ async getOrCreateState(
219
+ storyId: string,
220
+ sessionId: string,
221
+ options: {
222
+ includeDefaultCharacters?: boolean;
223
+ includeDefaultThemes?: boolean;
224
+ } = {}
225
+ ): Promise<UnifiedNarrativeState> {
226
+ const existing = await this.getState(sessionId);
227
+ if (existing) {
228
+ return existing;
229
+ }
230
+
231
+ // Create new state
232
+ const state = createUnifiedNarrativeState(storyId, sessionId, options);
233
+
234
+ // Save it
235
+ await this.saveState(state);
236
+
237
+ return state;
238
+ }
239
+
240
+ /**
241
+ * Save narrative state to Redis.
242
+ */
243
+ async saveState(state: UnifiedNarrativeState): Promise<boolean> {
244
+ if (!this.redis) return false;
245
+
246
+ try {
247
+ const key = RedisKeys.state(state.sessionId);
248
+ state.updatedAt = new Date().toISOString();
249
+
250
+ const ttlSeconds = this.config.stateTtlHours * 3600;
251
+ await this.redis.setex(key, ttlSeconds, serializeState(state));
252
+
253
+ // Also update "current" pointer if this is the active state
254
+ await this.redis.set(RedisKeys.currentState(), state.sessionId);
255
+
256
+ return true;
257
+ } catch (e) {
258
+ console.error("Failed to save state:", e);
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Get the ID of the currently active session.
265
+ */
266
+ async getCurrentSessionId(): Promise<string | null> {
267
+ if (!this.redis) return null;
268
+ return this.redis.get(RedisKeys.currentState());
269
+ }
270
+
271
+ /**
272
+ * Set the currently active session.
273
+ */
274
+ async setCurrentSession(sessionId: string): Promise<void> {
275
+ if (this.redis) {
276
+ await this.redis.set(RedisKeys.currentState(), sessionId);
277
+ }
278
+ }
279
+
280
+ // =========================================================================
281
+ // Beat Management
282
+ // =========================================================================
283
+
284
+ /**
285
+ * Add a story beat to the session.
286
+ */
287
+ async addBeatToSession(
288
+ sessionId: string,
289
+ beat: StoryBeat
290
+ ): Promise<boolean> {
291
+ if (!this.redis) return false;
292
+
293
+ try {
294
+ // Store individual beat
295
+ const beatKey = RedisKeys.beat(beat.id);
296
+ const ttlSeconds = this.config.beatTtlHours * 3600;
297
+ await this.redis.setex(beatKey, ttlSeconds, JSON.stringify(beat));
298
+
299
+ // Add to session's beat list
300
+ const beatsKey = RedisKeys.beats(sessionId);
301
+ await this.redis.rpush(beatsKey, beat.id);
302
+ await this.redis.expire(beatsKey, ttlSeconds);
303
+
304
+ // Update state
305
+ const state = await this.getState(sessionId);
306
+ if (state) {
307
+ addBeat(state, beat);
308
+ await this.saveState(state);
309
+ }
310
+
311
+ return true;
312
+ } catch (e) {
313
+ console.error("Failed to add beat:", e);
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Retrieve a specific beat by ID.
320
+ */
321
+ async getBeat(beatId: string): Promise<StoryBeat | null> {
322
+ if (!this.redis) return null;
323
+
324
+ const key = RedisKeys.beat(beatId);
325
+ const data = await this.redis.get(key);
326
+
327
+ if (data) {
328
+ try {
329
+ return JSON.parse(data) as StoryBeat;
330
+ } catch (e) {
331
+ console.error(`Failed to deserialize beat ${beatId}:`, e);
332
+ return null;
333
+ }
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * Get the most recent beats for a session.
340
+ */
341
+ async getRecentBeats(
342
+ sessionId: string,
343
+ count: number = 10
344
+ ): Promise<StoryBeat[]> {
345
+ if (!this.redis) return [];
346
+
347
+ const beatsKey = RedisKeys.beats(sessionId);
348
+ const beatIds = await this.redis.lrange(beatsKey, -count, -1);
349
+
350
+ const beats: StoryBeat[] = [];
351
+ for (const beatId of beatIds.reverse()) {
352
+ // Most recent first
353
+ const beat = await this.getBeat(beatId);
354
+ if (beat) {
355
+ beats.push(beat);
356
+ }
357
+ }
358
+
359
+ return beats;
360
+ }
361
+
362
+ // =========================================================================
363
+ // Event Analysis Caching
364
+ // =========================================================================
365
+
366
+ /**
367
+ * Cache three-universe analysis for a webhook event.
368
+ */
369
+ async cacheEventAnalysis(
370
+ eventId: string,
371
+ analysis: ThreeUniverseAnalysis
372
+ ): Promise<boolean> {
373
+ if (!this.redis) return false;
374
+
375
+ try {
376
+ const key = RedisKeys.eventAnalysis(eventId);
377
+ const ttlSeconds = this.config.eventCacheTtlHours * 3600;
378
+ await this.redis.setex(key, ttlSeconds, JSON.stringify(analysis));
379
+ return true;
380
+ } catch (e) {
381
+ console.error("Failed to cache event analysis:", e);
382
+ return false;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Retrieve cached analysis for an event.
388
+ */
389
+ async getCachedAnalysis(
390
+ eventId: string
391
+ ): Promise<ThreeUniverseAnalysis | null> {
392
+ if (!this.redis) return null;
393
+
394
+ const key = RedisKeys.eventAnalysis(eventId);
395
+ const data = await this.redis.get(key);
396
+
397
+ if (data) {
398
+ try {
399
+ return JSON.parse(data) as ThreeUniverseAnalysis;
400
+ } catch (e) {
401
+ console.error(`Failed to deserialize analysis for ${eventId}:`, e);
402
+ return null;
403
+ }
404
+ }
405
+ return null;
406
+ }
407
+
408
+ // =========================================================================
409
+ // Routing History
410
+ // =========================================================================
411
+
412
+ /**
413
+ * Record a routing decision for learning and tracing.
414
+ */
415
+ async recordRoutingDecision(
416
+ sessionId: string,
417
+ decision: RoutingDecision
418
+ ): Promise<boolean> {
419
+ if (!this.redis) return false;
420
+
421
+ try {
422
+ const key = RedisKeys.routingHistory(sessionId);
423
+ await this.redis.rpush(key, JSON.stringify(decision));
424
+
425
+ // Keep only last 100 decisions per session
426
+ await this.redis.ltrim(key, -100, -1);
427
+
428
+ // Update state
429
+ const state = await this.getState(sessionId);
430
+ if (state) {
431
+ addRoutingDecision(state, decision);
432
+ await this.saveState(state);
433
+ }
434
+
435
+ return true;
436
+ } catch (e) {
437
+ console.error("Failed to record routing decision:", e);
438
+ return false;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Get recent routing decisions for a session.
444
+ */
445
+ async getRoutingHistory(
446
+ sessionId: string,
447
+ count: number = 50
448
+ ): Promise<RoutingDecision[]> {
449
+ if (!this.redis) return [];
450
+
451
+ const key = RedisKeys.routingHistory(sessionId);
452
+ const dataList = await this.redis.lrange(key, -count, -1);
453
+
454
+ const decisions: RoutingDecision[] = [];
455
+ for (const data of dataList) {
456
+ try {
457
+ decisions.push(JSON.parse(data) as RoutingDecision);
458
+ } catch (e) {
459
+ console.warn("Failed to deserialize routing decision:", e);
460
+ }
461
+ }
462
+
463
+ return decisions;
464
+ }
465
+
466
+ // =========================================================================
467
+ // Episode Management
468
+ // =========================================================================
469
+
470
+ /**
471
+ * Mark the start of a new episode.
472
+ */
473
+ async startNewEpisode(
474
+ sessionId: string,
475
+ episodeId: string
476
+ ): Promise<boolean> {
477
+ const state = await this.getState(sessionId);
478
+ if (state) {
479
+ startNewEpisode(state, episodeId);
480
+ return this.saveState(state);
481
+ }
482
+ return false;
483
+ }
484
+
485
+ /**
486
+ * Get all beats for an episode.
487
+ */
488
+ async getEpisodeBeats(episodeId: string): Promise<StoryBeat[]> {
489
+ if (!this.redis) return [];
490
+
491
+ const key = RedisKeys.episode(episodeId);
492
+ const data = await this.redis.get(key);
493
+
494
+ if (data) {
495
+ try {
496
+ const episodeData = JSON.parse(data);
497
+ const beatIds = episodeData.beat_ids || [];
498
+ const beats: StoryBeat[] = [];
499
+
500
+ for (const beatId of beatIds) {
501
+ const beat = await this.getBeat(beatId);
502
+ if (beat) {
503
+ beats.push(beat);
504
+ }
505
+ }
506
+ return beats;
507
+ } catch (e) {
508
+ console.error("Failed to get episode beats:", e);
509
+ return [];
510
+ }
511
+ }
512
+ return [];
513
+ }
514
+
515
+ // =========================================================================
516
+ // Utility Methods
517
+ // =========================================================================
518
+
519
+ /**
520
+ * List all session IDs matching pattern.
521
+ */
522
+ async listSessions(pattern: string = "ncp:state:*"): Promise<string[]> {
523
+ if (!this.redis) return [];
524
+
525
+ const keys = await this.redis.keys(pattern);
526
+ return keys
527
+ .filter((k) => k !== "ncp:state:current")
528
+ .map((k) => k.replace("ncp:state:", ""));
529
+ }
530
+
531
+ /**
532
+ * Delete all data for a session.
533
+ */
534
+ async deleteSession(sessionId: string): Promise<boolean> {
535
+ if (!this.redis) return false;
536
+
537
+ try {
538
+ // Get beat IDs first
539
+ const beatsKey = RedisKeys.beats(sessionId);
540
+ const beatIds = await this.redis.lrange(beatsKey, 0, -1);
541
+
542
+ // Delete beats
543
+ for (const beatId of beatIds) {
544
+ await this.redis.del(RedisKeys.beat(beatId));
545
+ }
546
+
547
+ // Delete session keys
548
+ await this.redis.del(
549
+ RedisKeys.state(sessionId),
550
+ RedisKeys.beats(sessionId),
551
+ RedisKeys.routingHistory(sessionId)
552
+ );
553
+
554
+ console.log(`Deleted session ${sessionId}`);
555
+ return true;
556
+ } catch (e) {
557
+ console.error("Failed to delete session:", e);
558
+ return false;
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Check Redis connection health.
564
+ */
565
+ async healthCheck(): Promise<HealthCheckResult> {
566
+ try {
567
+ const start = Date.now();
568
+ if (this.redis) {
569
+ await this.redis.ping();
570
+ }
571
+ const latency = Date.now() - start;
572
+
573
+ return {
574
+ status: "healthy",
575
+ connected: this.connected,
576
+ latencyMs: latency,
577
+ timestamp: new Date().toISOString(),
578
+ };
579
+ } catch (e) {
580
+ return {
581
+ status: "unhealthy",
582
+ connected: this.connected,
583
+ error: e instanceof Error ? e.message : String(e),
584
+ timestamp: new Date().toISOString(),
585
+ };
586
+ }
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Mock Redis for development/testing without real Redis.
592
+ * Stores data in memory, mimics async Redis API.
593
+ */
594
+ export class MockRedis implements RedisClient {
595
+ private data: Map<string, string> = new Map();
596
+ private lists: Map<string, string[]> = new Map();
597
+ private expiry: Map<string, number> = new Map();
598
+
599
+ async ping(): Promise<string> {
600
+ return "PONG";
601
+ }
602
+
603
+ async get(key: string): Promise<string | null> {
604
+ this.checkExpiry(key);
605
+ return this.data.get(key) ?? null;
606
+ }
607
+
608
+ async set(key: string, value: string): Promise<string> {
609
+ this.data.set(key, value);
610
+ return "OK";
611
+ }
612
+
613
+ async setex(key: string, ttl: number, value: string): Promise<string> {
614
+ this.data.set(key, value);
615
+ this.expiry.set(key, Date.now() + ttl * 1000);
616
+ return "OK";
617
+ }
618
+
619
+ async del(...keys: string[]): Promise<number> {
620
+ let count = 0;
621
+ for (const key of keys) {
622
+ if (this.data.delete(key)) count++;
623
+ if (this.lists.delete(key)) count++;
624
+ }
625
+ return count;
626
+ }
627
+
628
+ async keys(pattern: string): Promise<string[]> {
629
+ const regex = new RegExp(
630
+ "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$"
631
+ );
632
+ return [...this.data.keys(), ...this.lists.keys()].filter((k) =>
633
+ regex.test(k)
634
+ );
635
+ }
636
+
637
+ async rpush(key: string, value: string): Promise<number> {
638
+ if (!this.lists.has(key)) {
639
+ this.lists.set(key, []);
640
+ }
641
+ this.lists.get(key)!.push(value);
642
+ return this.lists.get(key)!.length;
643
+ }
644
+
645
+ async lrange(key: string, start: number, end: number): Promise<string[]> {
646
+ const list = this.lists.get(key) ?? [];
647
+ const actualEnd = end === -1 ? list.length : end + 1;
648
+ const actualStart = start < 0 ? Math.max(0, list.length + start) : start;
649
+ return list.slice(actualStart, actualEnd);
650
+ }
651
+
652
+ async ltrim(key: string, start: number, end: number): Promise<string> {
653
+ const list = this.lists.get(key);
654
+ if (list) {
655
+ const actualEnd = end === -1 ? list.length : end + 1;
656
+ const actualStart = start < 0 ? Math.max(0, list.length + start) : start;
657
+ this.lists.set(key, list.slice(actualStart, actualEnd));
658
+ }
659
+ return "OK";
660
+ }
661
+
662
+ async expire(key: string, ttl: number): Promise<number> {
663
+ this.expiry.set(key, Date.now() + ttl * 1000);
664
+ return 1;
665
+ }
666
+
667
+ async quit(): Promise<string> {
668
+ return "OK";
669
+ }
670
+
671
+ private checkExpiry(key: string): void {
672
+ const expiry = this.expiry.get(key);
673
+ if (expiry && Date.now() > expiry) {
674
+ this.data.delete(key);
675
+ this.lists.delete(key);
676
+ this.expiry.delete(key);
677
+ }
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Get a configured narrative Redis manager.
683
+ */
684
+ export async function getNarrativeManager(
685
+ redisUrl?: string
686
+ ): Promise<NarrativeRedisManager> {
687
+ const config = createRedisConfig(redisUrl ? { url: redisUrl } : {});
688
+ const manager = new NarrativeRedisManager(config);
689
+ await manager.connect();
690
+ return manager;
691
+ }