@animalabs/agent-framework 0.15.0 → 0.17.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 (100) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +32 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +55 -0
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts +9 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +156 -0
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/api/types.d.ts +15 -0
  11. package/dist/src/api/types.d.ts.map +1 -1
  12. package/dist/src/framework.d.ts +423 -3
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1857 -75
  15. package/dist/src/framework.js.map +1 -1
  16. package/dist/src/gate/event-gate.d.ts +16 -0
  17. package/dist/src/gate/event-gate.d.ts.map +1 -1
  18. package/dist/src/gate/event-gate.js +41 -3
  19. package/dist/src/gate/event-gate.js.map +1 -1
  20. package/dist/src/index.d.ts +4 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/channel-registry.d.ts +172 -0
  25. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  26. package/dist/src/mcpl/channel-registry.js +444 -2
  27. package/dist/src/mcpl/channel-registry.js.map +1 -1
  28. package/dist/src/mcpl/server-connection.d.ts +9 -0
  29. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  30. package/dist/src/mcpl/server-connection.js +71 -13
  31. package/dist/src/mcpl/server-connection.js.map +1 -1
  32. package/dist/src/modules/history/index.d.ts +89 -13
  33. package/dist/src/modules/history/index.d.ts.map +1 -1
  34. package/dist/src/modules/history/index.js +655 -24
  35. package/dist/src/modules/history/index.js.map +1 -1
  36. package/dist/src/operator-log.d.ts +78 -0
  37. package/dist/src/operator-log.d.ts.map +1 -0
  38. package/dist/src/operator-log.js +102 -0
  39. package/dist/src/operator-log.js.map +1 -0
  40. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  41. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  42. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  43. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  44. package/dist/src/types/framework.d.ts +56 -0
  45. package/dist/src/types/framework.d.ts.map +1 -1
  46. package/dist/src/types/trace.d.ts +36 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/agent-runtime-settings.test.js +199 -1
  49. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  50. package/dist/test/api-admin-gate.test.d.ts +2 -0
  51. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  52. package/dist/test/api-admin-gate.test.js +116 -0
  53. package/dist/test/api-admin-gate.test.js.map +1 -0
  54. package/dist/test/channel-label-history.test.d.ts +2 -0
  55. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  56. package/dist/test/channel-label-history.test.js +424 -0
  57. package/dist/test/channel-label-history.test.js.map +1 -0
  58. package/dist/test/discord-awareness-outbox.test.js +16 -0
  59. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  60. package/dist/test/event-gate.test.js +44 -0
  61. package/dist/test/event-gate.test.js.map +1 -1
  62. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  63. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  64. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  65. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  66. package/dist/test/history-module-overview.test.d.ts +2 -0
  67. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  68. package/dist/test/history-module-overview.test.js +711 -0
  69. package/dist/test/history-module-overview.test.js.map +1 -0
  70. package/dist/test/history-module.test.js +146 -0
  71. package/dist/test/history-module.test.js.map +1 -1
  72. package/dist/test/host-quiesce.test.d.ts +2 -0
  73. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  74. package/dist/test/host-quiesce.test.js +863 -0
  75. package/dist/test/host-quiesce.test.js.map +1 -0
  76. package/dist/test/liveness-watchdog.test.js +18 -2
  77. package/dist/test/liveness-watchdog.test.js.map +1 -1
  78. package/dist/test/operator-surgery.test.d.ts +2 -0
  79. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  80. package/dist/test/operator-surgery.test.js +398 -0
  81. package/dist/test/operator-surgery.test.js.map +1 -0
  82. package/dist/test/provider-acceleration-cooldown.test.js +145 -0
  83. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  84. package/dist/test/puppet-tool-call.test.js +5 -0
  85. package/dist/test/puppet-tool-call.test.js.map +1 -1
  86. package/dist/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +2 -2
  88. package/src/agent.ts +69 -0
  89. package/src/api/server.ts +159 -2
  90. package/src/api/types.ts +15 -0
  91. package/src/framework.ts +2155 -96
  92. package/src/gate/event-gate.ts +47 -4
  93. package/src/index.ts +5 -1
  94. package/src/mcpl/channel-registry.ts +495 -2
  95. package/src/mcpl/server-connection.ts +73 -13
  96. package/src/modules/history/index.ts +689 -25
  97. package/src/operator-log.ts +132 -0
  98. package/src/recovery/discord-awareness-outbox.ts +19 -0
  99. package/src/types/framework.ts +63 -0
  100. package/src/types/trace.ts +42 -0
@@ -9,13 +9,25 @@
9
9
  * this module can answer "what happened in #foo last Tuesday" against a
10
10
  * multi-million-message store without walking the whole log.
11
11
  *
12
- * Three tools:
13
- * - `stats` — per-channel message counts (all-time) + token totals
14
- * (range-scoped), for orienting before pulling raw content.
15
- * - `extract` — paginated raw messages for a time range and/or channel.
16
- * - `search` — substring/regex match over a narrowed candidate window,
17
- * with an explicit truncation signal when the caller's
18
- * filter was too broad for `maxScan`.
12
+ * Four tools:
13
+ * - `stats` — per-channel message counts (all-time) + token totals
14
+ * (range-scoped), for orienting before pulling raw content.
15
+ * - `extract` — paginated raw messages for a time range and/or channel.
16
+ * - `search` — substring/regex match over a narrowed candidate window,
17
+ * with an explicit truncation signal when the caller's
18
+ * filter was too broad for `maxScan`.
19
+ * - `overview` — a compression-summary table of contents (zero new LLM
20
+ * calls) for a time range, gap-filled with raw message
21
+ * counts wherever nothing has been summarized yet. See
22
+ * `handleOverview` for the fold-reduction and gap-fill
23
+ * algorithm.
24
+ *
25
+ * All four accept `channelId` as either a channel label (e.g. `#general`)
26
+ * or a raw internal channel id — `resolveChannel()` resolves a label via
27
+ * the bound `ChannelRegistry`'s durable label history before it reaches any
28
+ * context-manager call. A host that hasn't wired up MCPL (no registry
29
+ * bound) sees unchanged behavior: `channelId` is treated as already the raw
30
+ * internal id.
19
31
  *
20
32
  * Kept deliberately read-only, same posture as HealthModule: no side
21
33
  * effects, no message mutation. `onProcess` is a no-op.
@@ -51,6 +63,26 @@ const SEARCH_DEFAULT_LIMIT = 20;
51
63
  const SEARCH_MAX_LIMIT = 100;
52
64
  const SEARCH_DEFAULT_MAX_SCAN = 5000;
53
65
  const SEARCH_MAX_MAX_SCAN = 50000;
66
+ /**
67
+ * `overview` response entry cap — unlike `stats`/`extract`/`search`, which
68
+ * all clamp their result sizes, `overview` had none: an unbounded call
69
+ * against a long-lived resident with thousands of minted summaries would
70
+ * return every overlapping summary's full `content` text inlined, with one
71
+ * `getChannelTokenStats` native call per entry, synchronously, with no
72
+ * limit — exactly the resource this tool exists to conserve. Default/max
73
+ * roughly match `extract`'s own scale.
74
+ */
75
+ const OVERVIEW_DEFAULT_LIMIT = 50;
76
+ const OVERVIEW_MAX_LIMIT = 200;
77
+ /**
78
+ * A `<@id>`/`<@!id>` Discord-style mention — same shape `ChannelRegistry`
79
+ * uses for its own DM mention-form matching. A string matching this can
80
+ * never legitimately BE a raw internal channelId (unlike a bare word,
81
+ * which an agent could plausibly be echoing back), so `resolveChannel`
82
+ * treats an unresolved mention the same as an unresolved `#`/`@` label:
83
+ * a clean error, not a silent empty-result passthrough.
84
+ */
85
+ const DM_MENTION_RE = /^<@!?\d+>$/;
54
86
  /** Characters of surrounding context kept on each side of a search snippet. */
55
87
  const SNIPPET_CONTEXT_CHARS = 80;
56
88
  /** Fallback snippet length when a match position isn't meaningful to center on. */
@@ -73,15 +105,96 @@ export class HistoryModule {
73
105
  name = 'history';
74
106
  ctx = null;
75
107
  cm = null;
108
+ channelRegistry = null;
76
109
  /**
77
- * Wire the context-manager instance. Host calls this after
78
- * ContextManager.open() so the module can issue the native index-backed
79
- * queries — ModuleContext itself exposes no store/context-manager
80
- * reference, only the narrower message CRUD surface (addMessage/getMessage/
81
- * queryMessages), which can't do range or channel queries.
110
+ * Wire the context-manager instance, and optionally the host's
111
+ * ChannelRegistry. Host calls this after ContextManager.open() so the
112
+ * module can issue the native index-backed queries — ModuleContext itself
113
+ * exposes no store/context-manager reference, only the narrower message
114
+ * CRUD surface (addMessage/getMessage/queryMessages), which can't do
115
+ * range or channel queries.
116
+ *
117
+ * `channelRegistry` is optional: a host without MCPL wired up has no
118
+ * registry to pass, and `resolveChannel()` falls back to treating
119
+ * `channelId` as already the raw internal id — today's behavior,
120
+ * unchanged.
82
121
  */
83
- bind(contextManager) {
122
+ bind(contextManager, channelRegistry) {
84
123
  this.cm = contextManager;
124
+ this.channelRegistry = channelRegistry ?? null;
125
+ }
126
+ /**
127
+ * Resolve an agent-supplied channel spec — a label like `#general` or an
128
+ * already-raw internal channelId — to the internal id used by
129
+ * context-manager's channel-indexed queries, via the bound
130
+ * ChannelRegistry's durable label history (`resolveProseTargetDurable`,
131
+ * which survives the bot disconnecting from or restarting on a channel —
132
+ * exactly the case that matters for browsing OLD history).
133
+ *
134
+ * Falls through the input unchanged when no registry is bound (host
135
+ * without MCPL — today's behavior). On a registry MISS, the response
136
+ * depends on whether the spec actually looks like a label: `#general`,
137
+ * `@name`, or a `<@id>`/`<@!id>` mention (the syntaxes `resolveProseTarget`
138
+ * itself treats as unambiguously label-shaped — see its own leading
139
+ * `#`/`@` and mention-regex handling) is very likely a typo or a
140
+ * never-registered target, not a raw internal id, so a miss there throws
141
+ * a clean tool error (with any `candidates` suggestions folded in)
142
+ * instead of quietly treating it as an empty/nonexistent channel. A
143
+ * mention in particular can never legitimately BE a raw internal
144
+ * channelId, so the escape-hatch rationale below doesn't even apply to
145
+ * it. Anything else is
146
+ * passed through as an escape hatch — an agent can legitimately already
147
+ * hold a raw channelId (e.g. echoed back by a prior
148
+ * `stats`/`extract`/`search`/`overview` call), and erroring on that would
149
+ * break working addressing to "fix" a spec that was never a label at all.
150
+ *
151
+ * The thrown message is deliberately NOT `resolved.error` verbatim:
152
+ * `resolveProseTargetDurable`'s underlying live resolver
153
+ * (`resolveProseTarget`) writes its error text for a SEND context (e.g.
154
+ * a DM miss says '...use the send_dm tool') — sensible advice when
155
+ * you're trying to deliver a message, nonsense advice from a read-only
156
+ * history tool. This module always uses its own read-only-appropriate
157
+ * wording instead, regardless of which branch of the live resolver
158
+ * produced the miss.
159
+ */
160
+ resolveChannel(input) {
161
+ if (!input || !this.channelRegistry)
162
+ return input;
163
+ const resolved = this.channelRegistry.resolveProseTargetDurable(input);
164
+ if (!('error' in resolved))
165
+ return resolved.channelId;
166
+ if (input.startsWith('#') || input.startsWith('@') || DM_MENTION_RE.test(input)) {
167
+ const suggestions = resolved.candidates?.length ? ` Did you mean: ${resolved.candidates.join(', ')}?` : '';
168
+ throw new Error(`No channel history found for "${input}" — it may never have been seen by this resident, or predates this feature.${suggestions}`);
169
+ }
170
+ return input;
171
+ }
172
+ /**
173
+ * Earliest message timestamp anywhere in the store, constrained to
174
+ * `toMs` when given (never look past the caller's own upper bound while
175
+ * anchoring). Used by `handleOverview` to anchor its gap-walk cursor when
176
+ * `from` is omitted — see the cursor-anchoring comment there for why
177
+ * falling back to "now" instead is wrong. Cheap: a single native
178
+ * time-indexed query for one message (oldest-first is
179
+ * `queryMessagesByTime`'s default order). Returns undefined for a
180
+ * genuinely empty store (or empty up to `toMs`).
181
+ */
182
+ earliestMessageMs(toMs) {
183
+ const cm = this.cm;
184
+ const probe = cm.queryMessagesByTime({ toMs, limit: 1 });
185
+ return probe.messages[0]?.timestamp.getTime();
186
+ }
187
+ /**
188
+ * Every raw message sharing exactly `ms` (a single-point, both-ends-
189
+ * inclusive time query). Used by `handleOverview`'s boundary-tie
190
+ * disambiguation to see past `getChannelTokenStats`'s aggregate-only,
191
+ * sequence-blind view of a millisecond — see the correction pass there
192
+ * for why. Cheap: at most a handful of messages share one millisecond in
193
+ * practice.
194
+ */
195
+ boundaryMessagesAt(ms) {
196
+ const cm = this.cm;
197
+ return cm.queryMessagesByTime({ fromMs: ms, toMs: ms }).messages;
85
198
  }
86
199
  async start(ctx) {
87
200
  this.ctx = ctx;
@@ -103,7 +216,7 @@ export class HistoryModule {
103
216
  properties: {
104
217
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound for tokenStatsForRange. Omit for open-ended.' },
105
218
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound for tokenStatsForRange. Omit for open-ended.' },
106
- channelId: { type: 'string', description: 'Restrict both parts of the response to one channel.' },
219
+ channelId: { type: 'string', description: 'Restrict both parts of the response to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
107
220
  },
108
221
  },
109
222
  },
@@ -118,7 +231,7 @@ export class HistoryModule {
118
231
  properties: {
119
232
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
120
233
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
121
- channelId: { type: 'string', description: 'Restrict to one channel.' },
234
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
122
235
  limit: { type: 'number', description: `Max messages to return (default ${EXTRACT_DEFAULT_LIMIT}, hard cap ${EXTRACT_MAX_LIMIT}). Must be a non-negative integer.` },
123
236
  offset: { type: 'number', description: `Number of matching messages to skip (default 0, capped to ${NATIVE_OFFSET_MAX}). Must be a non-negative integer.` },
124
237
  format: { type: 'string', enum: ['text', 'raw'], description: 'Content rendering (default "text").' },
@@ -141,13 +254,56 @@ export class HistoryModule {
141
254
  caseSensitive: { type: 'boolean', description: 'Case-sensitive match (default false).' },
142
255
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
143
256
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
144
- channelId: { type: 'string', description: 'Restrict to one channel.' },
257
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
145
258
  limit: { type: 'number', description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, hard cap ${SEARCH_MAX_LIMIT}). Must be a non-negative integer.` },
146
259
  maxScan: { type: 'number', description: `Max candidate messages to scan (default ${SEARCH_DEFAULT_MAX_SCAN}, hard cap ${SEARCH_MAX_MAX_SCAN}). Must be a non-negative integer.` },
147
260
  },
148
261
  required: ['query'],
149
262
  },
150
263
  },
264
+ {
265
+ name: 'overview',
266
+ description: 'Browse a time range when you don\'t know exactly what you\'re looking for yet: returns existing ' +
267
+ 'compression summaries as a table of contents, at zero new LLM cost (purely a read over summaries ' +
268
+ 'already produced by compression). Spans not yet summarized fall back to raw message counts, ' +
269
+ 'marked summarized:false. Omit `level` to let each span show its own coarsest available summary ' +
270
+ '(spans can end up at DIFFERENT levels — the response then reports maxLevelAvailable rather than ' +
271
+ 'a single `level`, since no one number would describe a mixed result); pass an exact `level` for ' +
272
+ 'flat, single-granularity results instead (finer detail = lower level), and the response then ' +
273
+ 'echoes that `level` back. `channelId` (a label like "#general" or the raw internal id) narrows ' +
274
+ 'WHICH spans are shown (only spans with at least one message from that channel) and scopes each ' +
275
+ 'entry\'s messageCount/tokensEstimate to that channel\'s own numbers (spanMessageCount/' +
276
+ 'spanTokensEstimate carry the whole-span totals alongside, for context) — but does NOT scope a ' +
277
+ 'kept entry\'s summary TEXT to that channel: compression doesn\'t chunk per-channel, so a ' +
278
+ 'summary may describe other channels\' traffic too. A wide or unbounded from/to can be expensive ' +
279
+ 'on a cold cache — same cost class as `stats`\'s tokenStatsForRange — since gap-filling still ' +
280
+ 'walks the underlying message range wherever nothing has been summarized yet. Response spans are ' +
281
+ 'capped at `limit`, keeping the MOST RECENT spans and reporting truncated:true + totalSpans when ' +
282
+ 'more exist — narrow the range or raise `limit` to see further back. `from` after `to` is rejected ' +
283
+ 'with a clear error rather than silently returning no spans. An entry marked boundaryUncertain:true ' +
284
+ 'had a millisecond-timestamp collision at its edge with another span (rare — two distinct messages ' +
285
+ 'landing in the same millisecond); its messageCount is still exact but tokensEstimate may be off by ' +
286
+ 'a small, bounded amount.',
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {
290
+ from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
291
+ to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
292
+ channelId: { type: 'string', description: 'Only show spans with traffic from this channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
293
+ level: {
294
+ type: 'number',
295
+ description: 'Exact summary level to fetch, flat (no fold-reduction across levels). Omit to default to ' +
296
+ 'each span\'s own coarsest currently-available summary (a mix of levels across the response ' +
297
+ 'is expected and normal), reduced so a folded summary\'s now-superseded child does not also ' +
298
+ 'appear alongside a coarser entry that already covers its span.',
299
+ },
300
+ limit: {
301
+ type: 'number',
302
+ description: `Max spans to return, keeping the most recent (default ${OVERVIEW_DEFAULT_LIMIT}, hard cap ${OVERVIEW_MAX_LIMIT}). Must be a non-negative integer.`,
303
+ },
304
+ },
305
+ },
306
+ },
151
307
  ];
152
308
  }
153
309
  async handleToolCall(call) {
@@ -162,6 +318,8 @@ export class HistoryModule {
162
318
  return this.handleExtract((call.input ?? {}));
163
319
  case 'search':
164
320
  return await this.handleSearch((call.input ?? {}));
321
+ case 'overview':
322
+ return this.handleOverview((call.input ?? {}));
165
323
  default:
166
324
  return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
167
325
  }
@@ -175,9 +333,9 @@ export class HistoryModule {
175
333
  // capability-absent error ("Chronicle history index unsupported...",
176
334
  // thrown by queryMessagesByTime/queryMessagesByChannel/
177
335
  // queryMessagesByTimeAndChannel/getChannelMessageCounts/
178
- // getChannelTokenStats on a chronicle build that predates the native
179
- // index-query capability) — all surfaced as a normal tool error
180
- // rather than crashing the module.
336
+ // getChannelTokenStats/getSummariesInRange/getMaxSummaryLevel on a
337
+ // chronicle/strategy build that predates the relevant capability) —
338
+ // all surfaced as a normal tool error rather than crashing the module.
181
339
  return {
182
340
  success: false,
183
341
  isError: true,
@@ -192,13 +350,14 @@ export class HistoryModule {
192
350
  // stats
193
351
  // ==========================================================================
194
352
  handleStats(input) {
353
+ const channelId = this.resolveChannel(input.channelId);
195
354
  const fromMs = parseIsoDate(input.from, 'from');
196
355
  const toMs = parseIsoDate(input.to, 'to');
197
356
  const cm = this.cm;
198
357
  let messageCountsAllTime = cm.getChannelMessageCounts();
199
358
  let tokenStatsForRange = cm.getChannelTokenStats({ fromMs, toMs });
200
- if (input.channelId) {
201
- messageCountsAllTime = messageCountsAllTime.filter((c) => c.channelId === input.channelId);
359
+ if (channelId) {
360
+ messageCountsAllTime = messageCountsAllTime.filter((c) => c.channelId === channelId);
202
361
  tokenStatsForRange = {
203
362
  // Totals stay whole-range (they're documented as store-wide-for-the-range,
204
363
  // not per-channel); only the byChannel breakdown is narrowed. Labeling the
@@ -207,7 +366,7 @@ export class HistoryModule {
207
366
  // misleadingly-merged single-channel total.
208
367
  totalMessages: tokenStatsForRange.totalMessages,
209
368
  totalTokensEstimate: tokenStatsForRange.totalTokensEstimate,
210
- byChannel: tokenStatsForRange.byChannel.filter((c) => c.channelId === input.channelId),
369
+ byChannel: tokenStatsForRange.byChannel.filter((c) => c.channelId === channelId),
211
370
  };
212
371
  }
213
372
  return {
@@ -223,6 +382,7 @@ export class HistoryModule {
223
382
  // extract
224
383
  // ==========================================================================
225
384
  handleExtract(input) {
385
+ const channelId = this.resolveChannel(input.channelId);
226
386
  const fromMs = parseIsoDate(input.from, 'from');
227
387
  const toMs = parseIsoDate(input.to, 'to');
228
388
  const limit = clampCount(input.limit, EXTRACT_DEFAULT_LIMIT, EXTRACT_MAX_LIMIT, 'limit');
@@ -232,7 +392,7 @@ export class HistoryModule {
232
392
  const result = cm.queryMessagesByTimeAndChannel({
233
393
  fromMs,
234
394
  toMs,
235
- channelId: input.channelId,
395
+ channelId,
236
396
  limit,
237
397
  offset,
238
398
  });
@@ -252,6 +412,7 @@ export class HistoryModule {
252
412
  if (!input.query) {
253
413
  throw new Error('search requires a non-empty "query".');
254
414
  }
415
+ const channelId = this.resolveChannel(input.channelId);
255
416
  const fromMs = parseIsoDate(input.from, 'from');
256
417
  const toMs = parseIsoDate(input.to, 'to');
257
418
  const limit = clampCount(input.limit, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, 'limit');
@@ -280,7 +441,7 @@ export class HistoryModule {
280
441
  const probe = cm.queryMessagesByTimeAndChannel({
281
442
  fromMs,
282
443
  toMs,
283
- channelId: input.channelId,
444
+ channelId,
284
445
  limit: maxScan + 1,
285
446
  offset: 0,
286
447
  });
@@ -391,6 +552,369 @@ export class HistoryModule {
391
552
  void worker.terminate().catch(() => { });
392
553
  }
393
554
  }
555
+ // ==========================================================================
556
+ // overview
557
+ // ==========================================================================
558
+ handleOverview(input) {
559
+ const channelId = this.resolveChannel(input.channelId);
560
+ const fromMs = parseIsoDate(input.from, 'from');
561
+ const toMs = parseIsoDate(input.to, 'to');
562
+ assertOrderedRange(fromMs, toMs, input.from, input.to);
563
+ const limit = clampCount(input.limit, OVERVIEW_DEFAULT_LIMIT, OVERVIEW_MAX_LIMIT, 'limit');
564
+ const cm = this.cm;
565
+ let entries;
566
+ if (input.level !== undefined) {
567
+ // Caller asked for one exact level: flat, no fold-reduction — they
568
+ // want that granularity, not the coarsest-available mix below.
569
+ entries = cm.getSummariesInRange({ fromMs, toMs, level: input.level });
570
+ }
571
+ else {
572
+ // No level given: fetch every level overlapping the range, then
573
+ // reduce to only "current" (non-superseded) entries. getSummariesInRange
574
+ // does NOT filter by fold status — a folded child and the coarser
575
+ // parent it was folded into can BOTH independently overlap the query
576
+ // range and BOTH come back in `all` (see TimeRangeSummaryEntry.parentId's
577
+ // doc comment in context-manager).
578
+ //
579
+ // Two complementary checks, because an ancestor CHAIN can be broken:
580
+ // `listSummariesInRange` silently skips an entry whose source range no
581
+ // longer resolves (a pruned/redacted source message, or a viewFilter
582
+ // exclusion — a real, named failure mode elsewhere in
583
+ // autobiographical.ts's contiguousMergeCandidates). If that skipped
584
+ // entry is an INTERMEDIATE level, its children's parentId points at an
585
+ // id that never made it into `all`, so the immediate-parent check
586
+ // alone can't see past the gap and a since-superseded child survives
587
+ // alongside the coarser grandparent that already covers its span.
588
+ // 1. Immediate-parent check (cheap, covers the intact-chain case).
589
+ // 2. Containment sweep (backstop, robust to a broken chain): drop an
590
+ // entry if any OTHER fetched entry is STRICTLY coarser (higher
591
+ // level) and its span fully contains this one — that entry
592
+ // already covers the same messages regardless of whether the
593
+ // parentId pointer chain between them survived intact.
594
+ // EXCEPTION: a zero-width entry (e.startMs === e.endMs — a tiny
595
+ // chunk that landed entirely within one millisecond) that merely
596
+ // TOUCHES a coarser entry's boundary at exactly that one point
597
+ // trivially satisfies "f.startMs <= e.startMs && f.endMs >=
598
+ // e.endMs" without f actually being an ancestor — a genuinely
599
+ // unrelated, later-starting/earlier-ending coarser summary can
600
+ // coincidentally share one endpoint with it. A real (non-zero-
601
+ // width) entry can't trigger this false positive: satisfying
602
+ // containment on BOTH ends while only touching at one means f
603
+ // must still genuinely extend past the entry's other, non-touching
604
+ // side. A genuine same-point fold is still caught by the
605
+ // immediate-parentId check above when the chain is intact.
606
+ // O(n^2) in the fetched set size, which is fine at summary-archive
607
+ // scale (bounded by how much has been compressed into this range, not
608
+ // by raw message count).
609
+ const all = cm.getSummariesInRange({ fromMs, toMs });
610
+ const idsInSet = new Set(all.map((e) => e.id));
611
+ entries = all.filter((e) => {
612
+ if (e.parentId && idsInSet.has(e.parentId))
613
+ return false;
614
+ return !all.some((f) => {
615
+ if (f.id === e.id || f.level <= e.level)
616
+ return false;
617
+ if (!(f.startMs <= e.startMs && f.endMs >= e.endMs))
618
+ return false;
619
+ if (e.startMs === e.endMs && (e.startMs === f.startMs || e.endMs === f.endMs))
620
+ return false;
621
+ return true;
622
+ });
623
+ });
624
+ }
625
+ entries.sort((a, b) => a.startMs - b.startMs);
626
+ // Per-summary channel/token stats — same call `stats` uses, scoped to
627
+ // just this entry's own source span.
628
+ const summarized = entries.map((entry) => ({
629
+ startMs: entry.startMs,
630
+ endMs: entry.endMs,
631
+ summarized: true,
632
+ content: entry.content,
633
+ level: entry.level,
634
+ stats: cm.getChannelTokenStats({ fromMs: entry.startMs, toMs: entry.endMs }),
635
+ }));
636
+ // Gap-fill: walk the sorted summary spans and probe seams wide enough
637
+ // to be worth a getChannelTokenStats call. A gap with real traffic
638
+ // becomes a synthetic summarized:false entry; an empty gap (nothing
639
+ // happened there) is skipped rather than cluttering the response.
640
+ //
641
+ // Cursor anchoring when `from` is omitted: NOT "now" — with zero
642
+ // summaries minted yet (first-ever call, or a PassthroughStrategy host
643
+ // where getSummariesInRange always returns []) that would make the
644
+ // whole walk a zero-width no-op and silently report entries:[] even
645
+ // though the store is full of messages. And even WITH summaries
646
+ // present, starting at the first summary's startMs silently hides any
647
+ // older unsummarized traffic that predates every summary (e.g. history
648
+ // from before compression was enabled). Anchor instead at the earlier
649
+ // of "the first summary's start" and "the earliest real message in the
650
+ // store" — falling back to Date.now() only when NEITHER exists, i.e.
651
+ // the store is genuinely empty, where entries:[] is the correct answer.
652
+ const rangeEnd = toMs ?? Date.now();
653
+ const firstEntryStartMs = entries[0]?.startMs;
654
+ let cursor;
655
+ if (fromMs !== undefined) {
656
+ cursor = fromMs;
657
+ }
658
+ else {
659
+ const earliestMessageMs = this.earliestMessageMs(toMs);
660
+ cursor =
661
+ earliestMessageMs !== undefined && firstEntryStartMs !== undefined
662
+ ? Math.min(earliestMessageMs, firstEntryStartMs)
663
+ : earliestMessageMs ?? firstEntryStartMs ?? Date.now();
664
+ }
665
+ // Effective query start — captured BEFORE the gap loop below mutates
666
+ // `cursor` (it walks forward past each entry's endMs). Needed by the
667
+ // boundary-tie correction pass further down to clamp its output to
668
+ // what the caller actually asked for (see the comment there): when
669
+ // `fromMs` is omitted this equals the resolved anchor above, not
670
+ // `-Infinity` — an open-ended query still has a concrete effective
671
+ // start, it's just not caller-supplied.
672
+ const rangeStart = cursor;
673
+ const gaps = [];
674
+ // True once `cursor` has been advanced past at least one real summary
675
+ // entry's endMs — only THEN is `cursor` itself a value that a summary
676
+ // already claims (and so needs a +1 nudge below to probe half-open).
677
+ // The very first gap (before any entry) sits against `fromMs` or an
678
+ // earliest-message anchor, neither of which any summary has claimed,
679
+ // so it must NOT be nudged — nudging it would skip a real message
680
+ // sitting exactly at the anchor.
681
+ let cursorIsEntryBoundary = false;
682
+ for (const entry of entries) {
683
+ // getChannelTokenStats's range is BOTH-ENDS INCLUSIVE (chronicle's
684
+ // gte/lte), so probing the raw [cursor, entry.startMs] pair would
685
+ // re-count the neighboring summaries' own boundary messages (ts ===
686
+ // cursor from the entry before, ts === entry.startMs from this one)
687
+ // as if they were unsummarized — fabricating a phantom gap between
688
+ // EVERY pair of adjacent summaries, even when they cover 100% of
689
+ // traffic with zero real gap between them. Narrow to a half-open
690
+ // probe instead, and only skip the call when narrowing collapsed the
691
+ // width to zero or negative (`probeTo < probeFrom` — an entry that
692
+ // starts exactly where the previous one ended, or crosses it).
693
+ //
694
+ // Deliberately NO minimum-width threshold beyond that: an earlier
695
+ // version skipped any seam narrower than a fixed few seconds as
696
+ // "bookkeeping noise", but that check ran BEFORE the
697
+ // stats.totalMessages > 0 check below that actually decides whether
698
+ // real traffic exists — so a query window or a between-summaries
699
+ // seam narrower than the threshold silently reported entries:[] even
700
+ // when full of real messages (verified repro: 300 real messages,
701
+ // zero summaries, a 4-second window). The totalMessages > 0 check
702
+ // that follows is the one that's actually correct; nothing should
703
+ // gate the probe ahead of it.
704
+ const probeFrom = cursorIsEntryBoundary ? cursor + 1 : cursor;
705
+ const probeTo = entry.startMs - 1;
706
+ if (probeTo >= probeFrom) {
707
+ const stats = cm.getChannelTokenStats({ fromMs: probeFrom, toMs: probeTo });
708
+ if (stats.totalMessages > 0)
709
+ gaps.push({ startMs: probeFrom, endMs: probeTo, summarized: false, stats });
710
+ }
711
+ cursor = Math.max(cursor, entry.endMs);
712
+ cursorIsEntryBoundary = true;
713
+ }
714
+ {
715
+ // Trailing gap: rangeEnd is either the caller's own inclusive `to`
716
+ // bound or Date.now() — never a summary boundary — so only the LOWER
717
+ // end gets the half-open nudge (same reasoning as inside the loop).
718
+ // Same no-threshold reasoning as above.
719
+ const probeFrom = cursorIsEntryBoundary ? cursor + 1 : cursor;
720
+ const probeTo = rangeEnd;
721
+ if (probeTo >= probeFrom) {
722
+ const stats = cm.getChannelTokenStats({ fromMs: probeFrom, toMs: probeTo });
723
+ if (stats.totalMessages > 0)
724
+ gaps.push({ startMs: probeFrom, endMs: probeTo, summarized: false, stats });
725
+ }
726
+ }
727
+ // Boundary-tie disambiguation: getChannelTokenStats operates on
728
+ // wall-clock TIME ranges and has no visibility into chronicle's
729
+ // sequence numbers, so whenever a message that's genuinely NOT part of
730
+ // a summary's source span happens to share the EXACT millisecond with
731
+ // that summary's own first/last covered message (rapid-fire appends —
732
+ // wall-clock ms resolution isn't unique, sequence is), the per-span
733
+ // aggregate calls above silently fold it into the summary's counts —
734
+ // and the adjacent gap probe (which deliberately EXCLUDES that same ms
735
+ // via the half-open nudge, on the assumption the boundary ms belongs
736
+ // entirely to the neighboring summary) has no way to see it at all: it
737
+ // doesn't just get miscounted, it vanishes from the response entirely.
738
+ //
739
+ // Resolved using TimeRangeSummaryEntry's firstSequence/lastSequence
740
+ // (chronicle's per-record sequence number — strictly monotonic, never
741
+ // ties, unlike wall-clock ms) against the ACTUAL messages at each
742
+ // boundary ms (queried directly via boundaryMessagesAt — cheap, at
743
+ // most a handful of messages share one millisecond in practice).
744
+ //
745
+ // A "foreign" message found this way either belongs to ANOTHER fetched
746
+ // summary entry (its sequence falls in THAT entry's own range — its
747
+ // own inclusive-range getChannelTokenStats call already counts it
748
+ // correctly, so this entry just needs the erroneous count subtracted,
749
+ // nothing added elsewhere) or is genuinely unsummarized (belongs to the
750
+ // adjacent gap, extending it — or creating a new point-width gap
751
+ // fragment if no gap was probed there at all, which is exactly the
752
+ // "vanishes entirely" failure mode this fix exists for).
753
+ //
754
+ // MESSAGE COUNT/membership is corrected exactly — we know precisely
755
+ // which raw messages are foreign and their channelId. TOKEN weight for
756
+ // those specific messages is deliberately NOT recomputed:
757
+ // getChannelTokenStats can only report tokens for a TIME range, never
758
+ // for an arbitrary message subset, and reimplementing token estimation
759
+ // locally would silently drift from context-manager's own (mutable,
760
+ // calibration-adjusted) estimate — worse than admitting the gap. Any
761
+ // entry this pass touches is marked `boundaryUncertain: true` so a
762
+ // caller knows its tokensEstimate may be off by the small, bounded
763
+ // weight of the message(s) that moved, even though messageCount there
764
+ // is exact.
765
+ // Messages already routed into a gap by an EARLIER entry's pass in this
766
+ // same loop — without this, two entries that both touch the same
767
+ // boundary millisecond (e.g. summary A ends and summary B starts at the
768
+ // exact same ms, with one genuinely-unsummarized stray message also at
769
+ // that ms) each independently decide "not mine, not any other fetched
770
+ // SUMMARY's either" and BOTH call mergeForeignIntoGap for the same
771
+ // stray — mergeForeignIntoGap's own gap-lookup only checks for a gap
772
+ // immediately adjacent to ITS OWN entry's boundary, so it can't see the
773
+ // point-width gap the other entry's pass just created one step away,
774
+ // and the same message gets double-counted into two separate gap
775
+ // entries. Tracking ids already merged (not just already-seen) across
776
+ // the whole loop — not just within one entry's own pass — closes this.
777
+ const alreadyMergedForeignIds = new Set();
778
+ for (let i = 0; i < entries.length; i++) {
779
+ const entry = entries[i];
780
+ const own = summarized[i];
781
+ // Defensive: firstSequence/lastSequence are new fields on
782
+ // TimeRangeSummaryEntry (added alongside this correction pass) — if
783
+ // this module ever ends up running against an OLDER published
784
+ // context-manager whose getSummariesInRange doesn't populate them
785
+ // (undefined, not a type error at runtime), every comparison below
786
+ // would silently evaluate false, foreignById would always be empty,
787
+ // and this whole pass would silently no-op — resurrecting the exact
788
+ // boundary-tie bug it exists to fix, with no error and no
789
+ // boundaryUncertain signal. Skip explicitly instead of trusting the
790
+ // type system alone.
791
+ if (typeof entry.firstSequence !== 'number' || typeof entry.lastSequence !== 'number')
792
+ continue;
793
+ const boundaryMsList = entry.startMs === entry.endMs ? [entry.startMs] : [entry.startMs, entry.endMs];
794
+ const foreignById = new Map();
795
+ for (const ms of boundaryMsList) {
796
+ for (const m of this.boundaryMessagesAt(ms)) {
797
+ if (m.sequence < entry.firstSequence || m.sequence > entry.lastSequence) {
798
+ foreignById.set(String(m.id), m);
799
+ }
800
+ }
801
+ }
802
+ if (foreignById.size === 0)
803
+ continue;
804
+ const genuinelyUnsummarized = [];
805
+ for (const m of foreignById.values()) {
806
+ const owner = entries.find((e) => e.id !== entry.id &&
807
+ typeof e.firstSequence === 'number' &&
808
+ typeof e.lastSequence === 'number' &&
809
+ m.sequence >= e.firstSequence &&
810
+ m.sequence <= e.lastSequence);
811
+ if (!owner && !alreadyMergedForeignIds.has(String(m.id)))
812
+ genuinelyUnsummarized.push(m);
813
+ }
814
+ own.stats = subtractMessagesFromStats(own.stats, [...foreignById.values()]);
815
+ own.boundaryUncertain = true;
816
+ // getSummariesInRange intentionally returns WHOLE summaries that
817
+ // overlap the query — a summary's own startMs/endMs can legitimately
818
+ // extend past the caller's requested [rangeStart, rangeEnd] (that's
819
+ // correct: the summary itself, and its corrected messageCount above,
820
+ // belong in the response either way). But a correction-pass GAP
821
+ // anchored at entry.startMs/entry.endMs must NOT be added when that
822
+ // boundary itself falls outside the query window — the ordinary gap
823
+ // walk elsewhere in this function never synthesizes a gap outside
824
+ // [rangeStart, rangeEnd], and this pass shouldn't either. Without
825
+ // this check, a narrow-window query landing entirely inside an
826
+ // already-summarized span could still surface phantom
827
+ // summarized:false rows anchored at that summary's FAR boundaries,
828
+ // outside anything the caller asked about.
829
+ const before = genuinelyUnsummarized.filter((m) => m.sequence < entry.firstSequence);
830
+ const after = genuinelyUnsummarized.filter((m) => m.sequence > entry.lastSequence);
831
+ const startInRange = entry.startMs >= rangeStart && entry.startMs <= rangeEnd;
832
+ const endInRange = entry.endMs >= rangeStart && entry.endMs <= rangeEnd;
833
+ if (before.length > 0 && startInRange) {
834
+ mergeForeignIntoGap(gaps, entry.startMs, 'before', before);
835
+ for (const m of before)
836
+ alreadyMergedForeignIds.add(String(m.id));
837
+ }
838
+ if (after.length > 0 && endInRange) {
839
+ mergeForeignIntoGap(gaps, entry.endMs, 'after', after);
840
+ for (const m of after)
841
+ alreadyMergedForeignIds.add(String(m.id));
842
+ }
843
+ }
844
+ // Merge summary-backed + gap-filled spans, apply the channel filter
845
+ // (drop any span with zero messages from the requested channel — but
846
+ // keep a kept summary's `content` unfiltered: compression doesn't chunk
847
+ // per-channel, so the text may legitimately describe other channels
848
+ // too), sort, and project to the response shape.
849
+ const merged = [...summarized, ...gaps].sort((a, b) => a.startMs - b.startMs);
850
+ const filtered = channelId
851
+ ? merged.filter((e) => e.stats.byChannel.some((c) => c.channelId === channelId))
852
+ : merged;
853
+ // Cap the final entry list — mirrors search's truncated:true contract.
854
+ // Keeps the MOST RECENT `limit` spans when the cap is hit (a browse
855
+ // tool defaulting to "what's happened lately" should keep the tail, not
856
+ // silently cut off at the oldest end) while the survivors stay in their
857
+ // own chronological (oldest-first) order within the response, matching
858
+ // extract's/search's own ordering convention.
859
+ const totalSpans = filtered.length;
860
+ const truncated = totalSpans > limit;
861
+ // NOT `filtered.slice(-limit)`: `slice(-0)` is `slice(+0)` (negative
862
+ // zero coerces away), so `limit: 0` — a value clampCount explicitly
863
+ // accepts — would slice from index 0 and return the ENTIRE array while
864
+ // still reporting truncated:true. Same bug class already fixed twice
865
+ // elsewhere in this project (search's/extract's own limit:0 bugs);
866
+ // `filtered.length - limit` has no such cliff, at limit:0 it's
867
+ // `slice(filtered.length)` -> `[]`, the honest answer.
868
+ const capped = truncated ? filtered.slice(filtered.length - limit) : filtered;
869
+ return {
870
+ success: true,
871
+ data: {
872
+ range: { from: input.from ?? null, to: input.to ?? null },
873
+ // Only meaningful as a single number when `level` was pinned: the
874
+ // default (fold-reduced) result mixes spans at whatever level each
875
+ // one's own coarsest-available summary happens to be, so reporting
876
+ // a single `level` there would be actively misleading (an agent
877
+ // could see level:3 while looking at an L1 entry). Each summarized
878
+ // entry already carries its own accurate `level`; the top-level
879
+ // field instead reports the ceiling as `maxLevelAvailable` in that
880
+ // case, distinctly named so it can't be mistaken for a uniform
881
+ // per-entry level.
882
+ ...(input.level !== undefined ? { level: input.level } : { maxLevelAvailable: cm.getMaxSummaryLevel() }),
883
+ truncated,
884
+ totalSpans,
885
+ entries: capped.map((e) => {
886
+ // When channelId narrows the response, the numbers an agent will
887
+ // actually reason over (messageCount/tokensEstimate) are scoped
888
+ // to THAT channel — not the whole span, which can otherwise
889
+ // include far more traffic from other channels sharing the same
890
+ // summarized/gap span. This is a deliberate departure from
891
+ // `stats`, which keeps its top-level totals whole-range and only
892
+ // narrows the byChannel breakdown (documented in handleStats) —
893
+ // here, an agent that asked to narrow by channel is reasoning
894
+ // about that channel specifically, so the whole-span totals stay
895
+ // available under distinctly-named spanMessageCount/
896
+ // spanTokensEstimate instead of silently double-serving as both.
897
+ const channelStats = channelId ? e.stats.byChannel.find((c) => c.channelId === channelId) : undefined;
898
+ return {
899
+ from: new Date(e.startMs).toISOString(),
900
+ to: new Date(e.endMs).toISOString(),
901
+ summarized: e.summarized,
902
+ ...(e.summarized ? { content: e.content, level: e.level } : {}),
903
+ // Set only when this entry's boundary-tie disambiguation (see
904
+ // the correction pass above) found and corrected a
905
+ // millisecond-timestamp collision at its edge. messageCount is
906
+ // still exact there; tokensEstimate may be off by the small,
907
+ // bounded weight of the message(s) that moved.
908
+ ...(e.boundaryUncertain ? { boundaryUncertain: true } : {}),
909
+ messageCount: channelStats ? channelStats.messages : e.stats.totalMessages,
910
+ tokensEstimate: channelStats ? channelStats.tokensEstimate : e.stats.totalTokensEstimate,
911
+ ...(channelId ? { spanMessageCount: e.stats.totalMessages, spanTokensEstimate: e.stats.totalTokensEstimate } : {}),
912
+ byChannel: e.stats.byChannel,
913
+ };
914
+ }),
915
+ },
916
+ };
917
+ }
394
918
  }
395
919
  // ============================================================================
396
920
  // Helpers
@@ -407,6 +931,18 @@ function parseIsoDate(value, field) {
407
931
  }
408
932
  return ms;
409
933
  }
934
+ /**
935
+ * Reject an inverted range (`from` after `to`) with a clear error instead of
936
+ * silently matching nothing. Used by `overview`, where "found nothing" is
937
+ * reported as `entries: []` — the same shape a caller sees for a genuinely
938
+ * quiet span — so a simple from/to mixup should never be allowed to look
939
+ * like "you have no history here" instead of the input mistake it is.
940
+ */
941
+ function assertOrderedRange(fromMs, toMs, fromRaw, toRaw) {
942
+ if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) {
943
+ throw new Error(`"from" (${fromRaw}) must not be after "to" (${toRaw}).`);
944
+ }
945
+ }
410
946
  /**
411
947
  * Validate a pagination-ish numeric input (limit/offset/maxScan) and clamp
412
948
  * it to an upper bound. `Math.min(value, max)` alone is NOT sufficient here:
@@ -453,6 +989,101 @@ function getChannelId(msg) {
453
989
  return metadata.channelId;
454
990
  return typeof metadata?.external?.channelId === 'string' ? metadata.external.channelId : undefined;
455
991
  }
992
+ // ============================================================================
993
+ // overview: boundary-tie correction helpers (see handleOverview)
994
+ // ============================================================================
995
+ /**
996
+ * Subtract the exact per-channel MESSAGE COUNT of `foreign` from `stats` —
997
+ * used to correct a summary entry's own stats once boundary-tie
998
+ * disambiguation finds messages counted in error (see handleOverview's
999
+ * correction pass). Deliberately leaves totalTokensEstimate/
1000
+ * byChannel[].tokensEstimate untouched — see that pass's header comment
1001
+ * for why an exact token subtraction isn't achievable from the available
1002
+ * API.
1003
+ */
1004
+ function subtractMessagesFromStats(stats, foreign) {
1005
+ if (foreign.length === 0)
1006
+ return stats;
1007
+ const countByChannel = new Map();
1008
+ for (const m of foreign) {
1009
+ const ch = getChannelId(m);
1010
+ if (ch === undefined)
1011
+ continue; // unchanneled messages aren't tracked in byChannel at all
1012
+ countByChannel.set(ch, (countByChannel.get(ch) ?? 0) + 1);
1013
+ }
1014
+ const byChannel = stats.byChannel
1015
+ .map((c) => ({ ...c, messages: Math.max(0, c.messages - (countByChannel.get(c.channelId) ?? 0)) }))
1016
+ .filter((c) => c.messages > 0);
1017
+ return {
1018
+ totalMessages: Math.max(0, stats.totalMessages - foreign.length),
1019
+ totalTokensEstimate: stats.totalTokensEstimate,
1020
+ byChannel,
1021
+ };
1022
+ }
1023
+ /**
1024
+ * Exact per-channel MESSAGE COUNT for a small raw message list, with
1025
+ * tokensEstimate left at 0 (unknown — see handleOverview's boundary-tie
1026
+ * correction pass). Only ever used to build a BRAND NEW synthetic gap
1027
+ * fragment out of boundary-tie foreign messages — a real query result
1028
+ * always goes through the real getChannelTokenStats aggregate instead.
1029
+ */
1030
+ function statsFromMessages(messages) {
1031
+ const byChannel = new Map();
1032
+ for (const m of messages) {
1033
+ const ch = getChannelId(m);
1034
+ if (ch === undefined)
1035
+ continue;
1036
+ byChannel.set(ch, (byChannel.get(ch) ?? 0) + 1);
1037
+ }
1038
+ return {
1039
+ totalMessages: messages.length,
1040
+ totalTokensEstimate: 0,
1041
+ byChannel: [...byChannel.entries()].map(([channelId, count]) => ({ channelId, messages: count, tokensEstimate: 0 })),
1042
+ };
1043
+ }
1044
+ /** Add `b`'s counts into `a` — used to fold boundary-tie foreign messages
1045
+ * into an already-probed gap's stats. */
1046
+ function mergeStats(a, b) {
1047
+ const byChannel = new Map();
1048
+ for (const c of a.byChannel)
1049
+ byChannel.set(c.channelId, { messages: c.messages, tokensEstimate: c.tokensEstimate });
1050
+ for (const c of b.byChannel) {
1051
+ const cur = byChannel.get(c.channelId) ?? { messages: 0, tokensEstimate: 0 };
1052
+ cur.messages += c.messages;
1053
+ cur.tokensEstimate += c.tokensEstimate;
1054
+ byChannel.set(c.channelId, cur);
1055
+ }
1056
+ return {
1057
+ totalMessages: a.totalMessages + b.totalMessages,
1058
+ totalTokensEstimate: a.totalTokensEstimate + b.totalTokensEstimate,
1059
+ byChannel: [...byChannel.entries()].map(([channelId, v]) => ({ channelId, ...v })),
1060
+ };
1061
+ }
1062
+ /**
1063
+ * Fold boundary-tie `foreign` messages into whichever gap sits immediately
1064
+ * `side` of `boundaryMs`, extending that gap's span to include the tied
1065
+ * millisecond — or, if no such gap currently exists (the common case when
1066
+ * the seam had zero OTHER real traffic and so was never probed into
1067
+ * existence), create a new point-width gap fragment exactly at
1068
+ * `boundaryMs` to hold them. Always marks the result `boundaryUncertain:
1069
+ * true` (see handleOverview's boundary-tie correction pass for what that
1070
+ * means).
1071
+ */
1072
+ function mergeForeignIntoGap(gaps, boundaryMs, side, foreign) {
1073
+ const addend = statsFromMessages(foreign);
1074
+ const existing = side === 'before' ? gaps.find((g) => g.endMs === boundaryMs - 1) : gaps.find((g) => g.startMs === boundaryMs + 1);
1075
+ if (existing) {
1076
+ existing.stats = mergeStats(existing.stats, addend);
1077
+ if (side === 'before')
1078
+ existing.endMs = boundaryMs;
1079
+ else
1080
+ existing.startMs = boundaryMs;
1081
+ existing.boundaryUncertain = true;
1082
+ }
1083
+ else {
1084
+ gaps.push({ startMs: boundaryMs, endMs: boundaryMs, summarized: false, stats: addend, boundaryUncertain: true });
1085
+ }
1086
+ }
456
1087
  function projectMessage(msg, format) {
457
1088
  return {
458
1089
  id: String(msg.id),