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.
- package/README.md +268 -0
- package/dist/graphs/index.cjs +1511 -0
- package/dist/graphs/index.cjs.map +1 -0
- package/dist/graphs/index.d.cts +2 -0
- package/dist/graphs/index.d.ts +2 -0
- package/dist/graphs/index.js +1468 -0
- package/dist/graphs/index.js.map +1 -0
- package/dist/index-Btxk3nQm.d.cts +430 -0
- package/dist/index-CgXXxuIH.d.ts +430 -0
- package/dist/index-CweT-D3c.d.cts +122 -0
- package/dist/index-D-zWH42e.d.cts +66 -0
- package/dist/index-D71kh3nE.d.cts +213 -0
- package/dist/index-DApls3w2.d.ts +66 -0
- package/dist/index-UamXITgg.d.ts +122 -0
- package/dist/index-v9AlRC0M.d.ts +213 -0
- package/dist/index.cjs +2753 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2654 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/index.cjs +654 -0
- package/dist/integrations/index.cjs.map +1 -0
- package/dist/integrations/index.d.cts +2 -0
- package/dist/integrations/index.d.ts +2 -0
- package/dist/integrations/index.js +614 -0
- package/dist/integrations/index.js.map +1 -0
- package/dist/ncp-tXS9Jr9e.d.cts +132 -0
- package/dist/ncp-tXS9Jr9e.d.ts +132 -0
- package/dist/nodes/index.cjs +226 -0
- package/dist/nodes/index.cjs.map +1 -0
- package/dist/nodes/index.d.cts +2 -0
- package/dist/nodes/index.d.ts +2 -0
- package/dist/nodes/index.js +196 -0
- package/dist/nodes/index.js.map +1 -0
- package/dist/schemas/index.cjs +550 -0
- package/dist/schemas/index.cjs.map +1 -0
- package/dist/schemas/index.d.cts +2 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.js +484 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/unified_state_bridge-CIDm1kuf.d.cts +266 -0
- package/dist/unified_state_bridge-CIDm1kuf.d.ts +266 -0
- package/package.json +91 -0
- package/src/graphs/coherence_engine.ts +1027 -0
- package/src/graphs/index.ts +47 -0
- package/src/graphs/three_universe_processor.ts +1136 -0
- package/src/index.ts +181 -0
- package/src/integrations/index.ts +17 -0
- package/src/integrations/redis_state.ts +691 -0
- package/src/nodes/emotional_classifier.ts +289 -0
- package/src/nodes/index.ts +17 -0
- package/src/schemas/index.ts +75 -0
- package/src/schemas/ncp.ts +312 -0
- package/src/schemas/unified_state_bridge.ts +681 -0
- package/src/tests/coherence_engine.test.ts +273 -0
- package/src/tests/three_universe_processor.test.ts +309 -0
- 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
|
+
}
|