@animalabs/context-manager 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 (83) hide show
  1. package/dist/src/blob-manager.d.ts +37 -0
  2. package/dist/src/blob-manager.d.ts.map +1 -0
  3. package/dist/src/blob-manager.js +128 -0
  4. package/dist/src/blob-manager.js.map +1 -0
  5. package/dist/src/context-log.d.ts +99 -0
  6. package/dist/src/context-log.d.ts.map +1 -0
  7. package/dist/src/context-log.js +277 -0
  8. package/dist/src/context-log.js.map +1 -0
  9. package/dist/src/context-manager.d.ts +245 -0
  10. package/dist/src/context-manager.d.ts.map +1 -0
  11. package/dist/src/context-manager.js +553 -0
  12. package/dist/src/context-manager.js.map +1 -0
  13. package/dist/src/index.d.ts +12 -0
  14. package/dist/src/index.d.ts.map +1 -0
  15. package/dist/src/index.js +12 -0
  16. package/dist/src/index.js.map +1 -0
  17. package/dist/src/message-store.d.ts +135 -0
  18. package/dist/src/message-store.d.ts.map +1 -0
  19. package/dist/src/message-store.js +372 -0
  20. package/dist/src/message-store.js.map +1 -0
  21. package/dist/src/strategies/autobiographical.d.ts +199 -0
  22. package/dist/src/strategies/autobiographical.d.ts.map +1 -0
  23. package/dist/src/strategies/autobiographical.js +1122 -0
  24. package/dist/src/strategies/autobiographical.js.map +1 -0
  25. package/dist/src/strategies/index.d.ts +3 -0
  26. package/dist/src/strategies/index.d.ts.map +1 -0
  27. package/dist/src/strategies/index.js +3 -0
  28. package/dist/src/strategies/index.js.map +1 -0
  29. package/dist/src/strategies/knowledge.d.ts +46 -0
  30. package/dist/src/strategies/knowledge.d.ts.map +1 -0
  31. package/dist/src/strategies/knowledge.js +270 -0
  32. package/dist/src/strategies/knowledge.js.map +1 -0
  33. package/dist/src/strategies/passthrough.d.ts +17 -0
  34. package/dist/src/strategies/passthrough.d.ts.map +1 -0
  35. package/dist/src/strategies/passthrough.js +69 -0
  36. package/dist/src/strategies/passthrough.js.map +1 -0
  37. package/dist/src/types/context.d.ts +108 -0
  38. package/dist/src/types/context.d.ts.map +1 -0
  39. package/dist/src/types/context.js +2 -0
  40. package/dist/src/types/context.js.map +1 -0
  41. package/dist/src/types/index.d.ts +5 -0
  42. package/dist/src/types/index.d.ts.map +1 -0
  43. package/dist/src/types/index.js +2 -0
  44. package/dist/src/types/index.js.map +1 -0
  45. package/dist/src/types/message.d.ts +129 -0
  46. package/dist/src/types/message.d.ts.map +1 -0
  47. package/dist/src/types/message.js +2 -0
  48. package/dist/src/types/message.js.map +1 -0
  49. package/dist/src/types/strategy.d.ts +233 -0
  50. package/dist/src/types/strategy.d.ts.map +1 -0
  51. package/dist/src/types/strategy.js +32 -0
  52. package/dist/src/types/strategy.js.map +1 -0
  53. package/dist/test/autobiographical.test.d.ts +2 -0
  54. package/dist/test/autobiographical.test.d.ts.map +1 -0
  55. package/dist/test/autobiographical.test.js +46 -0
  56. package/dist/test/autobiographical.test.js.map +1 -0
  57. package/dist/test/head-window-reset.test.d.ts +17 -0
  58. package/dist/test/head-window-reset.test.d.ts.map +1 -0
  59. package/dist/test/head-window-reset.test.js +342 -0
  60. package/dist/test/head-window-reset.test.js.map +1 -0
  61. package/dist/test/integration.test.d.ts +2 -0
  62. package/dist/test/integration.test.d.ts.map +1 -0
  63. package/dist/test/integration.test.js +1341 -0
  64. package/dist/test/integration.test.js.map +1 -0
  65. package/dist/test/knowledge.test.d.ts +2 -0
  66. package/dist/test/knowledge.test.d.ts.map +1 -0
  67. package/dist/test/knowledge.test.js +617 -0
  68. package/dist/test/knowledge.test.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -0
  70. package/package.json +48 -0
  71. package/src/blob-manager.ts +155 -0
  72. package/src/context-log.ts +342 -0
  73. package/src/context-manager.ts +726 -0
  74. package/src/index.ts +50 -0
  75. package/src/message-store.ts +479 -0
  76. package/src/strategies/autobiographical.ts +1355 -0
  77. package/src/strategies/index.ts +2 -0
  78. package/src/strategies/knowledge.ts +336 -0
  79. package/src/strategies/passthrough.ts +98 -0
  80. package/src/types/context.ts +119 -0
  81. package/src/types/index.ts +42 -0
  82. package/src/types/message.ts +140 -0
  83. package/src/types/strategy.ts +282 -0
@@ -0,0 +1,726 @@
1
+ import { JsStore } from '@animalabs/chronicle';
2
+ import type { Membrane, NormalizedMessage, ContentBlock } from '@animalabs/membrane';
3
+ import type {
4
+ MessageId,
5
+ Sequence,
6
+ MessageMetadata,
7
+ StoredMessage,
8
+ ContextEntry,
9
+ TokenBudget,
10
+ PendingWork,
11
+ BranchInfo,
12
+ ContextStrategy,
13
+ StrategyContext,
14
+ MessageQuery,
15
+ MessageQueryResult,
16
+ ContextInjection,
17
+ CompileResult,
18
+ } from './types/index.js';
19
+ import { isResettableStrategy } from './types/index.js';
20
+ import { MessageStore, MessageStoreEvent } from './message-store.js';
21
+ import { ContextLog } from './context-log.js';
22
+ import { PassthroughStrategy } from './strategies/passthrough.js';
23
+
24
+ /**
25
+ * Base configuration for ContextManager.
26
+ */
27
+ interface ContextManagerBaseConfig {
28
+ /** Initial strategy (default: PassthroughStrategy) */
29
+ strategy?: ContextStrategy;
30
+ /** Membrane instance for compression strategies */
31
+ membrane?: Membrane;
32
+ /** Token estimator function */
33
+ tokenEstimator?: (text: string) => number;
34
+ /**
35
+ * Namespace for multi-agent support.
36
+ * When set, the context log uses state ID `{namespace}/context`.
37
+ * Messages remain shared (no namespace) unless `isolate` is true.
38
+ */
39
+ namespace?: string;
40
+ /**
41
+ * When true, the namespace applies to messages as well as the context log,
42
+ * giving fully isolated state: `{namespace}/messages` + `{namespace}/context`.
43
+ * Use for subagents that should not share message state with the parent.
44
+ * Requires `namespace` to be set.
45
+ */
46
+ isolate?: boolean;
47
+ /**
48
+ * When true, log the compiled context to stderr for debugging.
49
+ */
50
+ debugLogContext?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Configuration when ContextManager creates and owns the store.
55
+ */
56
+ interface ContextManagerPathConfig extends ContextManagerBaseConfig {
57
+ /** Path to Chronicle store */
58
+ path: string;
59
+ /** Blob cache size (default: 1000) */
60
+ blobCacheSize?: number;
61
+ store?: never;
62
+ }
63
+
64
+ /**
65
+ * Configuration when app provides an existing store.
66
+ * App retains ownership and is responsible for closing the store.
67
+ */
68
+ interface ContextManagerStoreConfig extends ContextManagerBaseConfig {
69
+ /** Existing Chronicle store (app-owned) */
70
+ store: JsStore;
71
+ path?: never;
72
+ blobCacheSize?: never;
73
+ }
74
+
75
+ /**
76
+ * Configuration for ContextManager.
77
+ */
78
+ export type ContextManagerConfig = ContextManagerPathConfig | ContextManagerStoreConfig;
79
+
80
+ /**
81
+ * Context Manager - the main interface for managing conversation context.
82
+ *
83
+ * Sits between the application/agent layer and Membrane, managing what goes
84
+ * into the context window. Uses Chronicle for persistent storage.
85
+ */
86
+ export class ContextManager {
87
+ private store: JsStore;
88
+ private messageStore: MessageStore;
89
+ private contextLog: ContextLog;
90
+ private strategy: ContextStrategy;
91
+ private membrane?: Membrane;
92
+ private initialized = false;
93
+ /** Whether we own the store (created it) vs app owns it (passed in) */
94
+ private ownsStore: boolean;
95
+ private debugLogContext: boolean;
96
+
97
+ private constructor(
98
+ store: JsStore,
99
+ messageStore: MessageStore,
100
+ contextLog: ContextLog,
101
+ strategy: ContextStrategy,
102
+ ownsStore: boolean,
103
+ membrane?: Membrane,
104
+ debugLogContext = false,
105
+ ) {
106
+ this.store = store;
107
+ this.messageStore = messageStore;
108
+ this.contextLog = contextLog;
109
+ this.strategy = strategy;
110
+ this.ownsStore = ownsStore;
111
+ this.membrane = membrane;
112
+ this.debugLogContext = debugLogContext;
113
+
114
+ // Set up edit propagation
115
+ this.messageStore.addListener((event) => this.handleMessageStoreEvent(event));
116
+ }
117
+
118
+ /**
119
+ * Open or create a context manager.
120
+ *
121
+ * Can be called with either:
122
+ * - `{ path: string }` - Creates and owns a new store
123
+ * - `{ store: JsStore }` - Uses an existing app-owned store
124
+ *
125
+ * When using an app-owned store, the app is responsible for closing it.
126
+ * The app can register additional states on the store before passing it.
127
+ */
128
+ static async open(config: ContextManagerConfig): Promise<ContextManager> {
129
+ let store: JsStore;
130
+ let ownsStore: boolean;
131
+
132
+ if ('store' in config && config.store) {
133
+ // App provides existing store - app owns it
134
+ store = config.store;
135
+ ownsStore = false;
136
+ } else if ('path' in config && config.path) {
137
+ // Create new store - we own it
138
+ store = JsStore.openOrCreate({
139
+ path: config.path,
140
+ blobCacheSize: config.blobCacheSize ?? 1000,
141
+ });
142
+ ownsStore = true;
143
+ } else {
144
+ throw new Error('ContextManagerConfig must have either "path" or "store"');
145
+ }
146
+
147
+ // Namespace for messages: only when `isolate` is true
148
+ if (config.isolate && !config.namespace) {
149
+ throw new Error('ContextManagerConfig: "isolate" requires "namespace" to be set');
150
+ }
151
+ const messageNamespace = config.isolate ? config.namespace : undefined;
152
+
153
+ // Register states if needed (idempotent)
154
+ try {
155
+ MessageStore.register(store, messageNamespace);
156
+ } catch {
157
+ // State already registered
158
+ }
159
+
160
+ try {
161
+ ContextLog.register(store, config.namespace);
162
+ } catch {
163
+ // State already registered
164
+ }
165
+
166
+ const messageStore = new MessageStore(store, {
167
+ estimator: config.tokenEstimator,
168
+ namespace: messageNamespace,
169
+ });
170
+ const contextLog = new ContextLog(store, {
171
+ estimator: config.tokenEstimator,
172
+ namespace: config.namespace,
173
+ });
174
+ const strategy = config.strategy ?? new PassthroughStrategy();
175
+
176
+ const manager = new ContextManager(
177
+ store,
178
+ messageStore,
179
+ contextLog,
180
+ strategy,
181
+ ownsStore,
182
+ config.membrane,
183
+ config.debugLogContext ?? false,
184
+ );
185
+
186
+ // Initialize strategy
187
+ await manager.initializeStrategy();
188
+ manager.initialized = true;
189
+
190
+ return manager;
191
+ }
192
+
193
+ // ==========================================================================
194
+ // Message Store Operations
195
+ // ==========================================================================
196
+
197
+ /**
198
+ * Add a message to the store.
199
+ */
200
+ addMessage(
201
+ participant: string,
202
+ content: ContentBlock[],
203
+ metadata?: MessageMetadata,
204
+ causedBy?: MessageId[]
205
+ ): MessageId {
206
+ const message = this.messageStore.append(participant, content, metadata, causedBy);
207
+ return message.id;
208
+ }
209
+
210
+ /**
211
+ * Edit a message in the store. Propagates to context log based on source relation.
212
+ */
213
+ editMessage(messageId: MessageId, content: ContentBlock[]): void {
214
+ this.messageStore.edit(messageId, content);
215
+ // Propagation handled by event listener
216
+ }
217
+
218
+ /**
219
+ * Remove a message from the store. Propagates to context log.
220
+ */
221
+ removeMessage(messageId: MessageId): void {
222
+ this.messageStore.remove(messageId);
223
+ // Propagation handled by event listener
224
+ }
225
+
226
+ /**
227
+ * Remove a range of messages from the store.
228
+ */
229
+ removeMessages(fromId: MessageId, toId: MessageId): void {
230
+ this.messageStore.removeRange(fromId, toId);
231
+ // Propagation handled by event listener
232
+ }
233
+
234
+ /**
235
+ * Get a message by ID.
236
+ */
237
+ getMessage(messageId: MessageId): StoredMessage | null {
238
+ return this.messageStore.get(messageId);
239
+ }
240
+
241
+ /**
242
+ * Get a message as it was at a specific sequence (time travel).
243
+ */
244
+ getMessageAt(messageId: MessageId, atSequence: Sequence): StoredMessage | null {
245
+ return this.messageStore.getAt(messageId, atSequence);
246
+ }
247
+
248
+ /**
249
+ * Get all messages in the store.
250
+ */
251
+ getAllMessages(): StoredMessage[] {
252
+ return this.messageStore.getAll();
253
+ }
254
+
255
+ /**
256
+ * Query messages by filter criteria.
257
+ * Useful for finding messages from external sources, by participant, etc.
258
+ *
259
+ * @example
260
+ * // Find all messages from Discord
261
+ * const { messages } = manager.queryMessages({ source: 'discord' });
262
+ *
263
+ * @example
264
+ * // Find messages from a specific channel
265
+ * const { messages } = manager.queryMessages({
266
+ * source: 'discord',
267
+ * metadata: { 'external.channelId': '123456' }
268
+ * });
269
+ *
270
+ * @example
271
+ * // Find specific messages by external ID
272
+ * const { messages } = manager.queryMessages({
273
+ * source: 'discord',
274
+ * externalIds: ['msg1', 'msg2', 'msg3']
275
+ * });
276
+ */
277
+ queryMessages(filter: MessageQuery): MessageQueryResult {
278
+ return this.messageStore.query(filter);
279
+ }
280
+
281
+ /**
282
+ * Find a message by its external source and ID.
283
+ * Returns the internal message ID, or null if not found.
284
+ */
285
+ findMessageByExternalId(source: string, externalId: string): MessageId | null {
286
+ const msg = this.messageStore.findByExternalId(source, externalId);
287
+ return msg?.id ?? null;
288
+ }
289
+
290
+ // ==========================================================================
291
+ // Branching
292
+ // ==========================================================================
293
+
294
+ /**
295
+ * Create a branch from a specific message.
296
+ * The new branch will have state as of that message's sequence (time-travel branching).
297
+ */
298
+ branchAt(messageId: MessageId, name?: string): string {
299
+ const message = this.messageStore.get(messageId);
300
+ if (!message) {
301
+ throw new Error(`Message not found: ${messageId}`);
302
+ }
303
+
304
+ // Create branch name if not provided
305
+ const branchName = name ?? `branch-${Date.now()}`;
306
+
307
+ // Get current branch name to branch from
308
+ const currentBranch = this.store.currentBranch();
309
+
310
+ // Use createBranchAt to branch at the message's sequence (time-travel)
311
+ const branch = this.store.createBranchAt(branchName, currentBranch.name, message.sequence);
312
+
313
+ return branch.id;
314
+ }
315
+
316
+ /**
317
+ * Switch to a different branch.
318
+ */
319
+ switchBranch(branchId: string): void {
320
+ this.store.switchBranch(branchId);
321
+ }
322
+
323
+ /**
324
+ * Get current branch.
325
+ */
326
+ currentBranch(): BranchInfo {
327
+ const branch = this.store.currentBranch();
328
+ return {
329
+ id: branch.id,
330
+ name: branch.name,
331
+ head: branch.head,
332
+ parentId: branch.parentId ?? undefined,
333
+ branchPoint: branch.branchPoint ?? undefined,
334
+ created: new Date(branch.created),
335
+ };
336
+ }
337
+
338
+ /**
339
+ * List all branches.
340
+ */
341
+ listBranches(): BranchInfo[] {
342
+ return this.store.listBranches().map((b) => ({
343
+ id: b.id,
344
+ name: b.name,
345
+ head: b.head,
346
+ parentId: b.parentId ?? undefined,
347
+ branchPoint: b.branchPoint ?? undefined,
348
+ created: new Date(b.created),
349
+ }));
350
+ }
351
+
352
+ // ==========================================================================
353
+ // Context Compilation
354
+ // ==========================================================================
355
+
356
+ /**
357
+ * Check if compile() will block waiting for background work.
358
+ */
359
+ isReady(): boolean {
360
+ return this.strategy.checkReadiness().ready;
361
+ }
362
+
363
+ /**
364
+ * Get info about pending background work.
365
+ */
366
+ getPendingWork(): PendingWork | null {
367
+ const state = this.strategy.checkReadiness();
368
+ if (state.ready) {
369
+ return null;
370
+ }
371
+
372
+ return {
373
+ description: state.description ?? 'Background work pending',
374
+ started: new Date(),
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Compile context for Membrane.
380
+ *
381
+ * Accepts optional context injections (e.g., from MCPL servers) and merges
382
+ * them into the compiled output by position:
383
+ * - "system": returned separately in `systemInjections` (caller appends to system prompt)
384
+ * - "beforeUser": inserted before the last user message
385
+ * - "afterUser": inserted after the last user message
386
+ *
387
+ * May block if strategy has pending work.
388
+ */
389
+ async compile(
390
+ budget?: TokenBudget,
391
+ injections?: ContextInjection[]
392
+ ): Promise<CompileResult> {
393
+ // Check readiness and wait if needed
394
+ const readiness = this.strategy.checkReadiness();
395
+ if (!readiness.ready && readiness.pendingWork) {
396
+ await readiness.pendingWork;
397
+ }
398
+
399
+ // Default budget
400
+ const effectiveBudget: TokenBudget = budget ?? {
401
+ maxTokens: 100000,
402
+ reserveForResponse: 4000,
403
+ };
404
+
405
+ // Get selected entries from strategy
406
+ const entries = this.strategy.select(
407
+ this.messageStore.createView(),
408
+ this.contextLog.createView(),
409
+ effectiveBudget
410
+ );
411
+
412
+ // Convert to NormalizedMessage[]
413
+ const messages: NormalizedMessage[] = entries.map((entry) => ({
414
+ participant: entry.participant,
415
+ content: entry.content,
416
+ }));
417
+
418
+ // If no injections, log and return early
419
+ if (!injections || injections.length === 0) {
420
+ const result = { messages, systemInjections: [] };
421
+ if (this.debugLogContext) this.logCompiledContext(result);
422
+ return result;
423
+ }
424
+
425
+ // Separate injections by position
426
+ const systemInjections: ContentBlock[] = [];
427
+ const beforeUser: ContextInjection[] = [];
428
+ const afterUser: ContextInjection[] = [];
429
+
430
+ for (const injection of injections) {
431
+ switch (injection.position) {
432
+ case 'system':
433
+ systemInjections.push(...injection.content);
434
+ break;
435
+ case 'beforeUser':
436
+ beforeUser.push(injection);
437
+ break;
438
+ case 'afterUser':
439
+ afterUser.push(injection);
440
+ break;
441
+ }
442
+ }
443
+
444
+ // Find last user message index (participant is typically 'user' or 'User')
445
+ let lastUserIdx = -1;
446
+ for (let i = messages.length - 1; i >= 0; i--) {
447
+ if (messages[i].participant.toLowerCase() === 'user') {
448
+ lastUserIdx = i;
449
+ break;
450
+ }
451
+ }
452
+
453
+ // Insert beforeUser injections before last user message
454
+ if (beforeUser.length > 0 && lastUserIdx >= 0) {
455
+ const injectedMessages: NormalizedMessage[] = beforeUser.map((inj) => ({
456
+ participant: `injection:${inj.namespace}`,
457
+ content: inj.content,
458
+ }));
459
+ messages.splice(lastUserIdx, 0, ...injectedMessages);
460
+ // Adjust lastUserIdx to account for inserted messages
461
+ lastUserIdx += injectedMessages.length;
462
+ }
463
+
464
+ // Insert afterUser injections after last user message
465
+ if (afterUser.length > 0) {
466
+ const insertIdx = lastUserIdx >= 0 ? lastUserIdx + 1 : messages.length;
467
+ const injectedMessages: NormalizedMessage[] = afterUser.map((inj) => ({
468
+ participant: `injection:${inj.namespace}`,
469
+ content: inj.content,
470
+ }));
471
+ messages.splice(insertIdx, 0, ...injectedMessages);
472
+ }
473
+
474
+ const result = { messages, systemInjections };
475
+ if (this.debugLogContext) this.logCompiledContext(result);
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Log the compiled context to stderr for debugging.
481
+ * Uses stderr so it doesn't pollute the context log (which strategies read).
482
+ */
483
+ private logCompiledContext(result: CompileResult): void {
484
+ const renderedMessages = result.messages.map((m) => {
485
+ const text = m.content
486
+ .map((b) => {
487
+ switch (b.type) {
488
+ case 'text': return b.text;
489
+ case 'thinking': return `[thinking] ${b.thinking}`;
490
+ case 'tool_use': return `[tool_use:${b.name}] ${JSON.stringify(b.input)}`;
491
+ case 'tool_result': return `[tool_result:${b.toolUseId}] ${typeof b.content === 'string' ? b.content : JSON.stringify(b.content)}`;
492
+ default: return `[${b.type}]`;
493
+ }
494
+ })
495
+ .join('\n');
496
+ return { participant: m.participant, text };
497
+ });
498
+
499
+ const entry = {
500
+ timestamp: Date.now(),
501
+ type: 'compiled_context',
502
+ messageCount: result.messages.length,
503
+ systemInjectionCount: result.systemInjections.length,
504
+ messages: renderedMessages,
505
+ };
506
+
507
+ console.error('[debugLogContext]', JSON.stringify(entry));
508
+ }
509
+
510
+ // ==========================================================================
511
+ // Strategy
512
+ // ==========================================================================
513
+
514
+ /**
515
+ * Set the context management strategy.
516
+ */
517
+ async setStrategy(strategy: ContextStrategy): Promise<void> {
518
+ this.strategy = strategy;
519
+ await this.initializeStrategy();
520
+ }
521
+
522
+ /**
523
+ * Get the current strategy.
524
+ */
525
+ getStrategy(): ContextStrategy {
526
+ return this.strategy;
527
+ }
528
+
529
+ /**
530
+ * Reset the head window to start from a new position.
531
+ * Old head window messages become compressible.
532
+ *
533
+ * If transitionText is provided, it's used as the transition summary.
534
+ * If omitted, an LLM call auto-generates a transition summary.
535
+ *
536
+ * Returns the transition summary text used.
537
+ */
538
+ async resetHeadWindow(transitionText?: string): Promise<string> {
539
+ if (!isResettableStrategy(this.strategy)) {
540
+ throw new Error('Active strategy does not support head window reset');
541
+ }
542
+
543
+ const ctx = this.createStrategyContext();
544
+
545
+ // Generate transition summary if not provided
546
+ const summary = transitionText ?? await this.strategy.generateTransitionSummary(ctx);
547
+
548
+ // Inject transition message
549
+ const msgId = this.addMessage('Context Manager', [
550
+ { type: 'text', text: `[Topic Transition]\n\n${summary}` },
551
+ ]);
552
+
553
+ // Reset head window to start from this message
554
+ this.strategy.resetHeadWindow(msgId);
555
+
556
+ return summary;
557
+ }
558
+
559
+ /**
560
+ * Trigger background maintenance work.
561
+ * Call this periodically to allow strategies to do compression, etc.
562
+ */
563
+ async tick(): Promise<void> {
564
+ if (this.strategy.tick) {
565
+ await this.strategy.tick(this.createStrategyContext());
566
+ }
567
+ }
568
+
569
+ // ==========================================================================
570
+ // Internal
571
+ // ==========================================================================
572
+
573
+ private async initializeStrategy(): Promise<void> {
574
+ if (this.strategy.initialize) {
575
+ await this.strategy.initialize(this.createStrategyContext());
576
+ }
577
+ }
578
+
579
+ private createStrategyContext(): StrategyContext {
580
+ return {
581
+ messageStore: this.messageStore.createView(),
582
+ contextLog: this.contextLog.createView(),
583
+ membrane: this.membrane,
584
+ currentSequence: this.store.currentSequence(),
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Handle message store events for edit propagation.
590
+ */
591
+ private handleMessageStoreEvent(event: MessageStoreEvent): void {
592
+ switch (event.type) {
593
+ case 'add':
594
+ this.handleMessageAdd(event.message);
595
+ break;
596
+ case 'edit':
597
+ this.handleMessageEdit(event.messageId, event.newContent);
598
+ break;
599
+ case 'remove':
600
+ this.handleMessageRemove(event.messageId);
601
+ break;
602
+ case 'removeRange':
603
+ // For range removes, we need to check all affected messages
604
+ // This is a simplification - in practice we'd need to track the IDs
605
+ break;
606
+ }
607
+ }
608
+
609
+ private handleMessageAdd(message: StoredMessage): void {
610
+ // Notify strategy of new message
611
+ if (this.strategy.onNewMessage) {
612
+ // Fire and forget - don't block on strategy processing
613
+ this.strategy.onNewMessage(message, this.createStrategyContext()).catch((err) => {
614
+ console.error('Strategy onNewMessage failed:', err);
615
+ });
616
+ }
617
+ }
618
+
619
+ private handleMessageEdit(messageId: MessageId, newContent: ContentBlock[]): void {
620
+ // Find context entries that reference this message
621
+ const entries = this.contextLog.findBySource(messageId);
622
+
623
+ for (const entry of entries) {
624
+ // Check source relation to decide whether to propagate
625
+ switch (entry.sourceRelation) {
626
+ case 'copy':
627
+ // Must propagate
628
+ this.contextLog.edit(entry.index, newContent);
629
+ break;
630
+ case 'derived':
631
+ // May ignore (stale is acceptable)
632
+ // Do nothing
633
+ break;
634
+ case 'referenced':
635
+ // Don't propagate
636
+ // Do nothing
637
+ break;
638
+ default:
639
+ // No relation specified, treat as copy for safety
640
+ this.contextLog.edit(entry.index, newContent);
641
+ }
642
+ }
643
+ }
644
+
645
+ private handleMessageRemove(messageId: MessageId): void {
646
+ // Find context entries that reference this message
647
+ const entries = this.contextLog.findBySource(messageId);
648
+
649
+ // Collect indices to remove (in reverse order to maintain indices)
650
+ const indicesToRemove: number[] = [];
651
+
652
+ for (const entry of entries) {
653
+ switch (entry.sourceRelation) {
654
+ case 'copy':
655
+ // Must remove
656
+ indicesToRemove.push(entry.index);
657
+ break;
658
+ case 'derived':
659
+ // Ignore (it's a snapshot)
660
+ break;
661
+ case 'referenced':
662
+ // Don't propagate
663
+ break;
664
+ default:
665
+ // No relation specified, treat as copy
666
+ indicesToRemove.push(entry.index);
667
+ }
668
+ }
669
+
670
+ // Remove in reverse order to maintain indices
671
+ indicesToRemove.sort((a, b) => b - a);
672
+ for (const index of indicesToRemove) {
673
+ this.contextLog.remove(index);
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Get the underlying Chronicle store.
679
+ * Useful for registering additional states or accessing store-level features.
680
+ */
681
+ getStore(): JsStore {
682
+ return this.store;
683
+ }
684
+
685
+ /**
686
+ * Sync to disk.
687
+ */
688
+ sync(): void {
689
+ this.store.sync();
690
+ }
691
+
692
+ /**
693
+ * Close the context manager.
694
+ *
695
+ * If the manager owns the store (created via path config), this closes the store.
696
+ * If the app owns the store (passed via store config), this is a no-op;
697
+ * the app is responsible for closing the store when done.
698
+ */
699
+ close(): void {
700
+ if (this.ownsStore) {
701
+ this.store.close();
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Check if the store has been closed.
707
+ */
708
+ isClosed(): boolean {
709
+ return this.store.isClosed();
710
+ }
711
+
712
+ /**
713
+ * Get store stats.
714
+ */
715
+ stats(): {
716
+ messageCount: number;
717
+ contextEntryCount: number;
718
+ branches: number;
719
+ } {
720
+ return {
721
+ messageCount: this.messageStore.length(),
722
+ contextEntryCount: this.contextLog.length(),
723
+ branches: this.listBranches().length,
724
+ };
725
+ }
726
+ }