@animalabs/agent-framework 0.15.0 → 0.16.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 (96) 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 +418 -3
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1782 -68
  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 +24 -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/operator-surgery.test.d.ts +2 -0
  77. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  78. package/dist/test/operator-surgery.test.js +398 -0
  79. package/dist/test/operator-surgery.test.js.map +1 -0
  80. package/dist/test/puppet-tool-call.test.js +5 -0
  81. package/dist/test/puppet-tool-call.test.js.map +1 -1
  82. package/dist/tsconfig.tsbuildinfo +1 -1
  83. package/package.json +2 -2
  84. package/src/agent.ts +69 -0
  85. package/src/api/server.ts +159 -2
  86. package/src/api/types.ts +15 -0
  87. package/src/framework.ts +2081 -89
  88. package/src/gate/event-gate.ts +47 -4
  89. package/src/index.ts +5 -1
  90. package/src/mcpl/channel-registry.ts +495 -2
  91. package/src/mcpl/server-connection.ts +73 -13
  92. package/src/modules/history/index.ts +689 -25
  93. package/src/operator-log.ts +132 -0
  94. package/src/recovery/discord-awareness-outbox.ts +19 -0
  95. package/src/types/framework.ts +27 -0
  96. 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.
@@ -30,12 +42,13 @@
30
42
  import { Worker } from 'node:worker_threads';
31
43
  import { fileURLToPath } from 'node:url';
32
44
  import { dirname, join } from 'node:path';
33
- import type { ContextManager, StoredMessage, ChannelCount, ChannelTokenStats } from '@animalabs/context-manager';
45
+ import type { ContextManager, StoredMessage, ChannelCount, ChannelTokenStats, TimeRangeSummaryEntry } from '@animalabs/context-manager';
34
46
  import type { ContentBlock } from '@animalabs/membrane';
35
47
  import type { Module, ModuleContext } from '../../types/module.js';
36
48
  import type { ToolDefinition, ToolCall, ToolResult, ProcessEvent } from '../../types/events.js';
37
49
  import type { EventResponse, ProcessState } from '../../types/module.js';
38
50
  import type { SearchWorkerMessage, SearchWorkerMatch } from './search-regex-worker.js';
51
+ import type { ChannelRegistry } from '../../mcpl/channel-registry.js';
39
52
 
40
53
  // ============================================================================
41
54
  // Tool input shapes
@@ -67,6 +80,14 @@ interface SearchInput {
67
80
  maxScan?: number;
68
81
  }
69
82
 
83
+ interface OverviewInput {
84
+ from?: string;
85
+ to?: string;
86
+ channelId?: string;
87
+ level?: number;
88
+ limit?: number;
89
+ }
90
+
70
91
  // ============================================================================
71
92
  // Limits
72
93
  // ============================================================================
@@ -93,6 +114,28 @@ const SEARCH_MAX_LIMIT = 100;
93
114
  const SEARCH_DEFAULT_MAX_SCAN = 5000;
94
115
  const SEARCH_MAX_MAX_SCAN = 50000;
95
116
 
117
+ /**
118
+ * `overview` response entry cap — unlike `stats`/`extract`/`search`, which
119
+ * all clamp their result sizes, `overview` had none: an unbounded call
120
+ * against a long-lived resident with thousands of minted summaries would
121
+ * return every overlapping summary's full `content` text inlined, with one
122
+ * `getChannelTokenStats` native call per entry, synchronously, with no
123
+ * limit — exactly the resource this tool exists to conserve. Default/max
124
+ * roughly match `extract`'s own scale.
125
+ */
126
+ const OVERVIEW_DEFAULT_LIMIT = 50;
127
+ const OVERVIEW_MAX_LIMIT = 200;
128
+
129
+ /**
130
+ * A `<@id>`/`<@!id>` Discord-style mention — same shape `ChannelRegistry`
131
+ * uses for its own DM mention-form matching. A string matching this can
132
+ * never legitimately BE a raw internal channelId (unlike a bare word,
133
+ * which an agent could plausibly be echoing back), so `resolveChannel`
134
+ * treats an unresolved mention the same as an unresolved `#`/`@` label:
135
+ * a clean error, not a silent empty-result passthrough.
136
+ */
137
+ const DM_MENTION_RE = /^<@!?\d+>$/;
138
+
96
139
  /** Characters of surrounding context kept on each side of a search snippet. */
97
140
  const SNIPPET_CONTEXT_CHARS = 80;
98
141
  /** Fallback snippet length when a match position isn't meaningful to center on. */
@@ -119,16 +162,100 @@ export class HistoryModule implements Module {
119
162
 
120
163
  private ctx: ModuleContext | null = null;
121
164
  private cm: ContextManager | null = null;
165
+ private channelRegistry: ChannelRegistry | null = null;
122
166
 
123
167
  /**
124
- * Wire the context-manager instance. Host calls this after
125
- * ContextManager.open() so the module can issue the native index-backed
126
- * queries — ModuleContext itself exposes no store/context-manager
127
- * reference, only the narrower message CRUD surface (addMessage/getMessage/
128
- * queryMessages), which can't do range or channel queries.
168
+ * Wire the context-manager instance, and optionally the host's
169
+ * ChannelRegistry. Host calls this after ContextManager.open() so the
170
+ * module can issue the native index-backed queries — ModuleContext itself
171
+ * exposes no store/context-manager reference, only the narrower message
172
+ * CRUD surface (addMessage/getMessage/queryMessages), which can't do
173
+ * range or channel queries.
174
+ *
175
+ * `channelRegistry` is optional: a host without MCPL wired up has no
176
+ * registry to pass, and `resolveChannel()` falls back to treating
177
+ * `channelId` as already the raw internal id — today's behavior,
178
+ * unchanged.
129
179
  */
130
- bind(contextManager: ContextManager): void {
180
+ bind(contextManager: ContextManager, channelRegistry?: ChannelRegistry): void {
131
181
  this.cm = contextManager;
182
+ this.channelRegistry = channelRegistry ?? null;
183
+ }
184
+
185
+ /**
186
+ * Resolve an agent-supplied channel spec — a label like `#general` or an
187
+ * already-raw internal channelId — to the internal id used by
188
+ * context-manager's channel-indexed queries, via the bound
189
+ * ChannelRegistry's durable label history (`resolveProseTargetDurable`,
190
+ * which survives the bot disconnecting from or restarting on a channel —
191
+ * exactly the case that matters for browsing OLD history).
192
+ *
193
+ * Falls through the input unchanged when no registry is bound (host
194
+ * without MCPL — today's behavior). On a registry MISS, the response
195
+ * depends on whether the spec actually looks like a label: `#general`,
196
+ * `@name`, or a `<@id>`/`<@!id>` mention (the syntaxes `resolveProseTarget`
197
+ * itself treats as unambiguously label-shaped — see its own leading
198
+ * `#`/`@` and mention-regex handling) is very likely a typo or a
199
+ * never-registered target, not a raw internal id, so a miss there throws
200
+ * a clean tool error (with any `candidates` suggestions folded in)
201
+ * instead of quietly treating it as an empty/nonexistent channel. A
202
+ * mention in particular can never legitimately BE a raw internal
203
+ * channelId, so the escape-hatch rationale below doesn't even apply to
204
+ * it. Anything else is
205
+ * passed through as an escape hatch — an agent can legitimately already
206
+ * hold a raw channelId (e.g. echoed back by a prior
207
+ * `stats`/`extract`/`search`/`overview` call), and erroring on that would
208
+ * break working addressing to "fix" a spec that was never a label at all.
209
+ *
210
+ * The thrown message is deliberately NOT `resolved.error` verbatim:
211
+ * `resolveProseTargetDurable`'s underlying live resolver
212
+ * (`resolveProseTarget`) writes its error text for a SEND context (e.g.
213
+ * a DM miss says '...use the send_dm tool') — sensible advice when
214
+ * you're trying to deliver a message, nonsense advice from a read-only
215
+ * history tool. This module always uses its own read-only-appropriate
216
+ * wording instead, regardless of which branch of the live resolver
217
+ * produced the miss.
218
+ */
219
+ private resolveChannel(input: string | undefined): string | undefined {
220
+ if (!input || !this.channelRegistry) return input;
221
+ const resolved = this.channelRegistry.resolveProseTargetDurable(input);
222
+ if (!('error' in resolved)) return resolved.channelId;
223
+ if (input.startsWith('#') || input.startsWith('@') || DM_MENTION_RE.test(input)) {
224
+ const suggestions = resolved.candidates?.length ? ` Did you mean: ${resolved.candidates.join(', ')}?` : '';
225
+ throw new Error(
226
+ `No channel history found for "${input}" — it may never have been seen by this resident, or predates this feature.${suggestions}`,
227
+ );
228
+ }
229
+ return input;
230
+ }
231
+
232
+ /**
233
+ * Earliest message timestamp anywhere in the store, constrained to
234
+ * `toMs` when given (never look past the caller's own upper bound while
235
+ * anchoring). Used by `handleOverview` to anchor its gap-walk cursor when
236
+ * `from` is omitted — see the cursor-anchoring comment there for why
237
+ * falling back to "now" instead is wrong. Cheap: a single native
238
+ * time-indexed query for one message (oldest-first is
239
+ * `queryMessagesByTime`'s default order). Returns undefined for a
240
+ * genuinely empty store (or empty up to `toMs`).
241
+ */
242
+ private earliestMessageMs(toMs: number | undefined): number | undefined {
243
+ const cm = this.cm as ContextManager;
244
+ const probe = cm.queryMessagesByTime({ toMs, limit: 1 });
245
+ return probe.messages[0]?.timestamp.getTime();
246
+ }
247
+
248
+ /**
249
+ * Every raw message sharing exactly `ms` (a single-point, both-ends-
250
+ * inclusive time query). Used by `handleOverview`'s boundary-tie
251
+ * disambiguation to see past `getChannelTokenStats`'s aggregate-only,
252
+ * sequence-blind view of a millisecond — see the correction pass there
253
+ * for why. Cheap: at most a handful of messages share one millisecond in
254
+ * practice.
255
+ */
256
+ private boundaryMessagesAt(ms: number): StoredMessage[] {
257
+ const cm = this.cm as ContextManager;
258
+ return cm.queryMessagesByTime({ fromMs: ms, toMs: ms }).messages;
132
259
  }
133
260
 
134
261
  async start(ctx: ModuleContext): Promise<void> {
@@ -154,7 +281,7 @@ export class HistoryModule implements Module {
154
281
  properties: {
155
282
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound for tokenStatsForRange. Omit for open-ended.' },
156
283
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound for tokenStatsForRange. Omit for open-ended.' },
157
- channelId: { type: 'string', description: 'Restrict both parts of the response to one channel.' },
284
+ 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.' },
158
285
  },
159
286
  },
160
287
  },
@@ -170,7 +297,7 @@ export class HistoryModule implements Module {
170
297
  properties: {
171
298
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
172
299
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
173
- channelId: { type: 'string', description: 'Restrict to one channel.' },
300
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
174
301
  limit: { type: 'number', description: `Max messages to return (default ${EXTRACT_DEFAULT_LIMIT}, hard cap ${EXTRACT_MAX_LIMIT}). Must be a non-negative integer.` },
175
302
  offset: { type: 'number', description: `Number of matching messages to skip (default 0, capped to ${NATIVE_OFFSET_MAX}). Must be a non-negative integer.` },
176
303
  format: { type: 'string', enum: ['text', 'raw'], description: 'Content rendering (default "text").' },
@@ -194,13 +321,58 @@ export class HistoryModule implements Module {
194
321
  caseSensitive: { type: 'boolean', description: 'Case-sensitive match (default false).' },
195
322
  from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
196
323
  to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
197
- channelId: { type: 'string', description: 'Restrict to one channel.' },
324
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
198
325
  limit: { type: 'number', description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, hard cap ${SEARCH_MAX_LIMIT}). Must be a non-negative integer.` },
199
326
  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.` },
200
327
  },
201
328
  required: ['query'],
202
329
  },
203
330
  },
331
+ {
332
+ name: 'overview',
333
+ description:
334
+ 'Browse a time range when you don\'t know exactly what you\'re looking for yet: returns existing ' +
335
+ 'compression summaries as a table of contents, at zero new LLM cost (purely a read over summaries ' +
336
+ 'already produced by compression). Spans not yet summarized fall back to raw message counts, ' +
337
+ 'marked summarized:false. Omit `level` to let each span show its own coarsest available summary ' +
338
+ '(spans can end up at DIFFERENT levels — the response then reports maxLevelAvailable rather than ' +
339
+ 'a single `level`, since no one number would describe a mixed result); pass an exact `level` for ' +
340
+ 'flat, single-granularity results instead (finer detail = lower level), and the response then ' +
341
+ 'echoes that `level` back. `channelId` (a label like "#general" or the raw internal id) narrows ' +
342
+ 'WHICH spans are shown (only spans with at least one message from that channel) and scopes each ' +
343
+ 'entry\'s messageCount/tokensEstimate to that channel\'s own numbers (spanMessageCount/' +
344
+ 'spanTokensEstimate carry the whole-span totals alongside, for context) — but does NOT scope a ' +
345
+ 'kept entry\'s summary TEXT to that channel: compression doesn\'t chunk per-channel, so a ' +
346
+ 'summary may describe other channels\' traffic too. A wide or unbounded from/to can be expensive ' +
347
+ 'on a cold cache — same cost class as `stats`\'s tokenStatsForRange — since gap-filling still ' +
348
+ 'walks the underlying message range wherever nothing has been summarized yet. Response spans are ' +
349
+ 'capped at `limit`, keeping the MOST RECENT spans and reporting truncated:true + totalSpans when ' +
350
+ 'more exist — narrow the range or raise `limit` to see further back. `from` after `to` is rejected ' +
351
+ 'with a clear error rather than silently returning no spans. An entry marked boundaryUncertain:true ' +
352
+ 'had a millisecond-timestamp collision at its edge with another span (rare — two distinct messages ' +
353
+ 'landing in the same millisecond); its messageCount is still exact but tokensEstimate may be off by ' +
354
+ 'a small, bounded amount.',
355
+ inputSchema: {
356
+ type: 'object' as const,
357
+ properties: {
358
+ from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
359
+ to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
360
+ 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.' },
361
+ level: {
362
+ type: 'number',
363
+ description:
364
+ 'Exact summary level to fetch, flat (no fold-reduction across levels). Omit to default to ' +
365
+ 'each span\'s own coarsest currently-available summary (a mix of levels across the response ' +
366
+ 'is expected and normal), reduced so a folded summary\'s now-superseded child does not also ' +
367
+ 'appear alongside a coarser entry that already covers its span.',
368
+ },
369
+ limit: {
370
+ type: 'number',
371
+ description: `Max spans to return, keeping the most recent (default ${OVERVIEW_DEFAULT_LIMIT}, hard cap ${OVERVIEW_MAX_LIMIT}). Must be a non-negative integer.`,
372
+ },
373
+ },
374
+ },
375
+ },
204
376
  ];
205
377
  }
206
378
 
@@ -216,6 +388,8 @@ export class HistoryModule implements Module {
216
388
  return this.handleExtract((call.input ?? {}) as ExtractInput);
217
389
  case 'search':
218
390
  return await this.handleSearch((call.input ?? {}) as SearchInput);
391
+ case 'overview':
392
+ return this.handleOverview((call.input ?? {}) as OverviewInput);
219
393
  default:
220
394
  return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
221
395
  }
@@ -228,9 +402,9 @@ export class HistoryModule implements Module {
228
402
  // capability-absent error ("Chronicle history index unsupported...",
229
403
  // thrown by queryMessagesByTime/queryMessagesByChannel/
230
404
  // queryMessagesByTimeAndChannel/getChannelMessageCounts/
231
- // getChannelTokenStats on a chronicle build that predates the native
232
- // index-query capability) — all surfaced as a normal tool error
233
- // rather than crashing the module.
405
+ // getChannelTokenStats/getSummariesInRange/getMaxSummaryLevel on a
406
+ // chronicle/strategy build that predates the relevant capability) —
407
+ // all surfaced as a normal tool error rather than crashing the module.
234
408
  return {
235
409
  success: false,
236
410
  isError: true,
@@ -248,6 +422,7 @@ export class HistoryModule implements Module {
248
422
  // ==========================================================================
249
423
 
250
424
  private handleStats(input: StatsInput): ToolResult {
425
+ const channelId = this.resolveChannel(input.channelId);
251
426
  const fromMs = parseIsoDate(input.from, 'from');
252
427
  const toMs = parseIsoDate(input.to, 'to');
253
428
  const cm = this.cm as ContextManager;
@@ -255,8 +430,8 @@ export class HistoryModule implements Module {
255
430
  let messageCountsAllTime: ChannelCount[] = cm.getChannelMessageCounts();
256
431
  let tokenStatsForRange: ChannelTokenStats = cm.getChannelTokenStats({ fromMs, toMs });
257
432
 
258
- if (input.channelId) {
259
- messageCountsAllTime = messageCountsAllTime.filter((c) => c.channelId === input.channelId);
433
+ if (channelId) {
434
+ messageCountsAllTime = messageCountsAllTime.filter((c) => c.channelId === channelId);
260
435
  tokenStatsForRange = {
261
436
  // Totals stay whole-range (they're documented as store-wide-for-the-range,
262
437
  // not per-channel); only the byChannel breakdown is narrowed. Labeling the
@@ -265,7 +440,7 @@ export class HistoryModule implements Module {
265
440
  // misleadingly-merged single-channel total.
266
441
  totalMessages: tokenStatsForRange.totalMessages,
267
442
  totalTokensEstimate: tokenStatsForRange.totalTokensEstimate,
268
- byChannel: tokenStatsForRange.byChannel.filter((c) => c.channelId === input.channelId),
443
+ byChannel: tokenStatsForRange.byChannel.filter((c) => c.channelId === channelId),
269
444
  };
270
445
  }
271
446
 
@@ -284,6 +459,7 @@ export class HistoryModule implements Module {
284
459
  // ==========================================================================
285
460
 
286
461
  private handleExtract(input: ExtractInput): ToolResult {
462
+ const channelId = this.resolveChannel(input.channelId);
287
463
  const fromMs = parseIsoDate(input.from, 'from');
288
464
  const toMs = parseIsoDate(input.to, 'to');
289
465
  const limit = clampCount(input.limit, EXTRACT_DEFAULT_LIMIT, EXTRACT_MAX_LIMIT, 'limit');
@@ -294,7 +470,7 @@ export class HistoryModule implements Module {
294
470
  const result = cm.queryMessagesByTimeAndChannel({
295
471
  fromMs,
296
472
  toMs,
297
- channelId: input.channelId,
473
+ channelId,
298
474
  limit,
299
475
  offset,
300
476
  });
@@ -317,6 +493,7 @@ export class HistoryModule implements Module {
317
493
  if (!input.query) {
318
494
  throw new Error('search requires a non-empty "query".');
319
495
  }
496
+ const channelId = this.resolveChannel(input.channelId);
320
497
  const fromMs = parseIsoDate(input.from, 'from');
321
498
  const toMs = parseIsoDate(input.to, 'to');
322
499
  const limit = clampCount(input.limit, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, 'limit');
@@ -346,7 +523,7 @@ export class HistoryModule implements Module {
346
523
  const probe = cm.queryMessagesByTimeAndChannel({
347
524
  fromMs,
348
525
  toMs,
349
- channelId: input.channelId,
526
+ channelId,
350
527
  limit: maxScan + 1,
351
528
  offset: 0,
352
529
  });
@@ -467,6 +644,380 @@ export class HistoryModule implements Module {
467
644
  if (worker) void worker.terminate().catch(() => {});
468
645
  }
469
646
  }
647
+
648
+ // ==========================================================================
649
+ // overview
650
+ // ==========================================================================
651
+
652
+ private handleOverview(input: OverviewInput): ToolResult {
653
+ const channelId = this.resolveChannel(input.channelId);
654
+ const fromMs = parseIsoDate(input.from, 'from');
655
+ const toMs = parseIsoDate(input.to, 'to');
656
+ assertOrderedRange(fromMs, toMs, input.from, input.to);
657
+ const limit = clampCount(input.limit, OVERVIEW_DEFAULT_LIMIT, OVERVIEW_MAX_LIMIT, 'limit');
658
+ const cm = this.cm as ContextManager;
659
+
660
+ let entries: TimeRangeSummaryEntry[];
661
+ if (input.level !== undefined) {
662
+ // Caller asked for one exact level: flat, no fold-reduction — they
663
+ // want that granularity, not the coarsest-available mix below.
664
+ entries = cm.getSummariesInRange({ fromMs, toMs, level: input.level });
665
+ } else {
666
+ // No level given: fetch every level overlapping the range, then
667
+ // reduce to only "current" (non-superseded) entries. getSummariesInRange
668
+ // does NOT filter by fold status — a folded child and the coarser
669
+ // parent it was folded into can BOTH independently overlap the query
670
+ // range and BOTH come back in `all` (see TimeRangeSummaryEntry.parentId's
671
+ // doc comment in context-manager).
672
+ //
673
+ // Two complementary checks, because an ancestor CHAIN can be broken:
674
+ // `listSummariesInRange` silently skips an entry whose source range no
675
+ // longer resolves (a pruned/redacted source message, or a viewFilter
676
+ // exclusion — a real, named failure mode elsewhere in
677
+ // autobiographical.ts's contiguousMergeCandidates). If that skipped
678
+ // entry is an INTERMEDIATE level, its children's parentId points at an
679
+ // id that never made it into `all`, so the immediate-parent check
680
+ // alone can't see past the gap and a since-superseded child survives
681
+ // alongside the coarser grandparent that already covers its span.
682
+ // 1. Immediate-parent check (cheap, covers the intact-chain case).
683
+ // 2. Containment sweep (backstop, robust to a broken chain): drop an
684
+ // entry if any OTHER fetched entry is STRICTLY coarser (higher
685
+ // level) and its span fully contains this one — that entry
686
+ // already covers the same messages regardless of whether the
687
+ // parentId pointer chain between them survived intact.
688
+ // EXCEPTION: a zero-width entry (e.startMs === e.endMs — a tiny
689
+ // chunk that landed entirely within one millisecond) that merely
690
+ // TOUCHES a coarser entry's boundary at exactly that one point
691
+ // trivially satisfies "f.startMs <= e.startMs && f.endMs >=
692
+ // e.endMs" without f actually being an ancestor — a genuinely
693
+ // unrelated, later-starting/earlier-ending coarser summary can
694
+ // coincidentally share one endpoint with it. A real (non-zero-
695
+ // width) entry can't trigger this false positive: satisfying
696
+ // containment on BOTH ends while only touching at one means f
697
+ // must still genuinely extend past the entry's other, non-touching
698
+ // side. A genuine same-point fold is still caught by the
699
+ // immediate-parentId check above when the chain is intact.
700
+ // O(n^2) in the fetched set size, which is fine at summary-archive
701
+ // scale (bounded by how much has been compressed into this range, not
702
+ // by raw message count).
703
+ const all = cm.getSummariesInRange({ fromMs, toMs });
704
+ const idsInSet = new Set(all.map((e) => e.id));
705
+ entries = all.filter((e) => {
706
+ if (e.parentId && idsInSet.has(e.parentId)) return false;
707
+ return !all.some((f) => {
708
+ if (f.id === e.id || f.level <= e.level) return false;
709
+ if (!(f.startMs <= e.startMs && f.endMs >= e.endMs)) return false;
710
+ if (e.startMs === e.endMs && (e.startMs === f.startMs || e.endMs === f.endMs)) return false;
711
+ return true;
712
+ });
713
+ });
714
+ }
715
+ entries.sort((a, b) => a.startMs - b.startMs);
716
+
717
+ // Per-summary channel/token stats — same call `stats` uses, scoped to
718
+ // just this entry's own source span.
719
+ const summarized: Array<{
720
+ startMs: number;
721
+ endMs: number;
722
+ summarized: true;
723
+ content: string;
724
+ level: number;
725
+ stats: ChannelTokenStats;
726
+ boundaryUncertain?: boolean;
727
+ }> = entries.map((entry) => ({
728
+ startMs: entry.startMs,
729
+ endMs: entry.endMs,
730
+ summarized: true as const,
731
+ content: entry.content,
732
+ level: entry.level,
733
+ stats: cm.getChannelTokenStats({ fromMs: entry.startMs, toMs: entry.endMs }),
734
+ }));
735
+
736
+ // Gap-fill: walk the sorted summary spans and probe seams wide enough
737
+ // to be worth a getChannelTokenStats call. A gap with real traffic
738
+ // becomes a synthetic summarized:false entry; an empty gap (nothing
739
+ // happened there) is skipped rather than cluttering the response.
740
+ //
741
+ // Cursor anchoring when `from` is omitted: NOT "now" — with zero
742
+ // summaries minted yet (first-ever call, or a PassthroughStrategy host
743
+ // where getSummariesInRange always returns []) that would make the
744
+ // whole walk a zero-width no-op and silently report entries:[] even
745
+ // though the store is full of messages. And even WITH summaries
746
+ // present, starting at the first summary's startMs silently hides any
747
+ // older unsummarized traffic that predates every summary (e.g. history
748
+ // from before compression was enabled). Anchor instead at the earlier
749
+ // of "the first summary's start" and "the earliest real message in the
750
+ // store" — falling back to Date.now() only when NEITHER exists, i.e.
751
+ // the store is genuinely empty, where entries:[] is the correct answer.
752
+ const rangeEnd = toMs ?? Date.now();
753
+ const firstEntryStartMs = entries[0]?.startMs;
754
+ let cursor: number;
755
+ if (fromMs !== undefined) {
756
+ cursor = fromMs;
757
+ } else {
758
+ const earliestMessageMs = this.earliestMessageMs(toMs);
759
+ cursor =
760
+ earliestMessageMs !== undefined && firstEntryStartMs !== undefined
761
+ ? Math.min(earliestMessageMs, firstEntryStartMs)
762
+ : earliestMessageMs ?? firstEntryStartMs ?? Date.now();
763
+ }
764
+ // Effective query start — captured BEFORE the gap loop below mutates
765
+ // `cursor` (it walks forward past each entry's endMs). Needed by the
766
+ // boundary-tie correction pass further down to clamp its output to
767
+ // what the caller actually asked for (see the comment there): when
768
+ // `fromMs` is omitted this equals the resolved anchor above, not
769
+ // `-Infinity` — an open-ended query still has a concrete effective
770
+ // start, it's just not caller-supplied.
771
+ const rangeStart = cursor;
772
+
773
+ const gaps: Array<{ startMs: number; endMs: number; summarized: false; stats: ChannelTokenStats; boundaryUncertain?: boolean }> = [];
774
+ // True once `cursor` has been advanced past at least one real summary
775
+ // entry's endMs — only THEN is `cursor` itself a value that a summary
776
+ // already claims (and so needs a +1 nudge below to probe half-open).
777
+ // The very first gap (before any entry) sits against `fromMs` or an
778
+ // earliest-message anchor, neither of which any summary has claimed,
779
+ // so it must NOT be nudged — nudging it would skip a real message
780
+ // sitting exactly at the anchor.
781
+ let cursorIsEntryBoundary = false;
782
+ for (const entry of entries) {
783
+ // getChannelTokenStats's range is BOTH-ENDS INCLUSIVE (chronicle's
784
+ // gte/lte), so probing the raw [cursor, entry.startMs] pair would
785
+ // re-count the neighboring summaries' own boundary messages (ts ===
786
+ // cursor from the entry before, ts === entry.startMs from this one)
787
+ // as if they were unsummarized — fabricating a phantom gap between
788
+ // EVERY pair of adjacent summaries, even when they cover 100% of
789
+ // traffic with zero real gap between them. Narrow to a half-open
790
+ // probe instead, and only skip the call when narrowing collapsed the
791
+ // width to zero or negative (`probeTo < probeFrom` — an entry that
792
+ // starts exactly where the previous one ended, or crosses it).
793
+ //
794
+ // Deliberately NO minimum-width threshold beyond that: an earlier
795
+ // version skipped any seam narrower than a fixed few seconds as
796
+ // "bookkeeping noise", but that check ran BEFORE the
797
+ // stats.totalMessages > 0 check below that actually decides whether
798
+ // real traffic exists — so a query window or a between-summaries
799
+ // seam narrower than the threshold silently reported entries:[] even
800
+ // when full of real messages (verified repro: 300 real messages,
801
+ // zero summaries, a 4-second window). The totalMessages > 0 check
802
+ // that follows is the one that's actually correct; nothing should
803
+ // gate the probe ahead of it.
804
+ const probeFrom = cursorIsEntryBoundary ? cursor + 1 : cursor;
805
+ const probeTo = entry.startMs - 1;
806
+ if (probeTo >= probeFrom) {
807
+ const stats = cm.getChannelTokenStats({ fromMs: probeFrom, toMs: probeTo });
808
+ if (stats.totalMessages > 0) gaps.push({ startMs: probeFrom, endMs: probeTo, summarized: false, stats });
809
+ }
810
+ cursor = Math.max(cursor, entry.endMs);
811
+ cursorIsEntryBoundary = true;
812
+ }
813
+ {
814
+ // Trailing gap: rangeEnd is either the caller's own inclusive `to`
815
+ // bound or Date.now() — never a summary boundary — so only the LOWER
816
+ // end gets the half-open nudge (same reasoning as inside the loop).
817
+ // Same no-threshold reasoning as above.
818
+ const probeFrom = cursorIsEntryBoundary ? cursor + 1 : cursor;
819
+ const probeTo = rangeEnd;
820
+ if (probeTo >= probeFrom) {
821
+ const stats = cm.getChannelTokenStats({ fromMs: probeFrom, toMs: probeTo });
822
+ if (stats.totalMessages > 0) gaps.push({ startMs: probeFrom, endMs: probeTo, summarized: false, stats });
823
+ }
824
+ }
825
+
826
+ // Boundary-tie disambiguation: getChannelTokenStats operates on
827
+ // wall-clock TIME ranges and has no visibility into chronicle's
828
+ // sequence numbers, so whenever a message that's genuinely NOT part of
829
+ // a summary's source span happens to share the EXACT millisecond with
830
+ // that summary's own first/last covered message (rapid-fire appends —
831
+ // wall-clock ms resolution isn't unique, sequence is), the per-span
832
+ // aggregate calls above silently fold it into the summary's counts —
833
+ // and the adjacent gap probe (which deliberately EXCLUDES that same ms
834
+ // via the half-open nudge, on the assumption the boundary ms belongs
835
+ // entirely to the neighboring summary) has no way to see it at all: it
836
+ // doesn't just get miscounted, it vanishes from the response entirely.
837
+ //
838
+ // Resolved using TimeRangeSummaryEntry's firstSequence/lastSequence
839
+ // (chronicle's per-record sequence number — strictly monotonic, never
840
+ // ties, unlike wall-clock ms) against the ACTUAL messages at each
841
+ // boundary ms (queried directly via boundaryMessagesAt — cheap, at
842
+ // most a handful of messages share one millisecond in practice).
843
+ //
844
+ // A "foreign" message found this way either belongs to ANOTHER fetched
845
+ // summary entry (its sequence falls in THAT entry's own range — its
846
+ // own inclusive-range getChannelTokenStats call already counts it
847
+ // correctly, so this entry just needs the erroneous count subtracted,
848
+ // nothing added elsewhere) or is genuinely unsummarized (belongs to the
849
+ // adjacent gap, extending it — or creating a new point-width gap
850
+ // fragment if no gap was probed there at all, which is exactly the
851
+ // "vanishes entirely" failure mode this fix exists for).
852
+ //
853
+ // MESSAGE COUNT/membership is corrected exactly — we know precisely
854
+ // which raw messages are foreign and their channelId. TOKEN weight for
855
+ // those specific messages is deliberately NOT recomputed:
856
+ // getChannelTokenStats can only report tokens for a TIME range, never
857
+ // for an arbitrary message subset, and reimplementing token estimation
858
+ // locally would silently drift from context-manager's own (mutable,
859
+ // calibration-adjusted) estimate — worse than admitting the gap. Any
860
+ // entry this pass touches is marked `boundaryUncertain: true` so a
861
+ // caller knows its tokensEstimate may be off by the small, bounded
862
+ // weight of the message(s) that moved, even though messageCount there
863
+ // is exact.
864
+ // Messages already routed into a gap by an EARLIER entry's pass in this
865
+ // same loop — without this, two entries that both touch the same
866
+ // boundary millisecond (e.g. summary A ends and summary B starts at the
867
+ // exact same ms, with one genuinely-unsummarized stray message also at
868
+ // that ms) each independently decide "not mine, not any other fetched
869
+ // SUMMARY's either" and BOTH call mergeForeignIntoGap for the same
870
+ // stray — mergeForeignIntoGap's own gap-lookup only checks for a gap
871
+ // immediately adjacent to ITS OWN entry's boundary, so it can't see the
872
+ // point-width gap the other entry's pass just created one step away,
873
+ // and the same message gets double-counted into two separate gap
874
+ // entries. Tracking ids already merged (not just already-seen) across
875
+ // the whole loop — not just within one entry's own pass — closes this.
876
+ const alreadyMergedForeignIds = new Set<string>();
877
+ for (let i = 0; i < entries.length; i++) {
878
+ const entry = entries[i]!;
879
+ const own = summarized[i]!;
880
+ // Defensive: firstSequence/lastSequence are new fields on
881
+ // TimeRangeSummaryEntry (added alongside this correction pass) — if
882
+ // this module ever ends up running against an OLDER published
883
+ // context-manager whose getSummariesInRange doesn't populate them
884
+ // (undefined, not a type error at runtime), every comparison below
885
+ // would silently evaluate false, foreignById would always be empty,
886
+ // and this whole pass would silently no-op — resurrecting the exact
887
+ // boundary-tie bug it exists to fix, with no error and no
888
+ // boundaryUncertain signal. Skip explicitly instead of trusting the
889
+ // type system alone.
890
+ if (typeof entry.firstSequence !== 'number' || typeof entry.lastSequence !== 'number') continue;
891
+ const boundaryMsList = entry.startMs === entry.endMs ? [entry.startMs] : [entry.startMs, entry.endMs];
892
+ const foreignById = new Map<string, StoredMessage>();
893
+ for (const ms of boundaryMsList) {
894
+ for (const m of this.boundaryMessagesAt(ms)) {
895
+ if (m.sequence < entry.firstSequence || m.sequence > entry.lastSequence) {
896
+ foreignById.set(String(m.id), m);
897
+ }
898
+ }
899
+ }
900
+ if (foreignById.size === 0) continue;
901
+
902
+ const genuinelyUnsummarized: StoredMessage[] = [];
903
+ for (const m of foreignById.values()) {
904
+ const owner = entries.find(
905
+ (e) =>
906
+ e.id !== entry.id &&
907
+ typeof e.firstSequence === 'number' &&
908
+ typeof e.lastSequence === 'number' &&
909
+ m.sequence >= e.firstSequence &&
910
+ m.sequence <= e.lastSequence,
911
+ );
912
+ if (!owner && !alreadyMergedForeignIds.has(String(m.id))) genuinelyUnsummarized.push(m);
913
+ }
914
+
915
+ own.stats = subtractMessagesFromStats(own.stats, [...foreignById.values()]);
916
+ own.boundaryUncertain = true;
917
+
918
+ // getSummariesInRange intentionally returns WHOLE summaries that
919
+ // overlap the query — a summary's own startMs/endMs can legitimately
920
+ // extend past the caller's requested [rangeStart, rangeEnd] (that's
921
+ // correct: the summary itself, and its corrected messageCount above,
922
+ // belong in the response either way). But a correction-pass GAP
923
+ // anchored at entry.startMs/entry.endMs must NOT be added when that
924
+ // boundary itself falls outside the query window — the ordinary gap
925
+ // walk elsewhere in this function never synthesizes a gap outside
926
+ // [rangeStart, rangeEnd], and this pass shouldn't either. Without
927
+ // this check, a narrow-window query landing entirely inside an
928
+ // already-summarized span could still surface phantom
929
+ // summarized:false rows anchored at that summary's FAR boundaries,
930
+ // outside anything the caller asked about.
931
+ const before = genuinelyUnsummarized.filter((m) => m.sequence < entry.firstSequence);
932
+ const after = genuinelyUnsummarized.filter((m) => m.sequence > entry.lastSequence);
933
+ const startInRange = entry.startMs >= rangeStart && entry.startMs <= rangeEnd;
934
+ const endInRange = entry.endMs >= rangeStart && entry.endMs <= rangeEnd;
935
+ if (before.length > 0 && startInRange) {
936
+ mergeForeignIntoGap(gaps, entry.startMs, 'before', before);
937
+ for (const m of before) alreadyMergedForeignIds.add(String(m.id));
938
+ }
939
+ if (after.length > 0 && endInRange) {
940
+ mergeForeignIntoGap(gaps, entry.endMs, 'after', after);
941
+ for (const m of after) alreadyMergedForeignIds.add(String(m.id));
942
+ }
943
+ }
944
+
945
+ // Merge summary-backed + gap-filled spans, apply the channel filter
946
+ // (drop any span with zero messages from the requested channel — but
947
+ // keep a kept summary's `content` unfiltered: compression doesn't chunk
948
+ // per-channel, so the text may legitimately describe other channels
949
+ // too), sort, and project to the response shape.
950
+ const merged = [...summarized, ...gaps].sort((a, b) => a.startMs - b.startMs);
951
+ const filtered = channelId
952
+ ? merged.filter((e) => e.stats.byChannel.some((c) => c.channelId === channelId))
953
+ : merged;
954
+
955
+ // Cap the final entry list — mirrors search's truncated:true contract.
956
+ // Keeps the MOST RECENT `limit` spans when the cap is hit (a browse
957
+ // tool defaulting to "what's happened lately" should keep the tail, not
958
+ // silently cut off at the oldest end) while the survivors stay in their
959
+ // own chronological (oldest-first) order within the response, matching
960
+ // extract's/search's own ordering convention.
961
+ const totalSpans = filtered.length;
962
+ const truncated = totalSpans > limit;
963
+ // NOT `filtered.slice(-limit)`: `slice(-0)` is `slice(+0)` (negative
964
+ // zero coerces away), so `limit: 0` — a value clampCount explicitly
965
+ // accepts — would slice from index 0 and return the ENTIRE array while
966
+ // still reporting truncated:true. Same bug class already fixed twice
967
+ // elsewhere in this project (search's/extract's own limit:0 bugs);
968
+ // `filtered.length - limit` has no such cliff, at limit:0 it's
969
+ // `slice(filtered.length)` -> `[]`, the honest answer.
970
+ const capped = truncated ? filtered.slice(filtered.length - limit) : filtered;
971
+
972
+ return {
973
+ success: true,
974
+ data: {
975
+ range: { from: input.from ?? null, to: input.to ?? null },
976
+ // Only meaningful as a single number when `level` was pinned: the
977
+ // default (fold-reduced) result mixes spans at whatever level each
978
+ // one's own coarsest-available summary happens to be, so reporting
979
+ // a single `level` there would be actively misleading (an agent
980
+ // could see level:3 while looking at an L1 entry). Each summarized
981
+ // entry already carries its own accurate `level`; the top-level
982
+ // field instead reports the ceiling as `maxLevelAvailable` in that
983
+ // case, distinctly named so it can't be mistaken for a uniform
984
+ // per-entry level.
985
+ ...(input.level !== undefined ? { level: input.level } : { maxLevelAvailable: cm.getMaxSummaryLevel() }),
986
+ truncated,
987
+ totalSpans,
988
+ entries: capped.map((e) => {
989
+ // When channelId narrows the response, the numbers an agent will
990
+ // actually reason over (messageCount/tokensEstimate) are scoped
991
+ // to THAT channel — not the whole span, which can otherwise
992
+ // include far more traffic from other channels sharing the same
993
+ // summarized/gap span. This is a deliberate departure from
994
+ // `stats`, which keeps its top-level totals whole-range and only
995
+ // narrows the byChannel breakdown (documented in handleStats) —
996
+ // here, an agent that asked to narrow by channel is reasoning
997
+ // about that channel specifically, so the whole-span totals stay
998
+ // available under distinctly-named spanMessageCount/
999
+ // spanTokensEstimate instead of silently double-serving as both.
1000
+ const channelStats = channelId ? e.stats.byChannel.find((c) => c.channelId === channelId) : undefined;
1001
+ return {
1002
+ from: new Date(e.startMs).toISOString(),
1003
+ to: new Date(e.endMs).toISOString(),
1004
+ summarized: e.summarized,
1005
+ ...(e.summarized ? { content: e.content, level: e.level } : {}),
1006
+ // Set only when this entry's boundary-tie disambiguation (see
1007
+ // the correction pass above) found and corrected a
1008
+ // millisecond-timestamp collision at its edge. messageCount is
1009
+ // still exact there; tokensEstimate may be off by the small,
1010
+ // bounded weight of the message(s) that moved.
1011
+ ...(e.boundaryUncertain ? { boundaryUncertain: true } : {}),
1012
+ messageCount: channelStats ? channelStats.messages : e.stats.totalMessages,
1013
+ tokensEstimate: channelStats ? channelStats.tokensEstimate : e.stats.totalTokensEstimate,
1014
+ ...(channelId ? { spanMessageCount: e.stats.totalMessages, spanTokensEstimate: e.stats.totalTokensEstimate } : {}),
1015
+ byChannel: e.stats.byChannel,
1016
+ };
1017
+ }),
1018
+ },
1019
+ };
1020
+ }
470
1021
  }
471
1022
 
472
1023
  // ============================================================================
@@ -485,6 +1036,19 @@ function parseIsoDate(value: string | undefined, field: string): number | undefi
485
1036
  return ms;
486
1037
  }
487
1038
 
1039
+ /**
1040
+ * Reject an inverted range (`from` after `to`) with a clear error instead of
1041
+ * silently matching nothing. Used by `overview`, where "found nothing" is
1042
+ * reported as `entries: []` — the same shape a caller sees for a genuinely
1043
+ * quiet span — so a simple from/to mixup should never be allowed to look
1044
+ * like "you have no history here" instead of the input mistake it is.
1045
+ */
1046
+ function assertOrderedRange(fromMs: number | undefined, toMs: number | undefined, fromRaw: string | undefined, toRaw: string | undefined): void {
1047
+ if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) {
1048
+ throw new Error(`"from" (${fromRaw}) must not be after "to" (${toRaw}).`);
1049
+ }
1050
+ }
1051
+
488
1052
  /**
489
1053
  * Validate a pagination-ish numeric input (limit/offset/maxScan) and clamp
490
1054
  * it to an upper bound. `Math.min(value, max)` alone is NOT sufficient here:
@@ -531,6 +1095,106 @@ function getChannelId(msg: StoredMessage): string | undefined {
531
1095
  return typeof metadata?.external?.channelId === 'string' ? metadata.external.channelId : undefined;
532
1096
  }
533
1097
 
1098
+ // ============================================================================
1099
+ // overview: boundary-tie correction helpers (see handleOverview)
1100
+ // ============================================================================
1101
+
1102
+ /**
1103
+ * Subtract the exact per-channel MESSAGE COUNT of `foreign` from `stats` —
1104
+ * used to correct a summary entry's own stats once boundary-tie
1105
+ * disambiguation finds messages counted in error (see handleOverview's
1106
+ * correction pass). Deliberately leaves totalTokensEstimate/
1107
+ * byChannel[].tokensEstimate untouched — see that pass's header comment
1108
+ * for why an exact token subtraction isn't achievable from the available
1109
+ * API.
1110
+ */
1111
+ function subtractMessagesFromStats(stats: ChannelTokenStats, foreign: StoredMessage[]): ChannelTokenStats {
1112
+ if (foreign.length === 0) return stats;
1113
+ const countByChannel = new Map<string, number>();
1114
+ for (const m of foreign) {
1115
+ const ch = getChannelId(m);
1116
+ if (ch === undefined) continue; // unchanneled messages aren't tracked in byChannel at all
1117
+ countByChannel.set(ch, (countByChannel.get(ch) ?? 0) + 1);
1118
+ }
1119
+ const byChannel = stats.byChannel
1120
+ .map((c) => ({ ...c, messages: Math.max(0, c.messages - (countByChannel.get(c.channelId) ?? 0)) }))
1121
+ .filter((c) => c.messages > 0);
1122
+ return {
1123
+ totalMessages: Math.max(0, stats.totalMessages - foreign.length),
1124
+ totalTokensEstimate: stats.totalTokensEstimate,
1125
+ byChannel,
1126
+ };
1127
+ }
1128
+
1129
+ /**
1130
+ * Exact per-channel MESSAGE COUNT for a small raw message list, with
1131
+ * tokensEstimate left at 0 (unknown — see handleOverview's boundary-tie
1132
+ * correction pass). Only ever used to build a BRAND NEW synthetic gap
1133
+ * fragment out of boundary-tie foreign messages — a real query result
1134
+ * always goes through the real getChannelTokenStats aggregate instead.
1135
+ */
1136
+ function statsFromMessages(messages: StoredMessage[]): ChannelTokenStats {
1137
+ const byChannel = new Map<string, number>();
1138
+ for (const m of messages) {
1139
+ const ch = getChannelId(m);
1140
+ if (ch === undefined) continue;
1141
+ byChannel.set(ch, (byChannel.get(ch) ?? 0) + 1);
1142
+ }
1143
+ return {
1144
+ totalMessages: messages.length,
1145
+ totalTokensEstimate: 0,
1146
+ byChannel: [...byChannel.entries()].map(([channelId, count]) => ({ channelId, messages: count, tokensEstimate: 0 })),
1147
+ };
1148
+ }
1149
+
1150
+ /** Add `b`'s counts into `a` — used to fold boundary-tie foreign messages
1151
+ * into an already-probed gap's stats. */
1152
+ function mergeStats(a: ChannelTokenStats, b: ChannelTokenStats): ChannelTokenStats {
1153
+ const byChannel = new Map<string, { messages: number; tokensEstimate: number }>();
1154
+ for (const c of a.byChannel) byChannel.set(c.channelId, { messages: c.messages, tokensEstimate: c.tokensEstimate });
1155
+ for (const c of b.byChannel) {
1156
+ const cur = byChannel.get(c.channelId) ?? { messages: 0, tokensEstimate: 0 };
1157
+ cur.messages += c.messages;
1158
+ cur.tokensEstimate += c.tokensEstimate;
1159
+ byChannel.set(c.channelId, cur);
1160
+ }
1161
+ return {
1162
+ totalMessages: a.totalMessages + b.totalMessages,
1163
+ totalTokensEstimate: a.totalTokensEstimate + b.totalTokensEstimate,
1164
+ byChannel: [...byChannel.entries()].map(([channelId, v]) => ({ channelId, ...v })),
1165
+ };
1166
+ }
1167
+
1168
+ /**
1169
+ * Fold boundary-tie `foreign` messages into whichever gap sits immediately
1170
+ * `side` of `boundaryMs`, extending that gap's span to include the tied
1171
+ * millisecond — or, if no such gap currently exists (the common case when
1172
+ * the seam had zero OTHER real traffic and so was never probed into
1173
+ * existence), create a new point-width gap fragment exactly at
1174
+ * `boundaryMs` to hold them. Always marks the result `boundaryUncertain:
1175
+ * true` (see handleOverview's boundary-tie correction pass for what that
1176
+ * means).
1177
+ */
1178
+ function mergeForeignIntoGap(
1179
+ gaps: Array<{ startMs: number; endMs: number; summarized: false; stats: ChannelTokenStats; boundaryUncertain?: boolean }>,
1180
+ boundaryMs: number,
1181
+ side: 'before' | 'after',
1182
+ foreign: StoredMessage[],
1183
+ ): void {
1184
+ const addend = statsFromMessages(foreign);
1185
+ const existing =
1186
+ side === 'before' ? gaps.find((g) => g.endMs === boundaryMs - 1) : gaps.find((g) => g.startMs === boundaryMs + 1);
1187
+
1188
+ if (existing) {
1189
+ existing.stats = mergeStats(existing.stats, addend);
1190
+ if (side === 'before') existing.endMs = boundaryMs;
1191
+ else existing.startMs = boundaryMs;
1192
+ existing.boundaryUncertain = true;
1193
+ } else {
1194
+ gaps.push({ startMs: boundaryMs, endMs: boundaryMs, summarized: false, stats: addend, boundaryUncertain: true });
1195
+ }
1196
+ }
1197
+
534
1198
  function projectMessage(msg: StoredMessage, format: 'text' | 'raw'): Record<string, unknown> {
535
1199
  return {
536
1200
  id: String(msg.id),