@animalabs/agent-framework 0.6.5 → 0.6.7

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 (155) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +56 -2
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +372 -57
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/canonical-json.d.ts +2 -0
  11. package/dist/src/canonical-json.d.ts.map +1 -0
  12. package/dist/src/canonical-json.js +23 -0
  13. package/dist/src/canonical-json.js.map +1 -0
  14. package/dist/src/framework.d.ts +160 -2
  15. package/dist/src/framework.d.ts.map +1 -1
  16. package/dist/src/framework.js +3098 -167
  17. package/dist/src/framework.js.map +1 -1
  18. package/dist/src/gate/event-gate.d.ts.map +1 -1
  19. package/dist/src/gate/event-gate.js +34 -4
  20. package/dist/src/gate/event-gate.js.map +1 -1
  21. package/dist/src/index.d.ts +4 -0
  22. package/dist/src/index.d.ts.map +1 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/mcpl/channel-registry.d.ts +36 -15
  26. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  27. package/dist/src/mcpl/channel-registry.js +322 -76
  28. package/dist/src/mcpl/channel-registry.js.map +1 -1
  29. package/dist/src/mcpl/push-handler.d.ts +1 -0
  30. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  31. package/dist/src/mcpl/push-handler.js +1 -0
  32. package/dist/src/mcpl/push-handler.js.map +1 -1
  33. package/dist/src/mcpl/server-connection.d.ts +39 -4
  34. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  35. package/dist/src/mcpl/server-connection.js +146 -28
  36. package/dist/src/mcpl/server-connection.js.map +1 -1
  37. package/dist/src/mcpl/types.d.ts +43 -12
  38. package/dist/src/mcpl/types.d.ts.map +1 -1
  39. package/dist/src/mcpl/types.js +1 -0
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/modules/workspace/index.d.ts +12 -1
  42. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  43. package/dist/src/modules/workspace/index.js +661 -2
  44. package/dist/src/modules/workspace/index.js.map +1 -1
  45. package/dist/src/modules/workspace/types.d.ts +4 -0
  46. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  47. package/dist/src/modules/workspace/types.js.map +1 -1
  48. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  49. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  50. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  51. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  52. package/dist/src/recovery/offline-branch.d.ts +45 -0
  53. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  54. package/dist/src/recovery/offline-branch.js +362 -0
  55. package/dist/src/recovery/offline-branch.js.map +1 -0
  56. package/dist/src/recovery/recover-cli.d.ts +3 -0
  57. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  58. package/dist/src/recovery/recover-cli.js +129 -0
  59. package/dist/src/recovery/recover-cli.js.map +1 -0
  60. package/dist/src/types/agent.d.ts +37 -0
  61. package/dist/src/types/agent.d.ts.map +1 -1
  62. package/dist/src/types/events.d.ts +1 -0
  63. package/dist/src/types/events.d.ts.map +1 -1
  64. package/dist/src/types/framework.d.ts +14 -0
  65. package/dist/src/types/framework.d.ts.map +1 -1
  66. package/dist/src/types/module.d.ts +31 -0
  67. package/dist/src/types/module.d.ts.map +1 -1
  68. package/dist/src/types/trace.d.ts +4 -0
  69. package/dist/src/types/trace.d.ts.map +1 -1
  70. package/dist/test/agent-runtime-settings.test.d.ts +2 -0
  71. package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
  72. package/dist/test/agent-runtime-settings.test.js +227 -0
  73. package/dist/test/agent-runtime-settings.test.js.map +1 -0
  74. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  75. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  76. package/dist/test/busy-agent-scheduler.test.js +62 -0
  77. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  78. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  79. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  80. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  81. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  82. package/dist/test/channel-registry-routing.test.js +5 -4
  83. package/dist/test/channel-registry-routing.test.js.map +1 -1
  84. package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
  85. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  86. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  87. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  88. package/dist/test/discord-awareness-outbox.test.js +248 -0
  89. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  90. package/dist/test/event-gate.test.js +21 -1
  91. package/dist/test/event-gate.test.js.map +1 -1
  92. package/dist/test/helpers/mock-membrane.d.ts +13 -1
  93. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  94. package/dist/test/helpers/mock-membrane.js +9 -2
  95. package/dist/test/helpers/mock-membrane.js.map +1 -1
  96. package/dist/test/maintenance-timer.test.js +103 -1
  97. package/dist/test/maintenance-timer.test.js.map +1 -1
  98. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  99. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  100. package/dist/test/mcpl-awareness-barrier.test.js +845 -0
  101. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  102. package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
  103. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  104. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  105. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  106. package/dist/test/mcpl-request-timeout.test.js +15 -0
  107. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  108. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  109. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  110. package/dist/test/offline-recovery-branch.test.js +226 -0
  111. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  112. package/dist/test/present-while-acting.test.d.ts +19 -0
  113. package/dist/test/present-while-acting.test.d.ts.map +1 -0
  114. package/dist/test/present-while-acting.test.js +295 -0
  115. package/dist/test/present-while-acting.test.js.map +1 -0
  116. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
  117. package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
  118. package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
  119. package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
  120. package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
  121. package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
  122. package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
  123. package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
  124. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  125. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  126. package/dist/test/same-round-think-policy.test.js +853 -0
  127. package/dist/test/same-round-think-policy.test.js.map +1 -0
  128. package/dist/test/trunk-channel-routing.test.js +38 -0
  129. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  130. package/dist/test/workspace-read-image.test.d.ts +2 -0
  131. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  132. package/dist/test/workspace-read-image.test.js +518 -0
  133. package/dist/test/workspace-read-image.test.js.map +1 -0
  134. package/dist/tsconfig.tsbuildinfo +1 -1
  135. package/package.json +6 -4
  136. package/src/agent.ts +485 -63
  137. package/src/api/server.ts +8 -2
  138. package/src/canonical-json.ts +22 -0
  139. package/src/framework.ts +4096 -304
  140. package/src/gate/event-gate.ts +50 -4
  141. package/src/index.ts +20 -0
  142. package/src/mcpl/channel-registry.ts +396 -85
  143. package/src/mcpl/push-handler.ts +2 -0
  144. package/src/mcpl/server-connection.ts +173 -28
  145. package/src/mcpl/types.ts +49 -12
  146. package/src/modules/workspace/index.ts +759 -2
  147. package/src/modules/workspace/types.ts +5 -0
  148. package/src/recovery/discord-awareness-outbox.ts +459 -0
  149. package/src/recovery/offline-branch.ts +510 -0
  150. package/src/recovery/recover-cli.ts +130 -0
  151. package/src/types/agent.ts +44 -0
  152. package/src/types/events.ts +1 -1
  153. package/src/types/framework.ts +17 -0
  154. package/src/types/module.ts +33 -0
  155. package/src/types/trace.ts +4 -0
@@ -0,0 +1,510 @@
1
+ import { JsStore } from '@animalabs/chronicle';
2
+ import { ContextManager } from '@animalabs/context-manager';
3
+ import {
4
+ DEFAULT_DISCORD_AWARENESS_EMOJI,
5
+ DiscordAwarenessOutbox,
6
+ defaultDiscordAwarenessOutboxPath,
7
+ extractDiscordAwarenessRefs,
8
+ type DiscordAwarenessRef,
9
+ } from './discord-awareness-outbox.js';
10
+
11
+ export interface OfflineRecoveryBranchOptions {
12
+ storePath: string;
13
+ agentName: string;
14
+ /** Preferred: Discord message that remains as the last visible anchor. */
15
+ messageId?: string;
16
+ /** Exact internal ContextManager message ID that remains as the anchor. */
17
+ contextId?: string;
18
+ /** Backward-compatible alternative: remove the newest N context entries. */
19
+ messages?: number;
20
+ /** Exact Discord messages to suppress on the new branch. */
21
+ suppressMessageIds?: string[];
22
+ /** Inclusive context-order ranges between Discord message endpoints. */
23
+ suppressRanges?: Array<{ fromMessageId: string; toMessageId: string }>;
24
+ branchName?: string;
25
+ /** Defaults to the primary-agent namespace `agents/{agentName}`. */
26
+ namespace?: string;
27
+ /** Use for old message records that do not carry metadata.serverId. */
28
+ discordServerId?: string;
29
+ outboxPath?: string;
30
+ emoji?: string;
31
+ dryRun?: boolean;
32
+ }
33
+
34
+ export interface OfflineRecoveryBranchResult {
35
+ dryRun: boolean;
36
+ sourceBranch: string;
37
+ targetBranch: string;
38
+ messagesRemoved: number;
39
+ messagesSuppressed: number;
40
+ discordMarkersQueued: number;
41
+ refs: DiscordAwarenessRef[];
42
+ outboxPath: string;
43
+ }
44
+
45
+ /**
46
+ * Create a safe Chronicle branch while the normal agent host is stopped.
47
+ *
48
+ * History is scanned in bounded windows and blobs are not re-inlined. The
49
+ * affected messages are never compiled and their content is never written to
50
+ * the recovery outbox; only Discord addressing metadata leaves Chronicle.
51
+ */
52
+ export async function createOfflineRecoveryBranch(
53
+ options: OfflineRecoveryBranchOptions,
54
+ ): Promise<OfflineRecoveryBranchResult> {
55
+ const anchorCount = Number(!!options.messageId)
56
+ + Number(!!options.contextId)
57
+ + Number(options.messages !== undefined);
58
+ if (anchorCount !== 1) {
59
+ throw new Error('Specify exactly one of messageId, contextId, or messages');
60
+ }
61
+ const count = options.messages === undefined ? undefined : Math.floor(options.messages);
62
+ if (count !== undefined && (!Number.isFinite(count) || count < 1)) {
63
+ throw new Error('messages must be a positive integer');
64
+ }
65
+
66
+ const store = JsStore.openOrCreate({ path: options.storePath });
67
+ let contextManager: ContextManager | null = null;
68
+ try {
69
+ contextManager = await ContextManager.open({
70
+ store,
71
+ namespace: options.namespace ?? `agents/${options.agentName}`,
72
+ });
73
+
74
+ const total = contextManager.getMessageCount();
75
+ if (count !== undefined && count >= total) {
76
+ throw new Error(
77
+ `Cannot remove ${count} message(s) — history has ${total}; at least one must remain.`,
78
+ );
79
+ }
80
+
81
+ let target: StoredWindowMessage;
82
+ let targetIndex: number;
83
+ let removedCount: number;
84
+ let refs: DiscordAwarenessRef[];
85
+
86
+ if (options.messageId) {
87
+ // Search backward in bounded windows: recovery anchors are normally near
88
+ // the tail, and even a very large Chronicle is never materialized all at
89
+ // once. If an adaptive strategy sharded the Discord message, choosing the
90
+ // newest matching shard keeps the complete message at the branch point.
91
+ const anchorId = normalizeDiscordMessageId(options.messageId);
92
+ const located = findDiscordMessageFromTail(contextManager, total, anchorId);
93
+ if (!located) {
94
+ throw new Error(
95
+ `Discord message ${anchorId} is not an addressable message in this context.`,
96
+ );
97
+ }
98
+ target = located.message;
99
+ targetIndex = located.index;
100
+ removedCount = total - located.index - 1;
101
+ refs = collectDiscordRefs(
102
+ contextManager,
103
+ located.index + 1,
104
+ total,
105
+ options.discordServerId,
106
+ );
107
+ } else if (options.contextId) {
108
+ const located = findInternalMessageFromTail(contextManager, total, options.contextId.trim());
109
+ if (!located) {
110
+ throw new Error(`Internal context message ${options.contextId} was not found.`);
111
+ }
112
+ target = located.message;
113
+ targetIndex = located.index;
114
+ removedCount = total - located.index - 1;
115
+ refs = collectDiscordRefs(
116
+ contextManager,
117
+ located.index + 1,
118
+ total,
119
+ options.discordServerId,
120
+ );
121
+ } else {
122
+ // Count mode remains for compatibility. Read only the target plus suffix,
123
+ // avoiding full-history materialization and attachment blob inflation.
124
+ const window = contextManager.getMessageWindow(total - count! - 1, count! + 1, {
125
+ resolveBlobs: false,
126
+ }).messages;
127
+ const countTarget = window[0];
128
+ const discarded = window.slice(1);
129
+ if (!countTarget || discarded.length !== count) {
130
+ throw new Error('Could not read the requested recovery window from Chronicle');
131
+ }
132
+ target = countTarget;
133
+ targetIndex = total - count! - 1;
134
+ removedCount = count!;
135
+ refs = extractDiscordAwarenessRefs(discarded, options.discordServerId);
136
+ }
137
+
138
+ validateAnchorBoundary(contextManager, targetIndex, total, target);
139
+
140
+ const suppression = buildSuppressionPlan(
141
+ contextManager,
142
+ targetIndex,
143
+ options.suppressMessageIds ?? [],
144
+ options.suppressRanges ?? [],
145
+ options.discordServerId,
146
+ );
147
+ validateRemovalIntegrity(
148
+ contextManager,
149
+ total,
150
+ [
151
+ ...(removedCount > 0 ? [{
152
+ start: targetIndex + 1,
153
+ end: total - 1,
154
+ fromId: '',
155
+ toId: '',
156
+ }] : []),
157
+ ...suppression.intervals,
158
+ ],
159
+ );
160
+ refs = dedupeDiscordRefs([...refs, ...suppression.refs]);
161
+
162
+ const sourceBranch = store.currentBranch().name;
163
+ const targetBranch = options.branchName
164
+ ?? `recovery/${options.agentName}/${Date.now()}`;
165
+ if (targetBranch === sourceBranch) {
166
+ throw new Error('Recovery branch name must differ from the active source branch');
167
+ }
168
+ const outboxPath = options.outboxPath
169
+ ?? defaultDiscordAwarenessOutboxPath(options.storePath);
170
+
171
+ const result: OfflineRecoveryBranchResult = {
172
+ dryRun: options.dryRun === true,
173
+ sourceBranch,
174
+ targetBranch,
175
+ messagesRemoved: removedCount,
176
+ messagesSuppressed: suppression.messageCount,
177
+ discordMarkersQueued: refs.length,
178
+ refs,
179
+ outboxPath,
180
+ };
181
+ if (options.dryRun) return result;
182
+
183
+ const outbox = new DiscordAwarenessOutbox(outboxPath);
184
+ const batch = outbox.prepare({
185
+ agentName: options.agentName,
186
+ sourceBranch,
187
+ targetBranch,
188
+ refs,
189
+ emoji: options.emoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
190
+ // Merely seeing targetBranch active does not prove branch-local
191
+ // suppressions finished. Only the recovery operation may activate this
192
+ // batch after every removal below succeeds.
193
+ activationPolicy: suppression.intervals.length > 0 ? 'explicit' : 'target-branch',
194
+ suppressionIntervals: suppression.intervals.map((interval) => ({
195
+ fromId: String(interval.fromId),
196
+ toId: String(interval.toId),
197
+ })),
198
+ });
199
+
200
+ // branchAt uses the target message's origin sequence. No message content is
201
+ // compiled or submitted to Membrane during this operation.
202
+ const createdBranch = contextManager.branchAt(target.id, targetBranch);
203
+ try {
204
+ await contextManager.switchBranch(createdBranch);
205
+ // Work from newest to oldest so earlier redactions never perturb the
206
+ // live positions used by later range endpoints.
207
+ for (const interval of [...suppression.intervals].reverse()) {
208
+ if (interval.fromId === interval.toId) contextManager.removeMessage(interval.fromId);
209
+ else contextManager.removeMessages(interval.fromId, interval.toId);
210
+ }
211
+ if (batch) outbox.activate(batch.id);
212
+ } catch (error) {
213
+ // A partially suppressed branch is not safe to boot into. Preserve it
214
+ // for diagnosis, but leave Chronicle on the untouched source branch and
215
+ // keep the explicit outbox batch non-deliverable.
216
+ if (store.currentBranch().name === createdBranch) {
217
+ await contextManager.switchBranch(sourceBranch);
218
+ }
219
+ throw error;
220
+ }
221
+
222
+ return result;
223
+ } finally {
224
+ contextManager?.close();
225
+ store.close();
226
+ }
227
+ }
228
+
229
+ const RECOVERY_SCAN_WINDOW = 1_000;
230
+ type StoredWindowMessage = ReturnType<ContextManager['getMessageWindow']>['messages'][number];
231
+
232
+ interface SuppressionInterval {
233
+ start: number;
234
+ end: number;
235
+ fromId: StoredWindowMessage['id'];
236
+ toId: StoredWindowMessage['id'];
237
+ }
238
+
239
+ interface MessageLocation {
240
+ first: number;
241
+ last: number;
242
+ firstId: StoredWindowMessage['id'];
243
+ lastId: StoredWindowMessage['id'];
244
+ /** Usually one run; multiple runs tolerate duplicate external metadata. */
245
+ runs: SuppressionInterval[];
246
+ }
247
+
248
+ function normalizeDiscordMessageId(input: string): string {
249
+ const trimmed = input.trim();
250
+ const link = trimmed.match(/channels\/\d+\/\d+\/(\d+)/);
251
+ return link?.[1] ?? trimmed;
252
+ }
253
+
254
+ function findDiscordMessageFromTail(
255
+ contextManager: ContextManager,
256
+ total: number,
257
+ messageId: string,
258
+ ): { message: ReturnType<ContextManager['getMessageWindow']>['messages'][number]; index: number } | null {
259
+ let end = total;
260
+ while (end > 0) {
261
+ const start = Math.max(0, end - RECOVERY_SCAN_WINDOW);
262
+ const messages = contextManager.getMessageWindow(start, end - start, {
263
+ resolveBlobs: false,
264
+ }).messages;
265
+ for (let i = messages.length - 1; i >= 0; i--) {
266
+ if (String(messages[i].metadata?.messageId ?? '') === messageId) {
267
+ return { message: messages[i], index: start + i };
268
+ }
269
+ }
270
+ end = start;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ function findInternalMessageFromTail(
276
+ contextManager: ContextManager,
277
+ total: number,
278
+ contextId: string,
279
+ ): { message: StoredWindowMessage; index: number } | null {
280
+ let end = total;
281
+ while (end > 0) {
282
+ const start = Math.max(0, end - RECOVERY_SCAN_WINDOW);
283
+ const messages = contextManager.getMessageWindow(start, end - start, {
284
+ resolveBlobs: false,
285
+ }).messages;
286
+ for (let i = messages.length - 1; i >= 0; i--) {
287
+ if (String(messages[i].id) === contextId) {
288
+ return { message: messages[i], index: start + i };
289
+ }
290
+ }
291
+ end = start;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ function validateAnchorBoundary(
297
+ contextManager: ContextManager,
298
+ targetIndex: number,
299
+ total: number,
300
+ target: StoredWindowMessage,
301
+ ): void {
302
+ const groupId = (target as StoredWindowMessage & { bodyGroupId?: string }).bodyGroupId;
303
+ if (!groupId || targetIndex + 1 >= total) return;
304
+ const next = contextManager.getMessageWindow(targetIndex + 1, 1, {
305
+ resolveBlobs: false,
306
+ }).messages[0] as (StoredWindowMessage & { bodyGroupId?: string }) | undefined;
307
+ if (next?.bodyGroupId === groupId) {
308
+ throw new Error(
309
+ `Recovery anchor ${String(target.id)} splits body group ${groupId}; choose its final shard.`,
310
+ );
311
+ }
312
+ }
313
+
314
+ function validateRemovalIntegrity(
315
+ contextManager: ContextManager,
316
+ total: number,
317
+ intervals: SuppressionInterval[],
318
+ ): void {
319
+ if (intervals.length === 0) return;
320
+ const removed = (index: number) => intervals.some((interval) =>
321
+ index >= interval.start && index <= interval.end);
322
+ const toolUses = new Map<string, boolean>();
323
+ const toolResults = new Map<string, boolean>();
324
+ const groupMembership = new Map<string, boolean>();
325
+
326
+ for (let offset = 0; offset < total; offset += RECOVERY_SCAN_WINDOW) {
327
+ const messages = contextManager.getMessageWindow(
328
+ offset,
329
+ Math.min(RECOVERY_SCAN_WINDOW, total - offset),
330
+ { resolveBlobs: false },
331
+ ).messages;
332
+ for (let i = 0; i < messages.length; i++) {
333
+ const index = offset + i;
334
+ const isRemoved = removed(index);
335
+ const message = messages[i] as StoredWindowMessage & { bodyGroupId?: string };
336
+ if (message.bodyGroupId) {
337
+ const prior = groupMembership.get(message.bodyGroupId);
338
+ if (prior !== undefined && prior !== isRemoved) {
339
+ throw new Error(
340
+ `Recovery selection splits body group ${message.bodyGroupId}; adjust the anchor or suppression boundary.`,
341
+ );
342
+ }
343
+ groupMembership.set(message.bodyGroupId, isRemoved);
344
+ }
345
+ for (const rawBlock of message.content) {
346
+ const block = rawBlock as { type?: unknown; id?: unknown; toolUseId?: unknown };
347
+ if (block.type === 'tool_use' && typeof block.id === 'string') {
348
+ toolUses.set(block.id, isRemoved);
349
+ } else if (block.type === 'tool_result' && typeof block.toolUseId === 'string') {
350
+ toolResults.set(block.toolUseId, isRemoved);
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ for (const [toolUseId, useRemoved] of toolUses) {
357
+ const resultRemoved = toolResults.get(toolUseId);
358
+ if (resultRemoved !== undefined && resultRemoved !== useRemoved) {
359
+ throw new Error(
360
+ `Recovery selection would split tool exchange ${toolUseId}; suppress or retain both tool_use and tool_result.`,
361
+ );
362
+ }
363
+ }
364
+ }
365
+
366
+ function buildSuppressionPlan(
367
+ contextManager: ContextManager,
368
+ targetIndex: number,
369
+ rawMessageIds: string[],
370
+ rawRanges: Array<{ fromMessageId: string; toMessageId: string }>,
371
+ forcedServerId?: string,
372
+ ): { intervals: SuppressionInterval[]; refs: DiscordAwarenessRef[]; messageCount: number } {
373
+ const messageIds = rawMessageIds.map(normalizeDiscordMessageId);
374
+ const ranges = rawRanges.map((range) => ({
375
+ fromMessageId: normalizeDiscordMessageId(range.fromMessageId),
376
+ toMessageId: normalizeDiscordMessageId(range.toMessageId),
377
+ }));
378
+ const wanted = new Set([
379
+ ...messageIds,
380
+ ...ranges.flatMap((range) => [range.fromMessageId, range.toMessageId]),
381
+ ]);
382
+ if (wanted.size === 0) return { intervals: [], refs: [], messageCount: 0 };
383
+
384
+ const locations = new Map<string, MessageLocation>();
385
+ for (let offset = 0; offset <= targetIndex; offset += RECOVERY_SCAN_WINDOW) {
386
+ const messages = contextManager.getMessageWindow(
387
+ offset,
388
+ Math.min(RECOVERY_SCAN_WINDOW, targetIndex - offset + 1),
389
+ { resolveBlobs: false },
390
+ ).messages;
391
+ for (let i = 0; i < messages.length; i++) {
392
+ const externalId = String(messages[i].metadata?.messageId ?? '');
393
+ if (!wanted.has(externalId)) continue;
394
+ const index = offset + i;
395
+ const location = locations.get(externalId);
396
+ if (!location) {
397
+ const run = {
398
+ start: index,
399
+ end: index,
400
+ fromId: messages[i].id,
401
+ toId: messages[i].id,
402
+ };
403
+ locations.set(externalId, {
404
+ first: index,
405
+ last: index,
406
+ firstId: messages[i].id,
407
+ lastId: messages[i].id,
408
+ runs: [run],
409
+ });
410
+ } else {
411
+ const lastRun = location.runs.at(-1)!;
412
+ if (index === lastRun.end + 1) {
413
+ lastRun.end = index;
414
+ lastRun.toId = messages[i].id;
415
+ } else {
416
+ location.runs.push({
417
+ start: index,
418
+ end: index,
419
+ fromId: messages[i].id,
420
+ toId: messages[i].id,
421
+ });
422
+ }
423
+ location.last = index;
424
+ location.lastId = messages[i].id;
425
+ }
426
+ }
427
+ }
428
+
429
+ for (const id of wanted) {
430
+ if (!locations.has(id)) {
431
+ throw new Error(
432
+ `Suppression endpoint ${id} is not visible at the selected branch point.`,
433
+ );
434
+ }
435
+ }
436
+
437
+ const intervals: SuppressionInterval[] = [];
438
+ for (const id of messageIds) {
439
+ const location = locations.get(id)!;
440
+ intervals.push(...location.runs.map((run) => ({ ...run })));
441
+ }
442
+ for (const range of ranges) {
443
+ const from = locations.get(range.fromMessageId)!;
444
+ const to = locations.get(range.toMessageId)!;
445
+ const earlier = from.first <= to.first ? from : to;
446
+ const later = from.first <= to.first ? to : from;
447
+ intervals.push({
448
+ start: earlier.first,
449
+ end: later.last,
450
+ fromId: earlier.firstId,
451
+ toId: later.lastId,
452
+ });
453
+ }
454
+
455
+ intervals.sort((a, b) => a.start - b.start || a.end - b.end);
456
+ const merged: SuppressionInterval[] = [];
457
+ for (const interval of intervals) {
458
+ const previous = merged.at(-1);
459
+ if (previous && interval.start <= previous.end + 1) {
460
+ if (interval.end > previous.end) {
461
+ previous.end = interval.end;
462
+ previous.toId = interval.toId;
463
+ }
464
+ } else {
465
+ merged.push({ ...interval });
466
+ }
467
+ }
468
+
469
+ const refs: DiscordAwarenessRef[] = [];
470
+ for (const interval of merged) {
471
+ refs.push(...collectDiscordRefs(
472
+ contextManager,
473
+ interval.start,
474
+ interval.end + 1,
475
+ forcedServerId,
476
+ ));
477
+ }
478
+ return {
479
+ intervals: merged,
480
+ refs: dedupeDiscordRefs(refs),
481
+ messageCount: merged.reduce((sum, interval) => sum + interval.end - interval.start + 1, 0),
482
+ };
483
+ }
484
+
485
+ function collectDiscordRefs(
486
+ contextManager: ContextManager,
487
+ start: number,
488
+ end: number,
489
+ forcedServerId?: string,
490
+ ): DiscordAwarenessRef[] {
491
+ const refs: DiscordAwarenessRef[] = [];
492
+ for (let offset = start; offset < end; offset += RECOVERY_SCAN_WINDOW) {
493
+ const messages = contextManager.getMessageWindow(
494
+ offset,
495
+ Math.min(RECOVERY_SCAN_WINDOW, end - offset),
496
+ { resolveBlobs: false },
497
+ ).messages;
498
+ refs.push(...extractDiscordAwarenessRefs(messages, forcedServerId));
499
+ }
500
+ // A sharded message may repeat Discord metadata across adjacent records.
501
+ return dedupeDiscordRefs(refs);
502
+ }
503
+
504
+ function dedupeDiscordRefs(refs: DiscordAwarenessRef[]): DiscordAwarenessRef[] {
505
+ const deduped = new Map<string, DiscordAwarenessRef>();
506
+ for (const ref of refs) {
507
+ deduped.set(`${ref.serverId}\0${ref.channelId}\0${ref.messageId}`, ref);
508
+ }
509
+ return [...deduped.values()];
510
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createOfflineRecoveryBranch } from './offline-branch.js';
4
+
5
+ interface CliOptions {
6
+ storePath?: string;
7
+ agentName?: string;
8
+ messageId?: string;
9
+ contextId?: string;
10
+ messages?: number;
11
+ suppressMessageIds: string[];
12
+ suppressRanges: Array<{ fromMessageId: string; toMessageId: string }>;
13
+ branchName?: string;
14
+ namespace?: string;
15
+ discordServerId?: string;
16
+ outboxPath?: string;
17
+ emoji?: string;
18
+ dryRun: boolean;
19
+ }
20
+
21
+ function usage(): string {
22
+ return `Usage:
23
+ agent-framework-recover --store <path> --agent <name> <anchor> [options]
24
+
25
+ Creates and activates a Chronicle recovery branch while the normal host is down,
26
+ then queues Discord reactions for addressable messages removed from awareness.
27
+ Message content is never compiled or written to the recovery outbox.
28
+
29
+ Options:
30
+ --message-id <id|link> Last Discord message the agent should still see
31
+ --context-id <internal-id> Exact internal context message to retain as anchor
32
+ --messages <count> Legacy alternative: remove N context entries
33
+ --suppress <id|link> Suppress one Discord message; repeat or comma-separate
34
+ --suppress-range <a>..<b> Suppress an inclusive context range; repeatable
35
+ --branch <name> Recovery branch name (generated by default)
36
+ --namespace <namespace> Context namespace (default: agents/<agent>)
37
+ --discord-server <id> Override/inject the Discord MCPL server id
38
+ --outbox <path> Override the branch-independent outbox path
39
+ --emoji <emoji> Marker reaction (default: 💤)
40
+ --dry-run Inspect counts/addresses without writing
41
+ --help Show this help
42
+
43
+ The agent host must be stopped before running this command.`;
44
+ }
45
+
46
+ function parseArgs(args: string[]): CliOptions {
47
+ const options: CliOptions = { dryRun: false, suppressMessageIds: [], suppressRanges: [] };
48
+ for (let i = 0; i < args.length; i++) {
49
+ const arg = args[i];
50
+ const value = () => {
51
+ const next = args[++i];
52
+ if (!next) throw new Error(`${arg} requires a value`);
53
+ return next;
54
+ };
55
+ switch (arg) {
56
+ case '--store': options.storePath = value(); break;
57
+ case '--agent': options.agentName = value(); break;
58
+ case '--message-id':
59
+ case '--message': options.messageId = value(); break;
60
+ case '--context-id': options.contextId = value(); break;
61
+ case '--messages': options.messages = Number(value()); break;
62
+ case '--suppress':
63
+ case '--suppress-message':
64
+ options.suppressMessageIds.push(...value().split(',').map((item) => item.trim()).filter(Boolean));
65
+ break;
66
+ case '--suppress-range': {
67
+ const raw = value();
68
+ const separator = raw.indexOf('..');
69
+ if (separator <= 0 || separator >= raw.length - 2) {
70
+ throw new Error('--suppress-range must be formatted as <first>..<last>');
71
+ }
72
+ options.suppressRanges.push({
73
+ fromMessageId: raw.slice(0, separator),
74
+ toMessageId: raw.slice(separator + 2),
75
+ });
76
+ break;
77
+ }
78
+ case '--branch': options.branchName = value(); break;
79
+ case '--namespace': options.namespace = value(); break;
80
+ case '--discord-server': options.discordServerId = value(); break;
81
+ case '--outbox': options.outboxPath = value(); break;
82
+ case '--emoji': options.emoji = value(); break;
83
+ case '--dry-run': options.dryRun = true; break;
84
+ case '--help':
85
+ case '-h':
86
+ console.log(usage());
87
+ process.exit(0);
88
+ default: throw new Error(`Unknown argument: ${arg}`);
89
+ }
90
+ }
91
+ return options;
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ const options = parseArgs(process.argv.slice(2));
96
+ if (
97
+ !options.storePath
98
+ || !options.agentName
99
+ || (!options.messageId && !options.contextId && options.messages === undefined)
100
+ ) {
101
+ throw new Error(`--store, --agent, and one anchor (--message-id, --context-id, or --messages) are required\n\n${usage()}`);
102
+ }
103
+
104
+ const result = await createOfflineRecoveryBranch({
105
+ storePath: options.storePath,
106
+ agentName: options.agentName,
107
+ messageId: options.messageId,
108
+ contextId: options.contextId,
109
+ messages: options.messages,
110
+ suppressMessageIds: options.suppressMessageIds,
111
+ suppressRanges: options.suppressRanges,
112
+ branchName: options.branchName,
113
+ namespace: options.namespace,
114
+ discordServerId: options.discordServerId,
115
+ outboxPath: options.outboxPath,
116
+ emoji: options.emoji,
117
+ dryRun: options.dryRun,
118
+ });
119
+
120
+ console.log(JSON.stringify({
121
+ ...result,
122
+ // Keep the output useful for ops without ever exposing message content.
123
+ refs: result.refs.map(({ serverId, channelId, messageId }) => ({ serverId, channelId, messageId })),
124
+ }, null, 2));
125
+ }
126
+
127
+ main().catch((error) => {
128
+ console.error(`[agent-framework-recover] ${error instanceof Error ? error.message : String(error)}`);
129
+ process.exitCode = 1;
130
+ });
@@ -2,6 +2,12 @@ import type { ContentBlock, YieldingStream } from '@animalabs/membrane';
2
2
  import type { ContextStrategy } from '@animalabs/context-manager';
3
3
  import type { ToolCallId, ToolResult, ToolCall } from './events.js';
4
4
 
5
+ export type SameRoundThinkTextPolicy = 'public' | 'private';
6
+ export type SameRoundThinkTextPolicySource =
7
+ | 'runtime_override'
8
+ | 'recipe'
9
+ | 'compatibility_default';
10
+
5
11
  /**
6
12
  * Configuration for an agent.
7
13
  */
@@ -65,6 +71,11 @@ export interface AgentConfig {
65
71
  /** Provider-specific request parameters (for example Responses reasoning
66
72
  * and server-side compaction settings). */
67
73
  providerParams?: Record<string, unknown>;
74
+ /**
75
+ * Same-round routing policy for ordinary text emitted beside `think`.
76
+ * Omitted preserves the compatibility carry-forward: public.
77
+ */
78
+ sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
68
79
  /**
69
80
  * Extended thinking config. When `enabled: true`, the agent runs with
70
81
  * Anthropic's native extended thinking; responses include `thinking` blocks
@@ -104,9 +115,42 @@ export interface AgentConfig {
104
115
  * than dropping it silently. Default true.
105
116
  */
106
117
  announceHumanTurns?: boolean;
118
+ /**
119
+ * Primary-lane autobiographical summary refusal fallback. Disabled by
120
+ * default: when enabled, the framework may compare the refused primary
121
+ * request against the latest compatible healthy request on the same branch
122
+ * generation, expand newly admitted summary pairs back to exact raw
123
+ * source, and issue at most one retry.
124
+ */
125
+ primarySummaryFallback?: {
126
+ enabled?: boolean;
127
+ maxNewSummaries?: number;
128
+ requestBudgetTokens?: number;
129
+ };
107
130
  };
108
131
  }
109
132
 
133
+ /** The intentionally small set of agent settings that may change in-process. */
134
+ export interface AgentRuntimeSettingsPatch {
135
+ contextBudgetTokens?: number;
136
+ tailTokens?: number;
137
+ transitionPaceTokens?: number;
138
+ sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
139
+ }
140
+
141
+ export interface AgentRuntimeSettingsSnapshot {
142
+ contextBudgetTokens: number;
143
+ tailTokens?: number;
144
+ transitionPaceTokens?: number;
145
+ sameRoundThinkTextPolicy: SameRoundThinkTextPolicy;
146
+ sameRoundThinkTextPolicySource: SameRoundThinkTextPolicySource;
147
+ transition: 'stable' | 'converging' | 'blocked';
148
+ transitionReason?: 'transition_pace_too_small' | 'protected_context_exceeds_target';
149
+ }
150
+
151
+ /** Persisted values differ from recipe defaults; omitted keys inherit config. */
152
+ export type AgentRuntimeSettingsOverrides = AgentRuntimeSettingsPatch;
153
+
110
154
  /**
111
155
  * Result of running inference.
112
156
  */