@animalabs/context-manager 0.3.0 → 0.5.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 (161) hide show
  1. package/dist/src/adaptive/chunker.d.ts +70 -0
  2. package/dist/src/adaptive/chunker.d.ts.map +1 -0
  3. package/dist/src/adaptive/chunker.js +233 -0
  4. package/dist/src/adaptive/chunker.js.map +1 -0
  5. package/dist/src/adaptive/folding-strategy.d.ts +118 -0
  6. package/dist/src/adaptive/folding-strategy.d.ts.map +1 -0
  7. package/dist/src/adaptive/folding-strategy.js +12 -0
  8. package/dist/src/adaptive/folding-strategy.js.map +1 -0
  9. package/dist/src/adaptive/index.d.ts +17 -0
  10. package/dist/src/adaptive/index.d.ts.map +1 -0
  11. package/dist/src/adaptive/index.js +23 -0
  12. package/dist/src/adaptive/index.js.map +1 -0
  13. package/dist/src/adaptive/picker.d.ts +103 -0
  14. package/dist/src/adaptive/picker.d.ts.map +1 -0
  15. package/dist/src/adaptive/picker.js +357 -0
  16. package/dist/src/adaptive/picker.js.map +1 -0
  17. package/dist/src/adaptive/render.d.ts +39 -0
  18. package/dist/src/adaptive/render.d.ts.map +1 -0
  19. package/dist/src/adaptive/render.js +104 -0
  20. package/dist/src/adaptive/render.js.map +1 -0
  21. package/dist/src/adaptive/strategies/flat-profile.d.ts +19 -0
  22. package/dist/src/adaptive/strategies/flat-profile.d.ts.map +1 -0
  23. package/dist/src/adaptive/strategies/flat-profile.js +86 -0
  24. package/dist/src/adaptive/strategies/flat-profile.js.map +1 -0
  25. package/dist/src/adaptive/strategies/oldest-first.d.ts +19 -0
  26. package/dist/src/adaptive/strategies/oldest-first.d.ts.map +1 -0
  27. package/dist/src/adaptive/strategies/oldest-first.js +39 -0
  28. package/dist/src/adaptive/strategies/oldest-first.js.map +1 -0
  29. package/dist/src/context-manager.d.ts +74 -2
  30. package/dist/src/context-manager.d.ts.map +1 -1
  31. package/dist/src/context-manager.js +150 -10
  32. package/dist/src/context-manager.js.map +1 -1
  33. package/dist/src/index.d.ts +1 -1
  34. package/dist/src/index.d.ts.map +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/message-store.d.ts +36 -1
  37. package/dist/src/message-store.d.ts.map +1 -1
  38. package/dist/src/message-store.js +134 -8
  39. package/dist/src/message-store.js.map +1 -1
  40. package/dist/src/strategies/autobiographical.d.ts +422 -5
  41. package/dist/src/strategies/autobiographical.d.ts.map +1 -1
  42. package/dist/src/strategies/autobiographical.js +2248 -98
  43. package/dist/src/strategies/autobiographical.js.map +1 -1
  44. package/dist/src/strategies/index.d.ts +1 -1
  45. package/dist/src/strategies/index.d.ts.map +1 -1
  46. package/dist/src/strategies/index.js.map +1 -1
  47. package/dist/src/types/index.d.ts +2 -2
  48. package/dist/src/types/index.d.ts.map +1 -1
  49. package/dist/src/types/index.js +1 -1
  50. package/dist/src/types/index.js.map +1 -1
  51. package/dist/src/types/message.d.ts +38 -0
  52. package/dist/src/types/message.d.ts.map +1 -1
  53. package/dist/src/types/strategy.d.ts +302 -10
  54. package/dist/src/types/strategy.d.ts.map +1 -1
  55. package/dist/src/types/strategy.js +28 -0
  56. package/dist/src/types/strategy.js.map +1 -1
  57. package/dist/test/adaptive/branching.test.d.ts +20 -0
  58. package/dist/test/adaptive/branching.test.d.ts.map +1 -0
  59. package/dist/test/adaptive/branching.test.js +176 -0
  60. package/dist/test/adaptive/branching.test.js.map +1 -0
  61. package/dist/test/adaptive/chunker.test.d.ts +2 -0
  62. package/dist/test/adaptive/chunker.test.d.ts.map +1 -0
  63. package/dist/test/adaptive/chunker.test.js +120 -0
  64. package/dist/test/adaptive/chunker.test.js.map +1 -0
  65. package/dist/test/adaptive/deep-levels.test.d.ts +10 -0
  66. package/dist/test/adaptive/deep-levels.test.d.ts.map +1 -0
  67. package/dist/test/adaptive/deep-levels.test.js +167 -0
  68. package/dist/test/adaptive/deep-levels.test.js.map +1 -0
  69. package/dist/test/adaptive/doc-plus-chat.test.d.ts +10 -0
  70. package/dist/test/adaptive/doc-plus-chat.test.d.ts.map +1 -0
  71. package/dist/test/adaptive/doc-plus-chat.test.js +194 -0
  72. package/dist/test/adaptive/doc-plus-chat.test.js.map +1 -0
  73. package/dist/test/adaptive/hard-fail.test.d.ts +9 -0
  74. package/dist/test/adaptive/hard-fail.test.d.ts.map +1 -0
  75. package/dist/test/adaptive/hard-fail.test.js +154 -0
  76. package/dist/test/adaptive/hard-fail.test.js.map +1 -0
  77. package/dist/test/adaptive/harness.d.ts +109 -0
  78. package/dist/test/adaptive/harness.d.ts.map +1 -0
  79. package/dist/test/adaptive/harness.js +263 -0
  80. package/dist/test/adaptive/harness.js.map +1 -0
  81. package/dist/test/adaptive/ingestion.test.d.ts +13 -0
  82. package/dist/test/adaptive/ingestion.test.d.ts.map +1 -0
  83. package/dist/test/adaptive/ingestion.test.js +306 -0
  84. package/dist/test/adaptive/ingestion.test.js.map +1 -0
  85. package/dist/test/adaptive/integration.test.d.ts +12 -0
  86. package/dist/test/adaptive/integration.test.d.ts.map +1 -0
  87. package/dist/test/adaptive/integration.test.js +247 -0
  88. package/dist/test/adaptive/integration.test.js.map +1 -0
  89. package/dist/test/adaptive/long-chronicle.test.d.ts +13 -0
  90. package/dist/test/adaptive/long-chronicle.test.d.ts.map +1 -0
  91. package/dist/test/adaptive/long-chronicle.test.js +186 -0
  92. package/dist/test/adaptive/long-chronicle.test.js.map +1 -0
  93. package/dist/test/adaptive/persistence.test.d.ts +11 -0
  94. package/dist/test/adaptive/persistence.test.d.ts.map +1 -0
  95. package/dist/test/adaptive/persistence.test.js +200 -0
  96. package/dist/test/adaptive/persistence.test.js.map +1 -0
  97. package/dist/test/adaptive/picker.test.d.ts +2 -0
  98. package/dist/test/adaptive/picker.test.d.ts.map +1 -0
  99. package/dist/test/adaptive/picker.test.js +333 -0
  100. package/dist/test/adaptive/picker.test.js.map +1 -0
  101. package/dist/test/adaptive/render.test.d.ts +2 -0
  102. package/dist/test/adaptive/render.test.d.ts.map +1 -0
  103. package/dist/test/adaptive/render.test.js +109 -0
  104. package/dist/test/adaptive/render.test.js.map +1 -0
  105. package/dist/test/adaptive/shard-immutability.test.d.ts +14 -0
  106. package/dist/test/adaptive/shard-immutability.test.d.ts.map +1 -0
  107. package/dist/test/adaptive/shard-immutability.test.js +184 -0
  108. package/dist/test/adaptive/shard-immutability.test.js.map +1 -0
  109. package/dist/test/adaptive/strategy-integration.test.d.ts +9 -0
  110. package/dist/test/adaptive/strategy-integration.test.d.ts.map +1 -0
  111. package/dist/test/adaptive/strategy-integration.test.js +293 -0
  112. package/dist/test/adaptive/strategy-integration.test.js.map +1 -0
  113. package/dist/test/message-store-sequence.test.d.ts +25 -0
  114. package/dist/test/message-store-sequence.test.d.ts.map +1 -0
  115. package/dist/test/message-store-sequence.test.js +157 -0
  116. package/dist/test/message-store-sequence.test.js.map +1 -0
  117. package/dist/test/non-blocking-compile.test.d.ts +22 -0
  118. package/dist/test/non-blocking-compile.test.d.ts.map +1 -0
  119. package/dist/test/non-blocking-compile.test.js +114 -0
  120. package/dist/test/non-blocking-compile.test.js.map +1 -0
  121. package/dist/test/pins.test.d.ts +10 -0
  122. package/dist/test/pins.test.d.ts.map +1 -0
  123. package/dist/test/pins.test.js +164 -0
  124. package/dist/test/pins.test.js.map +1 -0
  125. package/dist/test/recall-positioning.test.d.ts +16 -0
  126. package/dist/test/recall-positioning.test.d.ts.map +1 -0
  127. package/dist/test/recall-positioning.test.js +181 -0
  128. package/dist/test/recall-positioning.test.js.map +1 -0
  129. package/dist/test/search.test.d.ts +10 -0
  130. package/dist/test/search.test.d.ts.map +1 -0
  131. package/dist/test/search.test.js +141 -0
  132. package/dist/test/search.test.js.map +1 -0
  133. package/dist/test/speculation-cap.test.d.ts +17 -0
  134. package/dist/test/speculation-cap.test.d.ts.map +1 -0
  135. package/dist/test/speculation-cap.test.js +157 -0
  136. package/dist/test/speculation-cap.test.js.map +1 -0
  137. package/dist/test/strategy-persistence.test.d.ts +15 -0
  138. package/dist/test/strategy-persistence.test.d.ts.map +1 -0
  139. package/dist/test/strategy-persistence.test.js +244 -0
  140. package/dist/test/strategy-persistence.test.js.map +1 -0
  141. package/dist/test/tool-pruning.test.d.ts +10 -0
  142. package/dist/test/tool-pruning.test.d.ts.map +1 -0
  143. package/dist/test/tool-pruning.test.js +140 -0
  144. package/dist/test/tool-pruning.test.js.map +1 -0
  145. package/dist/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +1 -1
  147. package/src/adaptive/chunker.ts +323 -0
  148. package/src/adaptive/folding-strategy.ts +129 -0
  149. package/src/adaptive/index.ts +51 -0
  150. package/src/adaptive/picker.ts +442 -0
  151. package/src/adaptive/render.ts +115 -0
  152. package/src/adaptive/strategies/flat-profile.ts +96 -0
  153. package/src/adaptive/strategies/oldest-first.ts +48 -0
  154. package/src/context-manager.ts +174 -10
  155. package/src/index.ts +1 -1
  156. package/src/message-store.ts +147 -8
  157. package/src/strategies/autobiographical.ts +2468 -166
  158. package/src/strategies/index.ts +1 -1
  159. package/src/types/index.ts +14 -1
  160. package/src/types/message.ts +43 -0
  161. package/src/types/strategy.ts +332 -10
@@ -15,8 +15,13 @@ import type {
15
15
  MessageQueryResult,
16
16
  ContextInjection,
17
17
  CompileResult,
18
+ ProtectedRange,
19
+ SearchQuery,
20
+ SearchResult,
21
+ SummaryEntry,
18
22
  } from './types/index.js';
19
- import { isResettableStrategy } from './types/index.js';
23
+ import { isResettableStrategy, isPinnableStrategy, isSearchableStrategy, isRenderStatsCapable } from './types/index.js';
24
+ import type { RenderStats } from './types/index.js';
20
25
  import { MessageStore, MessageStoreEvent } from './message-store.js';
21
26
  import { ContextLog } from './context-log.js';
22
27
  import { PassthroughStrategy } from './strategies/passthrough.js';
@@ -93,6 +98,8 @@ export class ContextManager {
93
98
  /** Whether we own the store (created it) vs app owns it (passed in) */
94
99
  private ownsStore: boolean;
95
100
  private debugLogContext: boolean;
101
+ /** Namespace passed to strategies for scoping their persistent state slots. */
102
+ private strategyNamespace: string;
96
103
 
97
104
  private constructor(
98
105
  store: JsStore,
@@ -100,6 +107,7 @@ export class ContextManager {
100
107
  contextLog: ContextLog,
101
108
  strategy: ContextStrategy,
102
109
  ownsStore: boolean,
110
+ strategyNamespace: string,
103
111
  membrane?: Membrane,
104
112
  debugLogContext = false,
105
113
  ) {
@@ -108,6 +116,7 @@ export class ContextManager {
108
116
  this.contextLog = contextLog;
109
117
  this.strategy = strategy;
110
118
  this.ownsStore = ownsStore;
119
+ this.strategyNamespace = strategyNamespace;
111
120
  this.membrane = membrane;
112
121
  this.debugLogContext = debugLogContext;
113
122
 
@@ -173,12 +182,18 @@ export class ContextManager {
173
182
  });
174
183
  const strategy = config.strategy ?? new PassthroughStrategy();
175
184
 
185
+ // Namespace passed to strategies. Falls back to a stable per-store value
186
+ // so strategies always have something to scope state IDs by, even when
187
+ // the caller didn't supply a namespace.
188
+ const strategyNamespace = config.namespace ?? 'default';
189
+
176
190
  const manager = new ContextManager(
177
191
  store,
178
192
  messageStore,
179
193
  contextLog,
180
194
  strategy,
181
195
  ownsStore,
196
+ strategyNamespace,
182
197
  config.membrane,
183
198
  config.debugLogContext ?? false,
184
199
  );
@@ -196,6 +211,11 @@ export class ContextManager {
196
211
 
197
212
  /**
198
213
  * Add a message to the store.
214
+ *
215
+ * If the configured strategy implements `chunkIngressMessage` and returns
216
+ * a non-null sharding decision, the message is stored as multiple records
217
+ * sharing a `bodyGroupId` (per the adaptive-resolution design §3.6). The
218
+ * returned MessageId is the first shard's id.
199
219
  */
200
220
  addMessage(
201
221
  participant: string,
@@ -203,6 +223,33 @@ export class ContextManager {
203
223
  metadata?: MessageMetadata,
204
224
  causedBy?: MessageId[]
205
225
  ): MessageId {
226
+ // Optional strategy-driven ingestion-time chunking
227
+ const strategyAny = this.strategy as unknown as {
228
+ chunkIngressMessage?: (
229
+ participant: string,
230
+ content: ContentBlock[]
231
+ ) => { bodyGroupId: string; shards: Array<{ content: ContentBlock[]; shardIndex: number }> } | null;
232
+ };
233
+ if (typeof strategyAny.chunkIngressMessage === 'function') {
234
+ const decision = strategyAny.chunkIngressMessage(participant, content);
235
+ if (decision && decision.shards.length > 1) {
236
+ let firstId: MessageId | null = null;
237
+ for (const shard of decision.shards) {
238
+ const message = this.messageStore.append(
239
+ participant,
240
+ shard.content,
241
+ metadata,
242
+ causedBy,
243
+ {
244
+ bodyGroupId: decision.bodyGroupId,
245
+ shardIndex: shard.shardIndex,
246
+ }
247
+ );
248
+ if (firstId === null) firstId = message.id;
249
+ }
250
+ return firstId!;
251
+ }
252
+ }
206
253
  const message = this.messageStore.append(participant, content, metadata, causedBy);
207
254
  return message.id;
208
255
  }
@@ -294,6 +341,10 @@ export class ContextManager {
294
341
  /**
295
342
  * Create a branch from a specific message.
296
343
  * The new branch will have state as of that message's sequence (time-travel branching).
344
+ *
345
+ * Returns the new branch *name*, which is what `switchBranch` and `forkAt`
346
+ * expect. (Chronicle's branch APIs are name-keyed; the numeric `id` field
347
+ * on JsBranch is an internal identifier and isn't accepted by switchBranch.)
297
348
  */
298
349
  branchAt(messageId: MessageId, name?: string): string {
299
350
  const message = this.messageStore.get(messageId);
@@ -303,21 +354,50 @@ export class ContextManager {
303
354
 
304
355
  // Create branch name if not provided
305
356
  const branchName = name ?? `branch-${Date.now()}`;
306
-
357
+
307
358
  // Get current branch name to branch from
308
359
  const currentBranch = this.store.currentBranch();
309
-
360
+
310
361
  // Use createBranchAt to branch at the message's sequence (time-travel)
311
362
  const branch = this.store.createBranchAt(branchName, currentBranch.name, message.sequence);
312
363
 
313
- return branch.id;
364
+ return branch.name;
314
365
  }
315
366
 
316
367
  /**
317
368
  * Switch to a different branch.
369
+ *
370
+ * Re-initializes the strategy after switching so any branch-scoped state
371
+ * stored on Chronicle is reloaded. Strategies that hold derived in-memory
372
+ * caches (e.g. AutobiographicalStrategy.summaries) need this to avoid
373
+ * showing the previous branch's state on the new branch.
318
374
  */
319
- switchBranch(branchId: string): void {
375
+ async switchBranch(branchId: string): Promise<void> {
320
376
  this.store.switchBranch(branchId);
377
+ await this.initializeStrategy();
378
+ }
379
+
380
+ /**
381
+ * Fork from the current head: create a new branch at the current sequence
382
+ * and switch to it. The new branch starts with all current state (messages,
383
+ * context log, and strategy state) and diverges from there.
384
+ *
385
+ * Use this when an agent wants to explore an alternate timeline from
386
+ * "now" — e.g. trying a different response without committing.
387
+ *
388
+ * For time-travel branching at a specific historical message, use
389
+ * `branchAt(messageId, name?)` instead, then `switchBranch(name)`.
390
+ *
391
+ * Returns the new branch's name. The strategy is re-initialized on the
392
+ * new branch so it picks up the forked state.
393
+ */
394
+ async fork(name?: string): Promise<string> {
395
+ const branchName = name ?? `fork-${Date.now()}`;
396
+ const currentBranch = this.store.currentBranch();
397
+ const currentSeq = this.store.currentSequence();
398
+ const branch = this.store.createBranchAt(branchName, currentBranch.name, currentSeq);
399
+ await this.switchBranch(branch.name);
400
+ return branch.name;
321
401
  }
322
402
 
323
403
  /**
@@ -390,11 +470,15 @@ export class ContextManager {
390
470
  budget?: TokenBudget,
391
471
  injections?: ContextInjection[]
392
472
  ): 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
- }
473
+ // Don't block the agent's turn on speculative compression — let it
474
+ // run in the background. The strategy renders whatever's available
475
+ // now; the next compile picks up the freshly-formed L1.
476
+ //
477
+ // Old behavior (await pendingWork to fold the latest chunk before
478
+ // the agent responds) added 30+ seconds of latency per turn whenever
479
+ // a chunk was forming, which is unacceptable UX for an agent that
480
+ // streams its responses. We accept "this turn doesn't have the very
481
+ // latest L1" in exchange for non-blocking compile.
398
482
 
399
483
  // Default budget
400
484
  const effectiveBudget: TokenBudget = budget ?? {
@@ -527,6 +611,84 @@ export class ContextManager {
527
611
  return this.strategy;
528
612
  }
529
613
 
614
+ // ==========================================================================
615
+ // Pins / documents (passthrough to the active strategy)
616
+ // ==========================================================================
617
+
618
+ /**
619
+ * Pin a range of messages so they aren't compressed and render raw at
620
+ * their original chronological position. Returns the new pin id.
621
+ *
622
+ * Throws if the active strategy doesn't support pins.
623
+ */
624
+ pinRange(firstMessageId: MessageId, lastMessageId: MessageId, opts?: { name?: string }): string {
625
+ if (!isPinnableStrategy(this.strategy)) {
626
+ throw new Error('Active strategy does not support pins');
627
+ }
628
+ return this.strategy.pinRange(firstMessageId, lastMessageId, opts);
629
+ }
630
+
631
+ /**
632
+ * Mark a single message as a "document" (semantically a body of
633
+ * information to retain in full). Same effect as a single-message pin
634
+ * with `kind: 'document'`. Returns the new pin id.
635
+ */
636
+ markDocument(messageId: MessageId, opts?: { name?: string }): string {
637
+ if (!isPinnableStrategy(this.strategy)) {
638
+ throw new Error('Active strategy does not support documents');
639
+ }
640
+ return this.strategy.markDocument(messageId, opts);
641
+ }
642
+
643
+ /** Remove a pin or document mark. Returns true if removed. */
644
+ unpin(pinId: string): boolean {
645
+ if (!isPinnableStrategy(this.strategy)) {
646
+ throw new Error('Active strategy does not support pins');
647
+ }
648
+ return this.strategy.unpin(pinId);
649
+ }
650
+
651
+ /** List all current pins. Returns empty array if strategy is not pinnable. */
652
+ listPins(): ReadonlyArray<ProtectedRange> {
653
+ if (!isPinnableStrategy(this.strategy)) return [];
654
+ return this.strategy.listPins();
655
+ }
656
+
657
+ // ==========================================================================
658
+ // Search (passthrough to the active strategy)
659
+ // ==========================================================================
660
+
661
+ /**
662
+ * Search the strategy's summary archive (substring or regex over content).
663
+ * Returns empty array if the strategy doesn't support search.
664
+ *
665
+ * Suitable for building memory-search agent tools at the framework layer
666
+ * — see e.g. agent-framework's MCPL host integration.
667
+ */
668
+ searchSummaries(query: SearchQuery): SearchResult[] {
669
+ if (!isSearchableStrategy(this.strategy)) return [];
670
+ return this.strategy.searchSummaries(query);
671
+ }
672
+
673
+ /** Look up a single summary by id. Returns null if not found / unsupported. */
674
+ getSummary(id: string): SummaryEntry | null {
675
+ if (!isSearchableStrategy(this.strategy)) return null;
676
+ return this.strategy.getSummary(id);
677
+ }
678
+
679
+ /**
680
+ * Per-render stats from the active strategy: head/tail message + token
681
+ * counts, plus per-level summary counts and total tokens. Returns null
682
+ * if the strategy doesn't implement `getRenderStats`.
683
+ *
684
+ * Designed for TUIs / dashboards that want to display "how much of the
685
+ * agent's context is folded vs raw" at a glance.
686
+ */
687
+ getRenderStats(): RenderStats | null {
688
+ if (!isRenderStatsCapable(this.strategy)) return null;
689
+ return this.strategy.getRenderStats(this.messageStore.createView());
690
+ }
691
+
530
692
  /**
531
693
  * Reset the head window to start from a new position.
532
694
  * Old head window messages become compressible.
@@ -583,6 +745,8 @@ export class ContextManager {
583
745
  contextLog: this.contextLog.createView(),
584
746
  membrane: this.membrane,
585
747
  currentSequence: this.store.currentSequence(),
748
+ store: this.store,
749
+ namespace: this.strategyNamespace,
586
750
  };
587
751
  }
588
752
 
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export { BlobManager } from './blob-manager.js';
10
10
 
11
11
  // Strategies
12
12
  export { PassthroughStrategy } from './strategies/passthrough.js';
13
- export { AutobiographicalStrategy } from './strategies/autobiographical.js';
13
+ export { AutobiographicalStrategy, type AutobiographicalProgressSnapshot } from './strategies/autobiographical.js';
14
14
  export { KnowledgeStrategy } from './strategies/knowledge.js';
15
15
 
16
16
  // Types
@@ -101,14 +101,31 @@ export class MessageStore {
101
101
  }
102
102
  }
103
103
 
104
+ /**
105
+ * Optional extra fields for `append`, used by callers that need to set
106
+ * adaptive-resolution metadata (bodyGroupId for shards, initial
107
+ * resolution state, etc.) at ingestion time.
108
+ */
109
+ static readonly _appendExtraKeys = ['bodyGroupId', 'shardIndex', 'currentResolution', 'lockedByAgent'] as const;
110
+
104
111
  /**
105
112
  * Append a new message to the store.
113
+ *
114
+ * `extra` is an optional bag of adaptive-resolution metadata
115
+ * (bodyGroupId / shardIndex / currentResolution / lockedByAgent) that
116
+ * callers may set at ingestion. Field semantics match StoredMessage.
106
117
  */
107
118
  append(
108
119
  participant: string,
109
120
  content: ContentBlock[],
110
121
  metadata?: MessageMetadata,
111
- causedBy?: MessageId[]
122
+ causedBy?: MessageId[],
123
+ extra?: {
124
+ bodyGroupId?: string;
125
+ shardIndex?: number;
126
+ currentResolution?: number;
127
+ lockedByAgent?: boolean;
128
+ }
112
129
  ): StoredMessage {
113
130
  // Extract blobs from content
114
131
  const storedContent = this.blobManager.extractBlobs(content);
@@ -120,28 +137,54 @@ export class MessageStore {
120
137
  metadata,
121
138
  timestamp: Date.now(),
122
139
  causedBy,
140
+ ...(extra ?? {}),
123
141
  };
124
142
 
125
143
  const record = this.store.appendToStateJson(this.stateId, partialInternal);
126
144
  const index = this.length() - 1;
127
145
 
128
- // Now update the stored item to include the id (for rebuildIndex)
146
+ // The append's payload doesn't carry the chronicle record's id /
147
+ // sequence — those are only known once the append returns. We patch
148
+ // them in via editStateItem below. The patch creates a new chronicle
149
+ // record at `record.sequence + 1` (the next sequence; nothing else
150
+ // writes between the append and the edit inside this function).
151
+ //
152
+ // We store the EDIT'S sequence (not the original append's) as the
153
+ // canonical "this message is fully readable at and after this seq"
154
+ // marker. Why: `ContextManager.branchAt(messageId)` forwards
155
+ // `message.sequence` to `chronicle.createBranchAt`, which forks
156
+ // visibility at that exact seq. If `sequence` pointed at the original
157
+ // append (record.sequence), the new branch would see the pre-patch
158
+ // payload (id/sequence undefined). Pointing at the edit's seq
159
+ // includes the patched payload in the inherited records — so a
160
+ // forked branch sees this message in its fully-established form.
161
+ //
162
+ // Invariant: this function does exactly ONE chronicle write between
163
+ // the append and the edit (the edit itself). If that ever changes,
164
+ // the +1 expression must be updated to match (or refactored to
165
+ // capture editStateItem's returned record.sequence — which is
166
+ // chicken-and-egg here since we need the value inside the payload
167
+ // we're writing).
168
+ const commitSequence = record.sequence + 1;
129
169
  const fullInternal: StoredMessageInternal = {
130
170
  id: record.id,
131
- sequence: record.sequence,
171
+ sequence: commitSequence,
132
172
  ...partialInternal,
133
173
  };
134
174
  this.store.editStateItem(this.stateId, index, Buffer.from(JSON.stringify(fullInternal)));
135
175
 
136
- // Build full message with ID and sequence from record
176
+ // Build full message with ID and sequence from record. The sequence
177
+ // we expose is the commit sequence (matches what's stored), so
178
+ // `branchAt(messageId)` forks at the right place for any caller.
137
179
  const message: StoredMessage = {
138
180
  id: record.id,
139
- sequence: record.sequence,
181
+ sequence: commitSequence,
140
182
  participant,
141
183
  content, // Original content with inline data
142
184
  metadata,
143
185
  timestamp: new Date(partialInternal.timestamp),
144
186
  causedBy,
187
+ ...(extra ?? {}),
145
188
  };
146
189
 
147
190
  // Update index
@@ -153,6 +196,11 @@ export class MessageStore {
153
196
 
154
197
  /**
155
198
  * Edit a message's content.
199
+ *
200
+ * Throws if `messageId` belongs to a bodyGroup (i.e., is a shard of a
201
+ * larger sharded message). Editing one shard would silently corrupt the
202
+ * bodyGroup's byte-faithful reassembly invariant. To replace a sharded
203
+ * message, remove the whole bodyGroup and re-append.
156
204
  */
157
205
  edit(messageId: MessageId, newContent: ContentBlock[]): void {
158
206
  const index = this.idToIndex.get(messageId);
@@ -165,6 +213,13 @@ export class MessageStore {
165
213
  throw new Error(`Message not found at index: ${index}`);
166
214
  }
167
215
 
216
+ if (oldMessage.bodyGroupId) {
217
+ throw new Error(
218
+ `Cannot edit shard ${messageId}: it is part of bodyGroup ${oldMessage.bodyGroupId}. ` +
219
+ `Sharded messages are immutable — remove the whole bodyGroup and re-append instead.`,
220
+ );
221
+ }
222
+
168
223
  const oldContent = this.blobManager.resolveBlobs(oldMessage.content);
169
224
  const storedContent = this.blobManager.extractBlobs(newContent);
170
225
 
@@ -181,6 +236,11 @@ export class MessageStore {
181
236
 
182
237
  /**
183
238
  * Remove a message from the store.
239
+ *
240
+ * For a sharded message (one shard of a bodyGroup), the caller MUST
241
+ * remove all shards in the group together — removing one shard would
242
+ * orphan the rest and break byte-faithful reassembly. Use
243
+ * `removeBodyGroup(id)` for that case.
184
244
  */
185
245
  remove(messageId: MessageId): void {
186
246
  const index = this.idToIndex.get(messageId);
@@ -188,14 +248,61 @@ export class MessageStore {
188
248
  throw new Error(`Message not found: ${messageId}`);
189
249
  }
190
250
 
251
+ const target = this.getInternal(index);
252
+ if (target?.bodyGroupId) {
253
+ throw new Error(
254
+ `Cannot remove shard ${messageId} in isolation: it is part of bodyGroup ${target.bodyGroupId}. ` +
255
+ `Use removeBodyGroup(${messageId}) to remove all shards atomically.`,
256
+ );
257
+ }
258
+
191
259
  this.store.redactStateItems(this.stateId, index, index + 1);
192
260
  this.rebuildIndex();
193
261
 
194
262
  this.emit({ type: 'remove', messageId });
195
263
  }
196
264
 
265
+ /**
266
+ * Remove every shard of the bodyGroup containing `messageId`. If the
267
+ * message is not part of a bodyGroup, falls back to single-message
268
+ * remove. Atomic from the caller's perspective.
269
+ */
270
+ removeBodyGroup(messageId: MessageId): void {
271
+ const index = this.idToIndex.get(messageId);
272
+ if (index === undefined) {
273
+ throw new Error(`Message not found: ${messageId}`);
274
+ }
275
+ const target = this.getInternal(index);
276
+ if (!target?.bodyGroupId) {
277
+ // Not sharded — defer to normal remove path (re-look up via getInternal
278
+ // since the normal `remove` checks bodyGroupId).
279
+ this.store.redactStateItems(this.stateId, index, index + 1);
280
+ this.rebuildIndex();
281
+ this.emit({ type: 'remove', messageId });
282
+ return;
283
+ }
284
+ // Find the contiguous run of shards with this bodyGroupId. Shards are
285
+ // stored consecutively (they're appended one after the other at
286
+ // ingestion), so we can scan outward from `index`.
287
+ const groupId = target.bodyGroupId;
288
+ const all = this.getAllInternal();
289
+ let from = index;
290
+ while (from > 0 && all[from - 1].bodyGroupId === groupId) from--;
291
+ let to = index;
292
+ while (to + 1 < all.length && all[to + 1].bodyGroupId === groupId) to++;
293
+ const firstId = all[from].id;
294
+ const lastId = all[to].id;
295
+ this.store.redactStateItems(this.stateId, from, to + 1);
296
+ this.rebuildIndex();
297
+ this.emit({ type: 'removeRange', fromId: firstId, toId: lastId });
298
+ }
299
+
197
300
  /**
198
301
  * Remove a range of messages from the store.
302
+ *
303
+ * If the range starts or ends in the middle of a bodyGroup, throws —
304
+ * removeRange must align to bodyGroup boundaries. (Use `removeBodyGroup`
305
+ * to remove an entire group, then call `removeRange` over plain messages.)
199
306
  */
200
307
  removeRange(fromId: MessageId, toId: MessageId): void {
201
308
  const fromIndex = this.idToIndex.get(fromId);
@@ -208,6 +315,21 @@ export class MessageStore {
208
315
  throw new Error(`Message not found: ${toId}`);
209
316
  }
210
317
 
318
+ // Verify the range doesn't bisect a bodyGroup.
319
+ const all = this.getAllInternal();
320
+ const startGroup = all[fromIndex].bodyGroupId;
321
+ const endGroup = all[toIndex].bodyGroupId;
322
+ if (startGroup && fromIndex > 0 && all[fromIndex - 1].bodyGroupId === startGroup) {
323
+ throw new Error(
324
+ `removeRange would bisect bodyGroup ${startGroup} at start. Use removeBodyGroup(${fromId}) first.`,
325
+ );
326
+ }
327
+ if (endGroup && toIndex + 1 < all.length && all[toIndex + 1].bodyGroupId === endGroup) {
328
+ throw new Error(
329
+ `removeRange would bisect bodyGroup ${endGroup} at end. Use removeBodyGroup(${toId}) first.`,
330
+ );
331
+ }
332
+
211
333
  this.store.redactStateItems(this.stateId, fromIndex, toIndex + 1);
212
334
  this.rebuildIndex();
213
335
 
@@ -457,17 +579,34 @@ export class MessageStore {
457
579
  private internalToStored(
458
580
  internal: StoredMessageInternal,
459
581
  id: MessageId,
460
- index: number
582
+ _index: number,
461
583
  ): StoredMessage {
462
- return {
584
+ const stored: StoredMessage = {
463
585
  id,
464
- sequence: index, // Use index as sequence for now
586
+ // chronicle record sequence captured when the message was appended
587
+ // (see `add()` line 131: `sequence: record.sequence`). The previous
588
+ // implementation returned the slot index ("// Use index as sequence
589
+ // for now"), which silently corrupted any downstream code that
590
+ // forwarded this number to chronicle APIs expecting a real sequence
591
+ // — most notably `ContextManager.branchAt`, which would fork the
592
+ // chronicle at the index-mistaken-for-sequence and lose every
593
+ // record between the intended fork point and the actual one. For
594
+ // typical autobio sessions the index-vs-sequence ratio is ~1:2
595
+ // (each message append is accompanied by ~1 autobio state update),
596
+ // so /undo of a 800-message conversation forked ~400 messages back.
597
+ sequence: internal.sequence,
465
598
  participant: internal.participant,
466
599
  content: this.blobManager.resolveBlobs(internal.content),
467
600
  metadata: internal.metadata,
468
601
  timestamp: new Date(internal.timestamp),
469
602
  causedBy: internal.causedBy,
470
603
  };
604
+ // Carry adaptive-resolution fields through unchanged.
605
+ if (internal.bodyGroupId !== undefined) stored.bodyGroupId = internal.bodyGroupId;
606
+ if (internal.shardIndex !== undefined) stored.shardIndex = internal.shardIndex;
607
+ if (internal.currentResolution !== undefined) stored.currentResolution = internal.currentResolution;
608
+ if (internal.lockedByAgent !== undefined) stored.lockedByAgent = internal.lockedByAgent;
609
+ return stored;
471
610
  }
472
611
  }
473
612