@animalabs/agent-framework 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +55 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +156 -0
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +15 -0
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/framework.d.ts +423 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1857 -75
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +16 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -3
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +172 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +444 -2
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +9 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +71 -13
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +89 -13
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +655 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/operator-log.d.ts +78 -0
- package/dist/src/operator-log.d.ts.map +1 -0
- package/dist/src/operator-log.js +102 -0
- package/dist/src/operator-log.js.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +20 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/types/framework.d.ts +56 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +36 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +199 -1
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/api-admin-gate.test.d.ts +2 -0
- package/dist/test/api-admin-gate.test.d.ts.map +1 -0
- package/dist/test/api-admin-gate.test.js +116 -0
- package/dist/test/api-admin-gate.test.js.map +1 -0
- package/dist/test/channel-label-history.test.d.ts +2 -0
- package/dist/test/channel-label-history.test.d.ts.map +1 -0
- package/dist/test/channel-label-history.test.js +424 -0
- package/dist/test/channel-label-history.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +16 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/event-gate.test.js +44 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
- package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
- package/dist/test/helpers/quiesce-crash-child.js +156 -0
- package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
- package/dist/test/history-module-overview.test.d.ts +2 -0
- package/dist/test/history-module-overview.test.d.ts.map +1 -0
- package/dist/test/history-module-overview.test.js +711 -0
- package/dist/test/history-module-overview.test.js.map +1 -0
- package/dist/test/history-module.test.js +146 -0
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/host-quiesce.test.d.ts +2 -0
- package/dist/test/host-quiesce.test.d.ts.map +1 -0
- package/dist/test/host-quiesce.test.js +863 -0
- package/dist/test/host-quiesce.test.js.map +1 -0
- package/dist/test/liveness-watchdog.test.js +18 -2
- package/dist/test/liveness-watchdog.test.js.map +1 -1
- package/dist/test/operator-surgery.test.d.ts +2 -0
- package/dist/test/operator-surgery.test.d.ts.map +1 -0
- package/dist/test/operator-surgery.test.js +398 -0
- package/dist/test/operator-surgery.test.js.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +145 -0
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/puppet-tool-call.test.js +5 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +69 -0
- package/src/api/server.ts +159 -2
- package/src/api/types.ts +15 -0
- package/src/framework.ts +2155 -96
- package/src/gate/event-gate.ts +47 -4
- package/src/index.ts +5 -1
- package/src/mcpl/channel-registry.ts +495 -2
- package/src/mcpl/server-connection.ts +73 -13
- package/src/modules/history/index.ts +689 -25
- package/src/operator-log.ts +132 -0
- package/src/recovery/discord-awareness-outbox.ts +19 -0
- package/src/types/framework.ts +63 -0
- 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
|
-
*
|
|
13
|
-
* - `stats`
|
|
14
|
-
*
|
|
15
|
-
* - `extract`
|
|
16
|
-
* - `search`
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
125
|
-
* ContextManager.open() so the
|
|
126
|
-
* queries — ModuleContext itself
|
|
127
|
-
* reference, only the narrower message
|
|
128
|
-
* queryMessages), which can't do
|
|
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
|
|
232
|
-
//
|
|
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 (
|
|
259
|
-
messageCountsAllTime = messageCountsAllTime.filter((c) => c.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 ===
|
|
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
|
|
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
|
|
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),
|