@animalabs/agent-framework 0.14.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 (134) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +42 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +94 -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 -5
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1906 -102
  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 +145 -0
  33. package/dist/src/modules/history/index.d.ts.map +1 -0
  34. package/dist/src/modules/history/index.js +1150 -0
  35. package/dist/src/modules/history/index.js.map +1 -0
  36. package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
  37. package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
  38. package/dist/src/modules/history/search-regex-worker.js +57 -0
  39. package/dist/src/modules/history/search-regex-worker.js.map +1 -0
  40. package/dist/src/modules/index.d.ts +1 -0
  41. package/dist/src/modules/index.d.ts.map +1 -1
  42. package/dist/src/modules/index.js +1 -0
  43. package/dist/src/modules/index.js.map +1 -1
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +42 -1
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +5 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/operator-log.d.ts +78 -0
  51. package/dist/src/operator-log.d.ts.map +1 -0
  52. package/dist/src/operator-log.js +102 -0
  53. package/dist/src/operator-log.js.map +1 -0
  54. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  55. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  56. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  57. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  58. package/dist/src/types/framework.d.ts +24 -0
  59. package/dist/src/types/framework.d.ts.map +1 -1
  60. package/dist/src/types/trace.d.ts +40 -0
  61. package/dist/src/types/trace.d.ts.map +1 -1
  62. package/dist/test/agent-runtime-settings.test.js +199 -1
  63. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  64. package/dist/test/api-admin-gate.test.d.ts +2 -0
  65. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  66. package/dist/test/api-admin-gate.test.js +116 -0
  67. package/dist/test/api-admin-gate.test.js.map +1 -0
  68. package/dist/test/channel-label-history.test.d.ts +2 -0
  69. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  70. package/dist/test/channel-label-history.test.js +424 -0
  71. package/dist/test/channel-label-history.test.js.map +1 -0
  72. package/dist/test/discord-awareness-outbox.test.js +16 -0
  73. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  74. package/dist/test/event-gate.test.js +44 -0
  75. package/dist/test/event-gate.test.js.map +1 -1
  76. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  77. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  78. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  79. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  80. package/dist/test/history-module-overview.test.d.ts +2 -0
  81. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  82. package/dist/test/history-module-overview.test.js +711 -0
  83. package/dist/test/history-module-overview.test.js.map +1 -0
  84. package/dist/test/history-module.test.d.ts +2 -0
  85. package/dist/test/history-module.test.d.ts.map +1 -0
  86. package/dist/test/history-module.test.js +595 -0
  87. package/dist/test/history-module.test.js.map +1 -0
  88. package/dist/test/host-quiesce.test.d.ts +2 -0
  89. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  90. package/dist/test/host-quiesce.test.js +863 -0
  91. package/dist/test/host-quiesce.test.js.map +1 -0
  92. package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
  93. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
  94. package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
  95. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
  96. package/dist/test/kv-unified-wiring.test.js +39 -0
  97. package/dist/test/kv-unified-wiring.test.js.map +1 -1
  98. package/dist/test/operator-surgery.test.d.ts +2 -0
  99. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  100. package/dist/test/operator-surgery.test.js +398 -0
  101. package/dist/test/operator-surgery.test.js.map +1 -0
  102. package/dist/test/puppet-tool-call.test.js +118 -1
  103. package/dist/test/puppet-tool-call.test.js.map +1 -1
  104. package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
  105. package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
  106. package/dist/test/puppet-turn-reservation.test.js +139 -0
  107. package/dist/test/puppet-turn-reservation.test.js.map +1 -0
  108. package/dist/test/save-recent-image.test.js +29 -0
  109. package/dist/test/save-recent-image.test.js.map +1 -1
  110. package/dist/test/tool-result-spill.test.js +114 -0
  111. package/dist/test/tool-result-spill.test.js.map +1 -1
  112. package/dist/test/workspace-read-text.test.d.ts +2 -0
  113. package/dist/test/workspace-read-text.test.d.ts.map +1 -0
  114. package/dist/test/workspace-read-text.test.js +101 -0
  115. package/dist/test/workspace-read-text.test.js.map +1 -0
  116. package/dist/tsconfig.tsbuildinfo +1 -1
  117. package/package.json +3 -3
  118. package/src/agent.ts +110 -0
  119. package/src/api/server.ts +159 -2
  120. package/src/api/types.ts +15 -0
  121. package/src/framework.ts +2196 -115
  122. package/src/gate/event-gate.ts +47 -4
  123. package/src/index.ts +5 -1
  124. package/src/mcpl/channel-registry.ts +495 -2
  125. package/src/mcpl/server-connection.ts +73 -13
  126. package/src/modules/history/index.ts +1277 -0
  127. package/src/modules/history/search-regex-worker.ts +76 -0
  128. package/src/modules/index.ts +2 -0
  129. package/src/modules/workspace/index.ts +42 -1
  130. package/src/modules/workspace/types.ts +5 -0
  131. package/src/operator-log.ts +132 -0
  132. package/src/recovery/discord-awareness-outbox.ts +19 -0
  133. package/src/types/framework.ts +27 -0
  134. package/src/types/trace.ts +46 -0
@@ -0,0 +1,1150 @@
1
+ /**
2
+ * HistoryModule — read-only access to an agent's full uncompressed message
3
+ * history, backed by context-manager's native chronicle secondary index
4
+ * (`queryMessagesByTime` / `queryMessagesByChannel` /
5
+ * `queryMessagesByTimeAndChannel` / `getChannelMessageCounts` /
6
+ * `getChannelTokenStats`, @animalabs/context-manager >= 0.6.0). Those calls
7
+ * are O(log n + k) against chronicle's `/timestamp` and
8
+ * `/metadata/external/channelId` field indexes, not a full-store scan, so
9
+ * this module can answer "what happened in #foo last Tuesday" against a
10
+ * multi-million-message store without walking the whole log.
11
+ *
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.
31
+ *
32
+ * Kept deliberately read-only, same posture as HealthModule: no side
33
+ * effects, no message mutation. `onProcess` is a no-op.
34
+ *
35
+ * Every query dispatch collapses onto `queryMessagesByTimeAndChannel` — its
36
+ * own implementation already handles "only a time range", "only a channel",
37
+ * or "neither" by delegating to the single-filter native queries (see
38
+ * context-manager's `test/message-store-history-index.test.ts`), so this
39
+ * module doesn't need to re-decide which of the three query methods to call.
40
+ */
41
+ import { Worker } from 'node:worker_threads';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { dirname, join } from 'node:path';
44
+ // ============================================================================
45
+ // Limits
46
+ // ============================================================================
47
+ const EXTRACT_DEFAULT_LIMIT = 50;
48
+ const EXTRACT_MAX_LIMIT = 200;
49
+ /**
50
+ * Real ceiling of the native chronicle pagination `offset` argument (a u32
51
+ * at the N-API boundary). `Number.MAX_SAFE_INTEGER` is NOT a safe ceiling
52
+ * for this field: a value between 0xFFFFFFFF and MAX_SAFE_INTEGER passes a
53
+ * naive upper-bound check unchanged, then wraps/truncates when it crosses
54
+ * into an unsigned 32-bit int on the native side — silently wrong
55
+ * pagination (repro: offset:4294967296 against a real store "succeeded"
56
+ * and returned page 0) instead of a clean failure. Clamping to this ceiling
57
+ * (same clamp-not-reject behavior `limit` already has) means anything
58
+ * beyond it lands on an offset no real store's message count will ever
59
+ * reach, i.e. a correctly-empty page, rather than wrapping onto a wrong one.
60
+ */
61
+ const NATIVE_OFFSET_MAX = 0xffffffff; // 4294967295
62
+ const SEARCH_DEFAULT_LIMIT = 20;
63
+ const SEARCH_MAX_LIMIT = 100;
64
+ const SEARCH_DEFAULT_MAX_SCAN = 5000;
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+>$/;
86
+ /** Characters of surrounding context kept on each side of a search snippet. */
87
+ const SNIPPET_CONTEXT_CHARS = 80;
88
+ /** Fallback snippet length when a match position isn't meaningful to center on. */
89
+ const SNIPPET_FALLBACK_CHARS = 160;
90
+ /**
91
+ * Wall-clock deadline for a single regex-mode search call, enforced by
92
+ * forcibly terminating the worker thread doing the matching (see
93
+ * search-regex-worker.ts's header for why a worker — not a
94
+ * Promise.race/setTimeout — is required to actually interrupt a stuck
95
+ * synchronous RegExp.exec()). Generous enough for any legitimate pattern
96
+ * against a few thousand short strings; short enough that a catastrophic
97
+ * pattern doesn't tie up a worker (or an agent's turn) for long.
98
+ */
99
+ const SEARCH_REGEX_TIMEOUT_MS = 2000;
100
+ /** Compiled sibling of search-regex-worker.ts — resolved at runtime the same
101
+ * way gate-script.ts locates gate-script-worker.js, so it tracks whatever
102
+ * directory this module's own compiled output lives in. */
103
+ const SEARCH_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'search-regex-worker.js');
104
+ export class HistoryModule {
105
+ name = 'history';
106
+ ctx = null;
107
+ cm = null;
108
+ channelRegistry = null;
109
+ /**
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.
121
+ */
122
+ bind(contextManager, channelRegistry) {
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;
198
+ }
199
+ async start(ctx) {
200
+ this.ctx = ctx;
201
+ }
202
+ async stop() {
203
+ this.ctx = null;
204
+ }
205
+ getTools() {
206
+ return [
207
+ {
208
+ name: 'stats',
209
+ description: 'Orient before pulling raw history: per-channel message counts and token totals. ' +
210
+ 'messageCountsAllTime is always all-time and all-channel-scope (the underlying index has no ' +
211
+ 'range dimension) — it is NOT limited by from/to, only optionally filtered to one channelId. ' +
212
+ 'tokenStatsForRange IS scoped to from/to when given. The two are reported separately, never ' +
213
+ 'merged, because their range semantics differ.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ from: { type: 'string', description: 'ISO 8601 inclusive lower bound for tokenStatsForRange. Omit for open-ended.' },
218
+ to: { type: 'string', description: 'ISO 8601 inclusive upper bound for tokenStatsForRange. Omit for open-ended.' },
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.' },
220
+ },
221
+ },
222
+ },
223
+ {
224
+ name: 'extract',
225
+ description: 'Fetch raw, uncompressed messages for a time range and/or channel, paginated oldest-first. ' +
226
+ 'Use after `stats` to pull the actual content. format:"text" flattens each message to a short ' +
227
+ 'readable string (tool_use/tool_result/thinking/images rendered as bracketed labels); ' +
228
+ 'format:"raw" returns the content blocks unmodified.',
229
+ inputSchema: {
230
+ type: 'object',
231
+ properties: {
232
+ from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
233
+ to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
234
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
235
+ limit: { type: 'number', description: `Max messages to return (default ${EXTRACT_DEFAULT_LIMIT}, hard cap ${EXTRACT_MAX_LIMIT}). Must be a non-negative integer.` },
236
+ offset: { type: 'number', description: `Number of matching messages to skip (default 0, capped to ${NATIVE_OFFSET_MAX}). Must be a non-negative integer.` },
237
+ format: { type: 'string', enum: ['text', 'raw'], description: 'Content rendering (default "text").' },
238
+ },
239
+ },
240
+ },
241
+ {
242
+ name: 'search',
243
+ description: 'Search message history for a substring (default) or regex match, within an optional time ' +
244
+ 'range/channel window. Narrows candidates via the same query as `extract` before matching, up ' +
245
+ 'to maxScan candidates — if the narrowed window is larger than maxScan, the response reports ' +
246
+ 'truncated:true up front rather than silently missing later matches; narrow the filter or raise ' +
247
+ 'maxScan and retry. regex:true matching runs under a wall-clock deadline and is cleanly failed ' +
248
+ `(not silently empty) if a pattern is too slow — avoid nested-quantifier patterns like (a+)+.`,
249
+ inputSchema: {
250
+ type: 'object',
251
+ properties: {
252
+ query: { type: 'string', description: 'Substring (or regex source, when regex:true) to search for.' },
253
+ regex: { type: 'boolean', description: 'Treat query as a regular expression (default false).' },
254
+ caseSensitive: { type: 'boolean', description: 'Case-sensitive match (default false).' },
255
+ from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
256
+ to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
257
+ channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
258
+ limit: { type: 'number', description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, hard cap ${SEARCH_MAX_LIMIT}). Must be a non-negative integer.` },
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.` },
260
+ },
261
+ required: ['query'],
262
+ },
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
+ },
307
+ ];
308
+ }
309
+ async handleToolCall(call) {
310
+ try {
311
+ if (!this.cm) {
312
+ throw new Error('HistoryModule not bound — host must call bind(contextManager) before tool dispatch.');
313
+ }
314
+ switch (call.name) {
315
+ case 'stats':
316
+ return this.handleStats((call.input ?? {}));
317
+ case 'extract':
318
+ return this.handleExtract((call.input ?? {}));
319
+ case 'search':
320
+ return await this.handleSearch((call.input ?? {}));
321
+ case 'overview':
322
+ return this.handleOverview((call.input ?? {}));
323
+ default:
324
+ return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
325
+ }
326
+ }
327
+ catch (error) {
328
+ // Catches our own validation errors (bad ISO date, invalid regex,
329
+ // out-of-range limit/offset/maxScan, unbound module), a regex-search
330
+ // worker timeout or worker-side error (see handleSearch/
331
+ // searchWithRegexWorker — a ReDoS-shaped pattern surfaces here as a
332
+ // clean timeout error, never a hang), and context-manager's
333
+ // capability-absent error ("Chronicle history index unsupported...",
334
+ // thrown by queryMessagesByTime/queryMessagesByChannel/
335
+ // queryMessagesByTimeAndChannel/getChannelMessageCounts/
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.
339
+ return {
340
+ success: false,
341
+ isError: true,
342
+ error: error instanceof Error ? error.message : String(error),
343
+ };
344
+ }
345
+ }
346
+ async onProcess(_event, _state) {
347
+ return {};
348
+ }
349
+ // ==========================================================================
350
+ // stats
351
+ // ==========================================================================
352
+ handleStats(input) {
353
+ const channelId = this.resolveChannel(input.channelId);
354
+ const fromMs = parseIsoDate(input.from, 'from');
355
+ const toMs = parseIsoDate(input.to, 'to');
356
+ const cm = this.cm;
357
+ let messageCountsAllTime = cm.getChannelMessageCounts();
358
+ let tokenStatsForRange = cm.getChannelTokenStats({ fromMs, toMs });
359
+ if (channelId) {
360
+ messageCountsAllTime = messageCountsAllTime.filter((c) => c.channelId === channelId);
361
+ tokenStatsForRange = {
362
+ // Totals stay whole-range (they're documented as store-wide-for-the-range,
363
+ // not per-channel); only the byChannel breakdown is narrowed. Labeling the
364
+ // field `byChannel` (rather than folding a channel-filtered total into
365
+ // totalMessages/totalTokensEstimate) keeps this from reading as a
366
+ // misleadingly-merged single-channel total.
367
+ totalMessages: tokenStatsForRange.totalMessages,
368
+ totalTokensEstimate: tokenStatsForRange.totalTokensEstimate,
369
+ byChannel: tokenStatsForRange.byChannel.filter((c) => c.channelId === channelId),
370
+ };
371
+ }
372
+ return {
373
+ success: true,
374
+ data: {
375
+ query: { from: input.from ?? null, to: input.to ?? null, channelId: input.channelId ?? null },
376
+ messageCountsAllTime,
377
+ tokenStatsForRange,
378
+ },
379
+ };
380
+ }
381
+ // ==========================================================================
382
+ // extract
383
+ // ==========================================================================
384
+ handleExtract(input) {
385
+ const channelId = this.resolveChannel(input.channelId);
386
+ const fromMs = parseIsoDate(input.from, 'from');
387
+ const toMs = parseIsoDate(input.to, 'to');
388
+ const limit = clampCount(input.limit, EXTRACT_DEFAULT_LIMIT, EXTRACT_MAX_LIMIT, 'limit');
389
+ const offset = clampCount(input.offset, 0, NATIVE_OFFSET_MAX, 'offset');
390
+ const format = input.format ?? 'text';
391
+ const cm = this.cm;
392
+ const result = cm.queryMessagesByTimeAndChannel({
393
+ fromMs,
394
+ toMs,
395
+ channelId,
396
+ limit,
397
+ offset,
398
+ });
399
+ return {
400
+ success: true,
401
+ data: {
402
+ matchedCount: result.matchedCount,
403
+ returned: result.messages.length,
404
+ messages: result.messages.map((msg) => projectMessage(msg, format)),
405
+ },
406
+ };
407
+ }
408
+ // ==========================================================================
409
+ // search
410
+ // ==========================================================================
411
+ async handleSearch(input) {
412
+ if (!input.query) {
413
+ throw new Error('search requires a non-empty "query".');
414
+ }
415
+ const channelId = this.resolveChannel(input.channelId);
416
+ const fromMs = parseIsoDate(input.from, 'from');
417
+ const toMs = parseIsoDate(input.to, 'to');
418
+ const limit = clampCount(input.limit, SEARCH_DEFAULT_LIMIT, SEARCH_MAX_LIMIT, 'limit');
419
+ const maxScan = clampCount(input.maxScan, SEARCH_DEFAULT_MAX_SCAN, SEARCH_MAX_MAX_SCAN, 'maxScan');
420
+ const caseSensitive = input.caseSensitive ?? false;
421
+ const flags = caseSensitive ? '' : 'i';
422
+ // Validate regex SYNTAX up front — an invalid pattern is a clean tool
423
+ // error, not a crash mid-scan. This does NOT bound match TIME (a
424
+ // syntactically valid pattern can still backtrack catastrophically),
425
+ // which is why regex-mode matching itself runs on a worker below rather
426
+ // than here.
427
+ if (input.regex) {
428
+ try {
429
+ new RegExp(input.query, flags);
430
+ }
431
+ catch (error) {
432
+ throw new Error(`Invalid regex "${input.query}": ${error instanceof Error ? error.message : String(error)}`);
433
+ }
434
+ }
435
+ const needle = caseSensitive ? input.query : input.query.toLowerCase();
436
+ const cm = this.cm;
437
+ // Fetch one more than maxScan so an oversized candidate window is
438
+ // detected up front (before any matching is attempted), rather than
439
+ // silently scanning maxScan candidates and returning as if that were the
440
+ // whole window. See the tool description's `truncated` contract.
441
+ const probe = cm.queryMessagesByTimeAndChannel({
442
+ fromMs,
443
+ toMs,
444
+ channelId,
445
+ limit: maxScan + 1,
446
+ offset: 0,
447
+ });
448
+ const truncated = probe.messages.length > maxScan;
449
+ const candidates = truncated ? probe.messages.slice(0, maxScan) : probe.messages;
450
+ if (input.regex) {
451
+ // Regex matching against caller-supplied patterns is ReDoS-shaped: a
452
+ // pathological pattern (e.g. `(a+)+$`) can take catastrophically long
453
+ // against one candidate string, and a synchronous RegExp.exec() on
454
+ // this thread would block the WHOLE framework's event loop — every
455
+ // agent's turns, health checks, timers — for as long as it runs, with
456
+ // no way to interrupt it from this same thread. Route matching
457
+ // through a worker thread instead, which can be forcibly terminated
458
+ // on a deadline. See search-regex-worker.ts's header for the full
459
+ // rationale.
460
+ const { matches, scanned } = await this.searchWithRegexWorker(candidates, input.query, flags, limit);
461
+ return { success: true, data: { scanned, candidatePoolSize: candidates.length, truncated, matches } };
462
+ }
463
+ // Plain substring search (String.prototype.indexOf) is inherently
464
+ // linear in input length — no ReDoS-equivalent risk — so it stays
465
+ // in-process. This is also the common case, so it pays no worker-spawn
466
+ // overhead.
467
+ const matches = [];
468
+ let scanned = 0;
469
+ for (const msg of candidates) {
470
+ // Check the limit BEFORE doing any work for this candidate — not
471
+ // after pushing a match — so limit:0 (a valid clampCount value: it's
472
+ // >= 0) correctly yields zero matches instead of one. Checking
473
+ // post-push would always let through the match that first reaches
474
+ // the limit. Same fix mirrored in search-regex-worker.ts's loop.
475
+ if (matches.length >= limit)
476
+ break;
477
+ scanned++;
478
+ const text = flattenContent(msg.content);
479
+ const hit = matchSubstring(text, needle, caseSensitive);
480
+ if (!hit)
481
+ continue;
482
+ matches.push({
483
+ id: String(msg.id),
484
+ timestamp: msg.timestamp.toISOString(),
485
+ participant: msg.participant,
486
+ channelId: getChannelId(msg) ?? null,
487
+ snippet: snippetAround(text, hit.index, hit.length),
488
+ });
489
+ }
490
+ return {
491
+ success: true,
492
+ data: { scanned, candidatePoolSize: candidates.length, truncated, matches },
493
+ };
494
+ }
495
+ /**
496
+ * Run regex matching for `search` on a worker thread with a hard
497
+ * wall-clock deadline, so a catastrophically-backtracking pattern can be
498
+ * forcibly killed instead of hanging the framework. One worker per call
499
+ * (not per candidate — spawn overhead would dominate at scale; not a
500
+ * persistent pool — a fresh worker per call means one bad pattern can
501
+ * never contaminate a later search). Always terminated on the way out,
502
+ * success or failure, so nothing lingers.
503
+ *
504
+ * On timeout or a worker-side error this THROWS (caught by
505
+ * handleToolCall's try/catch, same as every other error path in this
506
+ * module) rather than returning an empty match list — a timed-out search
507
+ * must never be indistinguishable from a clean "no matches" result.
508
+ */
509
+ async searchWithRegexWorker(candidates, pattern, flags, limit) {
510
+ const texts = candidates.map((msg) => flattenContent(msg.content));
511
+ let worker;
512
+ try {
513
+ const { matches: rawMatches, scanned } = await new Promise((resolve, reject) => {
514
+ worker = new Worker(SEARCH_WORKER_PATH, { workerData: { texts, pattern, flags, limit } });
515
+ const timer = setTimeout(() => {
516
+ reject(new Error(`search timed out after ${SEARCH_REGEX_TIMEOUT_MS}ms while matching regex "${pattern}" against ` +
517
+ `up to ${texts.length} candidate(s) — the pattern may be catastrophically slow (exponential ` +
518
+ 'backtracking) against this data; try a simpler pattern, a literal substring search ' +
519
+ '(regex:false), or a smaller maxScan.'));
520
+ }, SEARCH_REGEX_TIMEOUT_MS);
521
+ timer.unref?.();
522
+ worker.once('message', (msg) => {
523
+ clearTimeout(timer);
524
+ if (msg.type === 'error')
525
+ reject(new Error(msg.error));
526
+ else
527
+ resolve({ matches: msg.matches, scanned: msg.scanned });
528
+ });
529
+ worker.once('error', (err) => {
530
+ clearTimeout(timer);
531
+ reject(err);
532
+ });
533
+ });
534
+ const matches = rawMatches.map(({ candidateIndex, matchIndex, matchLength }) => {
535
+ const msg = candidates[candidateIndex];
536
+ const text = texts[candidateIndex];
537
+ return {
538
+ id: String(msg.id),
539
+ timestamp: msg.timestamp.toISOString(),
540
+ participant: msg.participant,
541
+ channelId: getChannelId(msg) ?? null,
542
+ snippet: snippetAround(text, matchIndex, matchLength),
543
+ };
544
+ });
545
+ return { matches, scanned };
546
+ }
547
+ finally {
548
+ // Always kill the worker — whether it finished, errored, or is still
549
+ // stuck mid-backtrack when the deadline hit. terminate() on an
550
+ // already-exited worker is a harmless no-op.
551
+ if (worker)
552
+ void worker.terminate().catch(() => { });
553
+ }
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
+ }
918
+ }
919
+ // ============================================================================
920
+ // Helpers
921
+ // ============================================================================
922
+ /** Parse an ISO 8601 date string to Unix ms. Throws a clear error (not a
923
+ * crash) on an unparseable string; returns undefined for an omitted field
924
+ * (open-ended bound). */
925
+ function parseIsoDate(value, field) {
926
+ if (value === undefined)
927
+ return undefined;
928
+ const ms = Date.parse(value);
929
+ if (Number.isNaN(ms)) {
930
+ throw new Error(`Invalid ISO 8601 date for "${field}": ${JSON.stringify(value)}`);
931
+ }
932
+ return ms;
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
+ }
946
+ /**
947
+ * Validate a pagination-ish numeric input (limit/offset/maxScan) and clamp
948
+ * it to an upper bound. `Math.min(value, max)` alone is NOT sufficient here:
949
+ * it only enforces an upper bound, so `Math.min(-1, 200)` is `-1`, not a
950
+ * sane value — a negative limit/offset/maxScan would sail straight past the
951
+ * "hard cap" and reach a native chronicle call expecting an unsigned
952
+ * pagination argument (observed: `extract({limit:-1})` returning every
953
+ * message in the store instead of being capped). Rejects (rather than
954
+ * silently clamping) anything that isn't a finite non-negative integer, so
955
+ * a caller mistake is surfaced as a clean tool error instead of silently
956
+ * doing something other than what was asked.
957
+ */
958
+ function clampCount(value, def, max, field) {
959
+ if (value === undefined)
960
+ return def;
961
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
962
+ throw new Error(`"${field}" must be a finite number, got ${JSON.stringify(value)}.`);
963
+ }
964
+ if (!Number.isInteger(value)) {
965
+ throw new Error(`"${field}" must be an integer, got ${value}.`);
966
+ }
967
+ if (value < 0) {
968
+ throw new Error(`"${field}" must be >= 0, got ${value}.`);
969
+ }
970
+ return Math.min(value, max);
971
+ }
972
+ /** channelId lives at metadata.external.channelId — same path context-manager's
973
+ * own native index reads from (message-store.ts's CHANNEL_FIELD). Metadata's
974
+ * index-signature type means this needs an explicit narrow, same as
975
+ * MessageStore.getChannelTokenStats does internally. */
976
+ /**
977
+ * Two channel-id metadata shapes exist in the wild: `metadata.channelId`
978
+ * (what agent-framework's real MCPL ingestion — `handleMcplChannelIncoming`
979
+ * — actually writes) and `metadata.external.channelId` (an older
980
+ * convention). context-manager 0.9.1+ indexes and queries BOTH (see
981
+ * MessageStore.extractChannelId there), so a channel-filtered result can
982
+ * come from either shape — check the same order here so a displayed
983
+ * `channelId` never reads `null` on a message that was, in fact, matched by
984
+ * channel.
985
+ */
986
+ function getChannelId(msg) {
987
+ const metadata = msg.metadata;
988
+ if (typeof metadata?.channelId === 'string')
989
+ return metadata.channelId;
990
+ return typeof metadata?.external?.channelId === 'string' ? metadata.external.channelId : undefined;
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
+ }
1087
+ function projectMessage(msg, format) {
1088
+ return {
1089
+ id: String(msg.id),
1090
+ timestamp: msg.timestamp.toISOString(),
1091
+ participant: msg.participant,
1092
+ channelId: getChannelId(msg) ?? null,
1093
+ content: format === 'raw' ? msg.content : flattenContent(msg.content),
1094
+ };
1095
+ }
1096
+ /** Flatten a message's content blocks to one short, readable string. Text
1097
+ * blocks verbatim; everything else (tool_use/tool_result/thinking/media) as
1098
+ * a short bracketed label — this is for agent readability and search
1099
+ * matching, not byte-faithful reconstruction. */
1100
+ function flattenContent(content) {
1101
+ return content.map(blockLabel).join(' ').trim();
1102
+ }
1103
+ function blockLabel(block) {
1104
+ switch (block.type) {
1105
+ case 'text':
1106
+ return block.text;
1107
+ case 'tool_use':
1108
+ return `[tool_use: ${block.name}]`;
1109
+ case 'tool_result':
1110
+ return block.toolName ? `[tool_result: ${block.toolName}]` : '[tool_result]';
1111
+ case 'thinking':
1112
+ case 'redacted_thinking':
1113
+ return '[thinking]';
1114
+ case 'image':
1115
+ return '[image]';
1116
+ case 'generated_image':
1117
+ return '[image]';
1118
+ case 'document':
1119
+ return '[document]';
1120
+ case 'audio':
1121
+ return '[audio]';
1122
+ case 'video':
1123
+ return '[video]';
1124
+ default:
1125
+ // Exhaustiveness guard: a future ContentBlock variant falls back to a
1126
+ // generic label instead of a compile error at a call site far from here.
1127
+ return `[${block.type}]`;
1128
+ }
1129
+ }
1130
+ function matchSubstring(text, needle, caseSensitive) {
1131
+ const haystack = caseSensitive ? text : text.toLowerCase();
1132
+ const index = haystack.indexOf(needle);
1133
+ if (index === -1)
1134
+ return null;
1135
+ return { index, length: needle.length };
1136
+ }
1137
+ /** ~SNIPPET_CONTEXT_CHARS of surrounding context on each side of a match, or
1138
+ * the first ~SNIPPET_FALLBACK_CHARS of content when no meaningful match
1139
+ * position is given (kept simple on purpose — this is a readability aid,
1140
+ * not a highlighting engine). */
1141
+ function snippetAround(text, index, length) {
1142
+ if (index < 0)
1143
+ return text.slice(0, SNIPPET_FALLBACK_CHARS);
1144
+ const start = Math.max(0, index - SNIPPET_CONTEXT_CHARS);
1145
+ const end = Math.min(text.length, index + length + SNIPPET_CONTEXT_CHARS);
1146
+ const prefix = start > 0 ? '…' : '';
1147
+ const suffix = end < text.length ? '…' : '';
1148
+ return prefix + text.slice(start, end) + suffix;
1149
+ }
1150
+ //# sourceMappingURL=index.js.map