@animalabs/agent-framework 0.19.0 → 0.20.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 +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +153 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1169 -76
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +59 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +106 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +69 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1201 -80
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +115 -7
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
import { Worker } from 'node:worker_threads';
|
|
42
42
|
import { fileURLToPath } from 'node:url';
|
|
43
43
|
import { dirname, join } from 'node:path';
|
|
44
|
+
import { SemanticIndexClient, SemanticIndexer, messageIndexText } from './semantic.js';
|
|
44
45
|
// ============================================================================
|
|
45
46
|
// Limits
|
|
46
47
|
// ============================================================================
|
|
@@ -59,6 +60,36 @@ const EXTRACT_MAX_LIMIT = 200;
|
|
|
59
60
|
* reach, i.e. a correctly-empty page, rather than wrapping onto a wrong one.
|
|
60
61
|
*/
|
|
61
62
|
const NATIVE_OFFSET_MAX = 0xffffffff; // 4294967295
|
|
63
|
+
/**
|
|
64
|
+
* `extract` with an author filter can't lean on a native index (chronicle
|
|
65
|
+
* indexes time and channel, not author), so it pages through the time/channel
|
|
66
|
+
* window and filters in-process. Bounded like `search`'s maxScan, with the
|
|
67
|
+
* same explicit truncated/scannedThrough signal when the window is larger.
|
|
68
|
+
*/
|
|
69
|
+
const EXTRACT_FILTER_DEFAULT_MAX_SCAN = 10000;
|
|
70
|
+
const EXTRACT_FILTER_MAX_MAX_SCAN = 50000;
|
|
71
|
+
/** How far (in window positions) a filtered-extract resume looks for its
|
|
72
|
+
* `afterId` when the window moved since the previous call. */
|
|
73
|
+
const RELOCATE_RADIUS = 1000;
|
|
74
|
+
/** Most appends a no-channel resume walks back through to check for
|
|
75
|
+
* messages added behind its cursor; past it the check is reported as
|
|
76
|
+
* unverified rather than failing. */
|
|
77
|
+
const APPENDED_SCAN_CAP = 200_000;
|
|
78
|
+
/** Native page size for the in-process filtered scans above. */
|
|
79
|
+
const FILTER_SCAN_PAGE = 1000;
|
|
80
|
+
/** `extract({aroundId})` window sizes, per side. */
|
|
81
|
+
const AROUND_DEFAULT = 10;
|
|
82
|
+
const AROUND_MAX = 100;
|
|
83
|
+
/** Upper bound on messages sharing the anchor's millisecond that
|
|
84
|
+
* `extract({aroundId})` will fetch to order them by sequence — real stores
|
|
85
|
+
* see a handful; past this the call fails loudly rather than guessing. */
|
|
86
|
+
const AROUND_TIE_CAP = 5000;
|
|
87
|
+
function tieCapError(anchorId, n) {
|
|
88
|
+
return new Error(`Message ${anchorId} shares its timestamp with ${n} messages (more than ${AROUND_TIE_CAP}), too many to order ` +
|
|
89
|
+
'around it. Use extract with from/to set to that instant instead.');
|
|
90
|
+
}
|
|
91
|
+
/** Largest valid Date value (ms) — an open newest edge. */
|
|
92
|
+
const MAX_DATE_MS = 8.64e15;
|
|
62
93
|
const SEARCH_DEFAULT_LIMIT = 20;
|
|
63
94
|
const SEARCH_MAX_LIMIT = 100;
|
|
64
95
|
const SEARCH_DEFAULT_MAX_SCAN = 5000;
|
|
@@ -74,6 +105,15 @@ const SEARCH_MAX_MAX_SCAN = 50000;
|
|
|
74
105
|
*/
|
|
75
106
|
const OVERVIEW_DEFAULT_LIMIT = 50;
|
|
76
107
|
const OVERVIEW_MAX_LIMIT = 200;
|
|
108
|
+
const SEMANTIC_DEFAULT_LIMIT = 10;
|
|
109
|
+
const SEMANTIC_MAX_LIMIT = 50;
|
|
110
|
+
/** Largest k sent to the embed-service when over-fetching past dropped hits (its own cap is 200). */
|
|
111
|
+
const SEMANTIC_MAX_FETCH = 200;
|
|
112
|
+
/** Local snippet: the leading SEMANTIC_SNIPPET_CHARS of the current text. */
|
|
113
|
+
function snippetOf(text) {
|
|
114
|
+
return text.length <= SEMANTIC_SNIPPET_CHARS ? text : `${text.slice(0, SEMANTIC_SNIPPET_CHARS)}…`;
|
|
115
|
+
}
|
|
116
|
+
const SEMANTIC_SNIPPET_CHARS = 400;
|
|
77
117
|
/**
|
|
78
118
|
* A `<@id>`/`<@!id>` Discord-style mention — same shape `ChannelRegistry`
|
|
79
119
|
* uses for its own DM mention-form matching. A string matching this can
|
|
@@ -101,11 +141,34 @@ const SEARCH_REGEX_TIMEOUT_MS = 2000;
|
|
|
101
141
|
* way gate-script.ts locates gate-script-worker.js, so it tracks whatever
|
|
102
142
|
* directory this module's own compiled output lives in. */
|
|
103
143
|
const SEARCH_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'search-regex-worker.js');
|
|
144
|
+
// Declared as an array (tool-schema dialects vary on anyOf); a bare string is
|
|
145
|
+
// also accepted at runtime — see toAuthorSet.
|
|
146
|
+
const AUTHOR_SPEC_JSON_SCHEMA = { type: 'array', items: { type: 'string' } };
|
|
147
|
+
const AUTHOR_SCHEMA = {
|
|
148
|
+
...AUTHOR_SPEC_JSON_SCHEMA,
|
|
149
|
+
description: 'Only messages written by one of these authors (an array of names or ids). Matches, case-insensitively and exactly, ' +
|
|
150
|
+
'the author\'s display name or user id (a leading "@" or a <@id> mention is accepted), or the ' +
|
|
151
|
+
'stored participant for messages without author metadata — e.g. your own turns, under your name.',
|
|
152
|
+
};
|
|
153
|
+
const EXCLUDE_AUTHOR_SCHEMA = {
|
|
154
|
+
...AUTHOR_SPEC_JSON_SCHEMA,
|
|
155
|
+
description: 'Drop messages written by any of these authors (an array of names or ids). Same matching as `author`.',
|
|
156
|
+
};
|
|
104
157
|
export class HistoryModule {
|
|
105
158
|
name = 'history';
|
|
106
159
|
ctx = null;
|
|
107
160
|
cm = null;
|
|
108
161
|
channelRegistry = null;
|
|
162
|
+
semanticCfg;
|
|
163
|
+
semanticClient = null;
|
|
164
|
+
indexer = null;
|
|
165
|
+
syncTimer = null;
|
|
166
|
+
firstSyncTimer = null;
|
|
167
|
+
constructor(options = {}) {
|
|
168
|
+
this.semanticCfg = options.semantic ?? null;
|
|
169
|
+
if (this.semanticCfg)
|
|
170
|
+
this.semanticClient = new SemanticIndexClient(this.semanticCfg);
|
|
171
|
+
}
|
|
109
172
|
/**
|
|
110
173
|
* Wire the context-manager instance, and optionally the host's
|
|
111
174
|
* ChannelRegistry. Host calls this after ContextManager.open() so the
|
|
@@ -122,6 +185,17 @@ export class HistoryModule {
|
|
|
122
185
|
bind(contextManager, channelRegistry) {
|
|
123
186
|
this.cm = contextManager;
|
|
124
187
|
this.channelRegistry = channelRegistry ?? null;
|
|
188
|
+
if (this.semanticCfg && this.semanticClient) {
|
|
189
|
+
this.indexer?.dispose();
|
|
190
|
+
this.indexer = new SemanticIndexer(contextManager, this.semanticClient, this.semanticCfg, (m) => console.warn(m));
|
|
191
|
+
// start() may already have run (framework start before host bind):
|
|
192
|
+
// wire persistence now, or pending changes are neither restored nor saved.
|
|
193
|
+
if (this.ctx)
|
|
194
|
+
this.wireIndexer(this.ctx);
|
|
195
|
+
else
|
|
196
|
+
this.indexer.attach();
|
|
197
|
+
this.startSyncTimer();
|
|
198
|
+
}
|
|
125
199
|
}
|
|
126
200
|
/**
|
|
127
201
|
* Resolve an agent-supplied channel spec — a label like `#general` or an
|
|
@@ -196,11 +270,241 @@ export class HistoryModule {
|
|
|
196
270
|
const cm = this.cm;
|
|
197
271
|
return cm.queryMessagesByTime({ fromMs: ms, toMs: ms }).messages;
|
|
198
272
|
}
|
|
273
|
+
/** Messages at exactly `ms` (in `channelId` when given). Bounded by
|
|
274
|
+
* AROUND_TIE_CAP: past it a same-millisecond group is too large to step
|
|
275
|
+
* through by sequence, and the caller should narrow instead. */
|
|
276
|
+
countAtMs(ms, channelId) {
|
|
277
|
+
const cm = this.cm;
|
|
278
|
+
const n = channelId !== undefined
|
|
279
|
+
? cm.queryMessagesByTimeAndChannel({ fromMs: ms, toMs: ms, channelId, limit: 0 }).matchedCount
|
|
280
|
+
: cm.queryMessagesByTime({ fromMs: ms, toMs: ms, limit: AROUND_TIE_CAP + 1 }).messages.length;
|
|
281
|
+
if (n > AROUND_TIE_CAP) {
|
|
282
|
+
throw new Error(`More than ${AROUND_TIE_CAP} messages share the instant ${new Date(ms).toISOString()}; a continuation can't step ` +
|
|
283
|
+
'through them. Narrow the search with channelId/author instead.');
|
|
284
|
+
}
|
|
285
|
+
return n;
|
|
286
|
+
}
|
|
287
|
+
/** Highest sequence in the store right now (the last append), or -1 when empty. */
|
|
288
|
+
storeSeqMark() {
|
|
289
|
+
const cm = this.cm;
|
|
290
|
+
const count = cm.getMessageCount();
|
|
291
|
+
if (count === 0)
|
|
292
|
+
return -1;
|
|
293
|
+
return cm.getMessageWindow(count - 1, 1).messages[0]?.sequence ?? -1;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Of the messages appended since `seqMark` (the store's append tail above
|
|
297
|
+
* it, walked back in pages), the ones `relevant` keeps. Unrelated appends
|
|
298
|
+
* cost a look but never count. `complete` is false when the walk stopped
|
|
299
|
+
* at APPENDED_SCAN_CAP before reaching seqMark — the result is then a
|
|
300
|
+
* lower bound, not a verification.
|
|
301
|
+
*/
|
|
302
|
+
appendedSince(seqMark, relevant) {
|
|
303
|
+
const cm = this.cm;
|
|
304
|
+
const out = [];
|
|
305
|
+
let looked = 0;
|
|
306
|
+
let end = cm.getMessageCount();
|
|
307
|
+
while (end > 0) {
|
|
308
|
+
const start = Math.max(0, end - 1000);
|
|
309
|
+
const w = cm.getMessageWindow(start, end - start).messages;
|
|
310
|
+
for (let i = w.length - 1; i >= 0; i--) {
|
|
311
|
+
if (w[i].sequence <= seqMark)
|
|
312
|
+
return { messages: out, complete: true };
|
|
313
|
+
if (relevant(w[i]))
|
|
314
|
+
out.push(w[i]);
|
|
315
|
+
if (++looked >= APPENDED_SCAN_CAP)
|
|
316
|
+
return { messages: out, complete: false };
|
|
317
|
+
}
|
|
318
|
+
end = start;
|
|
319
|
+
}
|
|
320
|
+
return { messages: out, complete: true };
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Validate a filtered-extract resume position against the window as it is
|
|
324
|
+
* NOW. `afterId` names the message the previous call saw at position
|
|
325
|
+
* `windowOffset - 1`. If it is still there, resume as asked. If the window
|
|
326
|
+
* moved (a removal or an insertion before that point since the previous
|
|
327
|
+
* call), look for it within RELOCATE_RADIUS positions either side and
|
|
328
|
+
* resume just past it, reporting the shift. If it can't be found — it was
|
|
329
|
+
* itself removed, or the window moved further than that — fail loudly:
|
|
330
|
+
* guessing is how a message gets silently skipped.
|
|
331
|
+
*/
|
|
332
|
+
relocateWindowOffset(windowOffset, afterId, q) {
|
|
333
|
+
if (windowOffset === 0) {
|
|
334
|
+
if (afterId != null)
|
|
335
|
+
throw new Error('"afterId" only applies together with a windowOffset > 0.');
|
|
336
|
+
return { windowOffset, shift: 0 };
|
|
337
|
+
}
|
|
338
|
+
if (afterId == null) {
|
|
339
|
+
throw new Error('"windowOffset" needs the "afterId" from the same `resume` object — without it a message added or removed ' +
|
|
340
|
+
'since the previous call would be silently skipped. Pass the whole `resume` object back.');
|
|
341
|
+
}
|
|
342
|
+
const cm = this.cm;
|
|
343
|
+
const at = cm.queryMessagesByTimeAndChannel({ ...q, limit: 1, offset: windowOffset - 1 }).messages[0];
|
|
344
|
+
if (at && String(at.id) === afterId)
|
|
345
|
+
return { windowOffset, shift: 0 };
|
|
346
|
+
const lo = Math.max(0, windowOffset - 1 - RELOCATE_RADIUS);
|
|
347
|
+
const around = cm.queryMessagesByTimeAndChannel({ ...q, limit: 2 * RELOCATE_RADIUS + 1, offset: lo }).messages;
|
|
348
|
+
const i = around.findIndex((m) => String(m.id) === afterId);
|
|
349
|
+
if (i === -1) {
|
|
350
|
+
throw new Error(`The window changed since the previous call: message ${afterId}, where that scan stopped, is no longer ` +
|
|
351
|
+
`within ${RELOCATE_RADIUS} positions of windowOffset ${windowOffset} (it may have been deleted). ` +
|
|
352
|
+
'Restart the scan without windowOffset/afterId, or narrow it with from/to.');
|
|
353
|
+
}
|
|
354
|
+
const relocated = lo + i + 1;
|
|
355
|
+
return { windowOffset: relocated, shift: relocated - windowOffset };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The `n` messages of a time/channel range nearest one of its edges, in
|
|
359
|
+
* TIMESTAMP order walking away from that edge (newest-first for
|
|
360
|
+
* side:"newest", oldest-first for side:"oldest"), plus whether the range
|
|
361
|
+
* holds more than `n`.
|
|
362
|
+
*
|
|
363
|
+
* Why not just `queryMessagesByTimeAndChannel` + offset: (a) a time-only
|
|
364
|
+
* query's `matchedCount` is the returned PAGE size, not a total (see
|
|
365
|
+
* context-manager MessageStore.queryByTime), so it can't locate a tail;
|
|
366
|
+
* (b) channel-scoped results come back in APPEND (ordinal) order, and
|
|
367
|
+
* Discord catch-up appends old-timestamped messages late — an ordinal
|
|
368
|
+
* tail is not the timestamp tail.
|
|
369
|
+
*
|
|
370
|
+
* No channel: chronicle's timestamp index answers directly (`reverse`
|
|
371
|
+
* for the newest edge). With a channel: channel queries report an exact
|
|
372
|
+
* matchedCount, so grow a time window out from the edge (×4 per step)
|
|
373
|
+
* until it holds ≥ n channel messages or covers the whole range. If that
|
|
374
|
+
* window holds more than `fetchCap` (a density jump across one step, or a
|
|
375
|
+
* burst), bisect its far bound between the last step that held < n and
|
|
376
|
+
* this one — counts are exact, so it lands on a window of [n, fetchCap].
|
|
377
|
+
* Only when a single millisecond holds the overflow does bisection bottom
|
|
378
|
+
* out; then the window is everything strictly nearer the edge plus that
|
|
379
|
+
* millisecond's nearest messages by sequence, which within one millisecond
|
|
380
|
+
* IS the (timestamp, sequence) order — still exact. The newest side is
|
|
381
|
+
* left open when `toMs` is (clock-skewed future stamps stay visible).
|
|
382
|
+
*/
|
|
383
|
+
edgeWindow(opts) {
|
|
384
|
+
const cm = this.cm;
|
|
385
|
+
const { fromMs, toMs, channelId, n, side } = opts;
|
|
386
|
+
const newest = side === 'newest';
|
|
387
|
+
const cmp = (a, b) => a.timestamp.getTime() - b.timestamp.getTime() || a.sequence - b.sequence;
|
|
388
|
+
const dirSort = (ms) => ms.sort(newest ? (a, b) => cmp(b, a) : cmp);
|
|
389
|
+
if (channelId === undefined) {
|
|
390
|
+
const r = cm.queryMessagesByTime({ fromMs, toMs, limit: n + 1, reverse: newest });
|
|
391
|
+
return { messages: r.messages.slice(0, n), more: r.messages.length > n };
|
|
392
|
+
}
|
|
393
|
+
const count = (f, t) => cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit: 0 }).matchedCount;
|
|
394
|
+
const fetch = (f, t, limit, offset = 0) => limit <= 0 ? [] : cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit, offset }).messages;
|
|
395
|
+
const total = count(fromMs, toMs);
|
|
396
|
+
if (n === 0)
|
|
397
|
+
return { messages: [], more: total > 0 };
|
|
398
|
+
if (total <= n)
|
|
399
|
+
return { messages: dirSort(fetch(fromMs, toMs, total)), more: false };
|
|
400
|
+
// The window is parameterised by its FAR bound `e`: [e, toMs] for the
|
|
401
|
+
// newest side, [fromMs, e] for the oldest; its count is monotone in e.
|
|
402
|
+
const win = (e) => (newest ? [e, toMs] : [fromMs, e]);
|
|
403
|
+
const cnt = (e) => count(...win(e));
|
|
404
|
+
const lo = fromMs ?? this.earliestMessageMs(toMs) ?? 0;
|
|
405
|
+
const hi = toMs ?? Date.now(); // growth anchor only — never a bound
|
|
406
|
+
// `outside`: a far bound whose window holds < n (initially empty).
|
|
407
|
+
// `inside`: one whose window holds ≥ n (initially the whole range).
|
|
408
|
+
let outside = null;
|
|
409
|
+
let inside = newest ? lo : (toMs ?? MAX_DATE_MS);
|
|
410
|
+
let inCount = total;
|
|
411
|
+
for (let width = 10 * 60_000;; width *= 4) {
|
|
412
|
+
const e = newest ? hi - width : lo + width;
|
|
413
|
+
if (newest ? e <= lo : e >= hi)
|
|
414
|
+
break; // covered: keep the whole range
|
|
415
|
+
const c = cnt(e);
|
|
416
|
+
if (c >= n) {
|
|
417
|
+
inside = e;
|
|
418
|
+
inCount = c;
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
outside = e;
|
|
422
|
+
}
|
|
423
|
+
const fetchCap = Math.max(4 * n, n + 1000);
|
|
424
|
+
if (inCount <= fetchCap) {
|
|
425
|
+
return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true };
|
|
426
|
+
}
|
|
427
|
+
// Overshoot: bisect the far bound. The empty side starts one past the
|
|
428
|
+
// range's near end (newest: past toMs / any date; oldest: before lo).
|
|
429
|
+
const emptyOut = newest ? (toMs ?? MAX_DATE_MS) + 1 : lo - 1;
|
|
430
|
+
let out = outside ?? emptyOut;
|
|
431
|
+
while (Math.abs(inside - out) > 1) {
|
|
432
|
+
const mid = Math.floor((inside + out) / 2);
|
|
433
|
+
const c = cnt(mid);
|
|
434
|
+
if (c < n) {
|
|
435
|
+
out = mid;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
inside = mid;
|
|
439
|
+
inCount = c;
|
|
440
|
+
if (c <= fetchCap) {
|
|
441
|
+
return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Millisecond `inside` alone carries the overflow. Everything nearer the
|
|
446
|
+
// edge (the `out` window, < n messages) plus the nearest-by-sequence
|
|
447
|
+
// messages of that millisecond (channel pages are append = sequence
|
|
448
|
+
// ordered, which is the tie order within one millisecond).
|
|
449
|
+
const near = out === emptyOut ? [] : fetch(...win(out), cnt(out));
|
|
450
|
+
const need = n - near.length;
|
|
451
|
+
const ties = count(inside, inside);
|
|
452
|
+
const edgeMs = fetch(inside, inside, need, newest ? Math.max(0, ties - need) : 0);
|
|
453
|
+
return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true };
|
|
454
|
+
}
|
|
199
455
|
async start(ctx) {
|
|
200
456
|
this.ctx = ctx;
|
|
457
|
+
this.wireIndexer(ctx);
|
|
458
|
+
this.startSyncTimer();
|
|
459
|
+
}
|
|
460
|
+
/** Pending edits/removals survive a restart through module state. Called from whichever of start()/bind() runs second. */
|
|
461
|
+
wireIndexer(ctx) {
|
|
462
|
+
if (!this.indexer)
|
|
463
|
+
return;
|
|
464
|
+
this.indexer.restorePending(ctx.getState()?.semanticPending);
|
|
465
|
+
this.indexer.onPendingChange = (p) => {
|
|
466
|
+
const prev = this.ctx?.getState() ?? {};
|
|
467
|
+
this.ctx?.setState({ ...prev, semanticPending: p });
|
|
468
|
+
};
|
|
469
|
+
this.indexer.attach();
|
|
201
470
|
}
|
|
202
471
|
async stop() {
|
|
203
472
|
this.ctx = null;
|
|
473
|
+
if (this.syncTimer) {
|
|
474
|
+
clearInterval(this.syncTimer);
|
|
475
|
+
this.syncTimer = null;
|
|
476
|
+
}
|
|
477
|
+
if (this.firstSyncTimer) {
|
|
478
|
+
clearTimeout(this.firstSyncTimer);
|
|
479
|
+
this.firstSyncTimer = null;
|
|
480
|
+
}
|
|
481
|
+
if (this.indexer)
|
|
482
|
+
this.indexer.onPendingChange = null;
|
|
483
|
+
this.indexer?.dispose();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Background sync into the semantic index: every `syncIntervalMs` (default
|
|
487
|
+
* 60 s) push up to `maxSyncPerTick` new items. Idempotent to call — starts
|
|
488
|
+
* once we have both a context-manager (bind) and a config; `unref`'d so it
|
|
489
|
+
* never keeps a shutting-down process alive. A first tick runs after 5 s so
|
|
490
|
+
* a fresh store starts backfilling immediately rather than a minute later.
|
|
491
|
+
*/
|
|
492
|
+
startSyncTimer() {
|
|
493
|
+
if (this.syncTimer || !this.indexer || !this.semanticCfg)
|
|
494
|
+
return;
|
|
495
|
+
const interval = this.semanticCfg.syncIntervalMs ?? 60_000;
|
|
496
|
+
if (interval <= 0)
|
|
497
|
+
return;
|
|
498
|
+
const perTick = this.semanticCfg.maxSyncPerTick ?? 1024;
|
|
499
|
+
const tick = () => { void this.indexer?.catchUp(perTick); };
|
|
500
|
+
this.firstSyncTimer = setTimeout(() => { this.firstSyncTimer = null; tick(); }, 5_000);
|
|
501
|
+
this.firstSyncTimer.unref?.();
|
|
502
|
+
this.syncTimer = setInterval(tick, interval);
|
|
503
|
+
this.syncTimer.unref?.();
|
|
504
|
+
}
|
|
505
|
+
/** Exposed for hosts/tests: one sync pass now. */
|
|
506
|
+
syncSemanticIndex(maxItems = 1024) {
|
|
507
|
+
return this.indexer ? this.indexer.catchUp(maxItems) : null;
|
|
204
508
|
}
|
|
205
509
|
getTools() {
|
|
206
510
|
return [
|
|
@@ -225,15 +529,33 @@ export class HistoryModule {
|
|
|
225
529
|
description: 'Fetch raw, uncompressed messages for a time range and/or channel, paginated oldest-first. ' +
|
|
226
530
|
'Use after `stats` to pull the actual content. format:"text" flattens each message to a short ' +
|
|
227
531
|
'readable string (tool_use/tool_result/thinking/images rendered as bracketed labels); ' +
|
|
228
|
-
'format:"raw" returns the content blocks unmodified.'
|
|
532
|
+
'format:"raw" returns the content blocks unmodified. ' +
|
|
533
|
+
'Two extra modes: (1) aroundId — pass a message id (e.g. from `search`) to get the conversation ' +
|
|
534
|
+
'around it: `before` messages before it, the message itself (anchor:true), and `after` messages ' +
|
|
535
|
+
'after it, in the anchor\'s own channel unless channelId/allChannels says otherwise; from/to/offset/limit ' +
|
|
536
|
+
'do not apply in this mode (at most before+after+1 messages). (2) author/excludeAuthor — keep only (or drop) messages by these ' +
|
|
537
|
+
'authors; this is filtered in-process over at most maxScan messages of the window, so a response ' +
|
|
538
|
+
'may report truncated:true with a `resume` object ({windowOffset, offset, afterId}) — repeat the call with ' +
|
|
539
|
+
'those fields added to continue exactly where it stopped. If messages were added or removed in the window ' +
|
|
540
|
+
'meanwhile, the resume re-anchors on afterId and says so (windowChanged), or fails loudly when it can\'t.',
|
|
229
541
|
inputSchema: {
|
|
230
542
|
type: 'object',
|
|
231
543
|
properties: {
|
|
232
544
|
from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
|
|
233
545
|
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
234
546
|
channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
|
|
547
|
+
author: AUTHOR_SCHEMA,
|
|
548
|
+
excludeAuthor: EXCLUDE_AUTHOR_SCHEMA,
|
|
549
|
+
aroundId: { type: 'string', description: 'Message id to center on (as returned by search/extract; a "msg:<id>" hit id works too). Returns the surrounding conversation instead of a range.' },
|
|
550
|
+
before: { type: 'number', description: `With aroundId: messages to include before the anchor (default ${AROUND_DEFAULT}, cap ${AROUND_MAX}).` },
|
|
551
|
+
after: { type: 'number', description: `With aroundId: messages to include after the anchor (default ${AROUND_DEFAULT}, cap ${AROUND_MAX}).` },
|
|
552
|
+
allChannels: { type: 'boolean', description: 'With aroundId: interleave every channel around the anchor\'s time instead of staying in its channel (default false).' },
|
|
235
553
|
limit: { type: 'number', description: `Max messages to return (default ${EXTRACT_DEFAULT_LIMIT}, hard cap ${EXTRACT_MAX_LIMIT}). Must be a non-negative integer.` },
|
|
236
554
|
offset: { type: 'number', description: `Number of matching messages to skip (default 0, capped to ${NATIVE_OFFSET_MAX}). Must be a non-negative integer.` },
|
|
555
|
+
maxScan: { type: 'number', description: `With author/excludeAuthor: max window messages to examine (default ${EXTRACT_FILTER_DEFAULT_MAX_SCAN}, hard cap ${EXTRACT_FILTER_MAX_MAX_SCAN}).` },
|
|
556
|
+
windowOffset: { type: 'number', description: 'With author/excludeAuthor: resume a truncated scan at this position of the window — copy it from the previous response\'s `resume`.' },
|
|
557
|
+
seqMark: { type: 'number', description: 'With windowOffset and no channelId: copy it from `resume`. Lets a resume find messages added behind it since the previous call.' },
|
|
558
|
+
afterId: { type: 'string', description: 'With windowOffset: id of the last message the previous call scanned — copy it from `resume`. Lets a resume notice messages added or removed since, instead of silently skipping one.' },
|
|
237
559
|
format: { type: 'string', enum: ['text', 'raw'], description: 'Content rendering (default "text").' },
|
|
238
560
|
},
|
|
239
561
|
},
|
|
@@ -244,7 +566,14 @@ export class HistoryModule {
|
|
|
244
566
|
'range/channel window. Narrows candidates via the same query as `extract` before matching, up ' +
|
|
245
567
|
'to maxScan candidates — if the narrowed window is larger than maxScan, the response reports ' +
|
|
246
568
|
'truncated:true up front rather than silently missing later matches; narrow the filter or raise ' +
|
|
247
|
-
'maxScan and retry.
|
|
569
|
+
'maxScan and retry. When the scan stops early (truncated, or `limit` matches reached) the response ' +
|
|
570
|
+
'carries a `resume` object ({from|to, skipSequences}): repeat the call with its fields to continue ' +
|
|
571
|
+
'(scannedThrough is the same instant, for reading). ' +
|
|
572
|
+
'order:"newest" scans the most recent maxScan messages of the window first — usually what you want ' +
|
|
573
|
+
'for "when did this last come up". author/excludeAuthor narrow by who wrote the message; ' +
|
|
574
|
+
'wholeWord:true stops "mission" from matching inside "uncommissioned". Each match carries an `id` ' +
|
|
575
|
+
'you can hand to extract({aroundId}) to read the conversation around it. ' +
|
|
576
|
+
'regex:true matching runs under a wall-clock deadline and is cleanly failed ' +
|
|
248
577
|
`(not silently empty) if a pattern is too slow — avoid nested-quantifier patterns like (a+)+.`,
|
|
249
578
|
inputSchema: {
|
|
250
579
|
type: 'object',
|
|
@@ -252,11 +581,20 @@ export class HistoryModule {
|
|
|
252
581
|
query: { type: 'string', description: 'Substring (or regex source, when regex:true) to search for.' },
|
|
253
582
|
regex: { type: 'boolean', description: 'Treat query as a regular expression (default false).' },
|
|
254
583
|
caseSensitive: { type: 'boolean', description: 'Case-sensitive match (default false).' },
|
|
584
|
+
wholeWord: { type: 'boolean', description: 'Substring mode only: match whole words, not inside longer words (default false). In regex mode use \\b yourself.' },
|
|
255
585
|
from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
|
|
256
586
|
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
257
587
|
channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
|
|
588
|
+
author: AUTHOR_SCHEMA,
|
|
589
|
+
excludeAuthor: EXCLUDE_AUTHOR_SCHEMA,
|
|
590
|
+
order: { type: 'string', enum: ['oldest', 'newest'], description: 'Scan and return oldest-first (default) or newest-first.' },
|
|
258
591
|
limit: { type: 'number', description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, hard cap ${SEARCH_MAX_LIMIT}). Must be a non-negative integer.` },
|
|
259
592
|
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.` },
|
|
593
|
+
skipSequences: {
|
|
594
|
+
type: 'array',
|
|
595
|
+
items: { type: 'number' },
|
|
596
|
+
description: 'Continuation only: copy it from the previous response\'s `resume` together with its from/to. Marks the messages at exactly that instant that were already scanned.',
|
|
597
|
+
},
|
|
260
598
|
},
|
|
261
599
|
required: ['query'],
|
|
262
600
|
},
|
|
@@ -304,8 +642,42 @@ export class HistoryModule {
|
|
|
304
642
|
},
|
|
305
643
|
},
|
|
306
644
|
},
|
|
645
|
+
...(this.semanticCfg ? [this.semanticSearchTool()] : []),
|
|
307
646
|
];
|
|
308
647
|
}
|
|
648
|
+
semanticSearchTool() {
|
|
649
|
+
return {
|
|
650
|
+
name: 'semantic_search',
|
|
651
|
+
description: 'Search your own history by MEANING, not exact words: an embedding index over your raw messages ' +
|
|
652
|
+
'(text plus your own think/journal/skip_reply notes) and every compression summary. Use it when you ' +
|
|
653
|
+
'remember roughly what something was about but not the words — "the night the fluid sim was read ' +
|
|
654
|
+
'back to me as art" — then narrow with `from`/`to`/`channelId` and drill into the exact span with ' +
|
|
655
|
+
'`extract` or `overview` (for a message hit, `extract({ aroundId: messageId })` opens the conversation ' +
|
|
656
|
+
'around it — use the raw `messageId` field, not the `msg:` id). Results are ranked by cosine similarity (score ~0.6+ is a strong match, ' +
|
|
657
|
+
'~0.3 is thematic, below ~0.2 is noise); each hit carries its id (`msg:<id>` or `sum:<id>`), ' +
|
|
658
|
+
'timestamp, channel, kind/level and a snippet. The index catches up with recent messages before ' +
|
|
659
|
+
'searching (bounded, so a huge backlog is reported as `index.behind` rather than blocking; if a ' +
|
|
660
|
+
'background sync is already running, the search waits for that run to finish instead). ' +
|
|
661
|
+
'Purely a read: nothing is written to your history. Hits are checked against your current branch ' +
|
|
662
|
+
'before they come back: a message you undid or a summary from a branch you left is dropped and ' +
|
|
663
|
+
'counted in index.droppedOffBranch; a message edited down to nothing searchable is dropped and ' +
|
|
664
|
+
'counted in index.droppedStale. Message snippets always show the current text of the message.',
|
|
665
|
+
inputSchema: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: {
|
|
668
|
+
query: { type: 'string', description: 'What you are looking for, in natural language. A sentence works better than keywords.' },
|
|
669
|
+
limit: { type: 'number', description: `Max hits (default ${SEMANTIC_DEFAULT_LIMIT}, cap ${SEMANTIC_MAX_LIMIT}).` },
|
|
670
|
+
from: { type: 'string', description: 'ISO 8601 inclusive lower bound on the message timestamp / summary span start. Omit for open-ended.' },
|
|
671
|
+
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
672
|
+
channelId: { type: 'string', description: 'Only raw messages from this channel (label like "#general" or the raw internal id). Summaries are not per-channel and are excluded when this is set; combining it with kinds="summaries" or level is an error.' },
|
|
673
|
+
kinds: { type: 'string', enum: ['messages', 'summaries', 'both'], description: 'What to search: raw messages, compression summaries, or both (default).' },
|
|
674
|
+
level: { type: 'number', description: 'Only summaries of this exact level (implies kinds=summaries).' },
|
|
675
|
+
minScore: { type: 'number', description: 'Drop hits below this cosine score (0..1). Default none.' },
|
|
676
|
+
},
|
|
677
|
+
required: ['query'],
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
309
681
|
async handleToolCall(call) {
|
|
310
682
|
try {
|
|
311
683
|
if (!this.cm) {
|
|
@@ -320,6 +692,8 @@ export class HistoryModule {
|
|
|
320
692
|
return await this.handleSearch((call.input ?? {}));
|
|
321
693
|
case 'overview':
|
|
322
694
|
return this.handleOverview((call.input ?? {}));
|
|
695
|
+
case 'semantic_search':
|
|
696
|
+
return await this.handleSemanticSearch((call.input ?? {}));
|
|
323
697
|
default:
|
|
324
698
|
return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
|
|
325
699
|
}
|
|
@@ -382,26 +756,304 @@ export class HistoryModule {
|
|
|
382
756
|
// extract
|
|
383
757
|
// ==========================================================================
|
|
384
758
|
handleExtract(input) {
|
|
759
|
+
if (input.aroundId !== undefined)
|
|
760
|
+
return this.handleExtractAround(input);
|
|
761
|
+
if (input.before !== undefined || input.after !== undefined || input.allChannels !== undefined) {
|
|
762
|
+
throw new Error('"before"/"after"/"allChannels" only apply together with "aroundId".');
|
|
763
|
+
}
|
|
385
764
|
const channelId = this.resolveChannel(input.channelId);
|
|
386
765
|
const fromMs = parseIsoDate(input.from, 'from');
|
|
387
766
|
const toMs = parseIsoDate(input.to, 'to');
|
|
388
767
|
const limit = clampCount(input.limit, EXTRACT_DEFAULT_LIMIT, EXTRACT_MAX_LIMIT, 'limit');
|
|
389
768
|
const offset = clampCount(input.offset, 0, NATIVE_OFFSET_MAX, 'offset');
|
|
390
769
|
const format = input.format ?? 'text';
|
|
770
|
+
const authorFilter = buildAuthorFilter(input.author, input.excludeAuthor);
|
|
391
771
|
const cm = this.cm;
|
|
772
|
+
if (!authorFilter && (input.windowOffset !== undefined || input.afterId !== undefined || input.seqMark !== undefined)) {
|
|
773
|
+
throw new Error('"windowOffset"/"afterId" only apply together with author/excludeAuthor (they resume a filtered scan). Use offset.');
|
|
774
|
+
}
|
|
775
|
+
if (authorFilter) {
|
|
776
|
+
// No native author index: page through the time/channel window and
|
|
777
|
+
// filter here. offset/limit apply to the FILTERED sequence. Stop as
|
|
778
|
+
// soon as the requested page is full; otherwise scan to maxScan. Only
|
|
779
|
+
// an exhausted window yields an exact matchedCount — anything else is
|
|
780
|
+
// reported as truncated with a resume point, never as a total.
|
|
781
|
+
//
|
|
782
|
+
// Resume is by WINDOW POSITION (`windowOffset`), never by timestamp: a
|
|
783
|
+
// channel-scoped window pages in append order, and Discord catch-up
|
|
784
|
+
// appends old-timestamped messages late, so "continue from the last
|
|
785
|
+
// timestamp seen" would skip them.
|
|
786
|
+
const maxScan = clampCount(input.maxScan, EXTRACT_FILTER_DEFAULT_MAX_SCAN, EXTRACT_FILTER_MAX_MAX_SCAN, 'maxScan');
|
|
787
|
+
if (maxScan === 0)
|
|
788
|
+
throw new Error('"maxScan" must be at least 1 for an author-filtered extract.');
|
|
789
|
+
const requestedOffset = clampCount(input.windowOffset, 0, NATIVE_OFFSET_MAX, 'windowOffset');
|
|
790
|
+
// A position is only meaningful against the window it was taken in:
|
|
791
|
+
// a removal before it (every discord:delete, host hide, /undo) shifts
|
|
792
|
+
// later positions left, and a message inserted before it (a
|
|
793
|
+
// time-ordered window with backfill) shifts them right. Either would
|
|
794
|
+
// silently skip or repeat a message. afterId pins the position to the
|
|
795
|
+
// message that was there; relocate it if the window moved.
|
|
796
|
+
const { windowOffset, shift } = this.relocateWindowOffset(requestedOffset, input.afterId, { fromMs, toMs, channelId });
|
|
797
|
+
// A time-ordered window (no channelId) can also take an older-stamped
|
|
798
|
+
// insertion BEFORE the cursor; paired with a removal there, positions
|
|
799
|
+
// balance out (shift 0) and afterId alone can't see it. Appends carry
|
|
800
|
+
// the highest sequences, so everything appended since the previous
|
|
801
|
+
// call is the store's append tail above its seqMark: check those
|
|
802
|
+
// directly. (A channel window pages in append order, so an append
|
|
803
|
+
// always lands after the cursor and is simply scanned.)
|
|
804
|
+
const seqMarkNow = channelId === undefined ? this.storeSeqMark() : undefined;
|
|
805
|
+
let missed = [];
|
|
806
|
+
let unverified = false;
|
|
807
|
+
if (channelId === undefined && windowOffset > 0) {
|
|
808
|
+
if (typeof input.seqMark !== 'number') {
|
|
809
|
+
throw new Error('"windowOffset" without channelId needs the "seqMark" from the same `resume` object. Pass the whole `resume` object back.');
|
|
810
|
+
}
|
|
811
|
+
const cursor = cm.queryMessagesByTimeAndChannel({ fromMs, toMs, limit: 1, offset: windowOffset - 1 }).messages[0];
|
|
812
|
+
if (cursor) {
|
|
813
|
+
const ct = cursor.timestamp.getTime();
|
|
814
|
+
const r = this.appendedSince(input.seqMark, (m) => {
|
|
815
|
+
const ts = m.timestamp.getTime();
|
|
816
|
+
if ((fromMs !== undefined && ts < fromMs) || (toMs !== undefined && ts > toMs))
|
|
817
|
+
return false;
|
|
818
|
+
return ts < ct || (ts === ct && m.sequence < cursor.sequence);
|
|
819
|
+
});
|
|
820
|
+
missed = r.messages;
|
|
821
|
+
unverified = !r.complete;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const page = [];
|
|
825
|
+
let kept = 0;
|
|
826
|
+
let scanned = 0;
|
|
827
|
+
let lastScanned;
|
|
828
|
+
// Window position just past the last message put on the page, and that message.
|
|
829
|
+
let afterPage = windowOffset;
|
|
830
|
+
let lastOnPage;
|
|
831
|
+
let exhausted = false;
|
|
832
|
+
let pageFull = false;
|
|
833
|
+
outer: for (;;) {
|
|
834
|
+
const want = Math.min(FILTER_SCAN_PAGE, maxScan - scanned);
|
|
835
|
+
if (want <= 0)
|
|
836
|
+
break;
|
|
837
|
+
const chunk = cm.queryMessagesByTimeAndChannel({
|
|
838
|
+
fromMs,
|
|
839
|
+
toMs,
|
|
840
|
+
channelId,
|
|
841
|
+
limit: want,
|
|
842
|
+
offset: Math.min(windowOffset + scanned, NATIVE_OFFSET_MAX),
|
|
843
|
+
}).messages;
|
|
844
|
+
for (const m of chunk) {
|
|
845
|
+
scanned++;
|
|
846
|
+
lastScanned = m;
|
|
847
|
+
if (!authorFilter(m))
|
|
848
|
+
continue;
|
|
849
|
+
if (kept >= offset && page.length < limit) {
|
|
850
|
+
page.push(m);
|
|
851
|
+
afterPage = windowOffset + scanned;
|
|
852
|
+
lastOnPage = m;
|
|
853
|
+
}
|
|
854
|
+
kept++;
|
|
855
|
+
if (limit > 0 && page.length >= limit && kept > offset + limit) {
|
|
856
|
+
// One filtered match beyond the page proves there is more;
|
|
857
|
+
// no need to keep scanning.
|
|
858
|
+
pageFull = true;
|
|
859
|
+
break outer;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (chunk.length < want) {
|
|
863
|
+
exhausted = true;
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!exhausted && !pageFull && scanned >= maxScan) {
|
|
868
|
+
// Probe one past maxScan: a window of exactly maxScan is complete.
|
|
869
|
+
const next = cm.queryMessagesByTimeAndChannel({
|
|
870
|
+
fromMs,
|
|
871
|
+
toMs,
|
|
872
|
+
channelId,
|
|
873
|
+
limit: 1,
|
|
874
|
+
offset: Math.min(windowOffset + scanned, NATIVE_OFFSET_MAX),
|
|
875
|
+
}).messages;
|
|
876
|
+
if (next.length === 0)
|
|
877
|
+
exhausted = true;
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
success: true,
|
|
881
|
+
data: {
|
|
882
|
+
// A resumed scan (windowOffset > 0) only saw the window from that
|
|
883
|
+
// position on, so its count is named for that — never a total. A
|
|
884
|
+
// page-full stop's lookahead match lies past the resume point and
|
|
885
|
+
// is left out, so counts add up across a chain of resumes.
|
|
886
|
+
...(() => {
|
|
887
|
+
const n = pageFull ? kept - 1 : kept;
|
|
888
|
+
const base = windowOffset > 0 ? 'matchedSinceWindowOffset' : 'matchedCount';
|
|
889
|
+
return { [exhausted ? base : `${base}AtLeast`]: n };
|
|
890
|
+
})(),
|
|
891
|
+
returned: page.length,
|
|
892
|
+
scanned,
|
|
893
|
+
truncated: !exhausted,
|
|
894
|
+
...(shift !== 0 || missed.length > 0 || unverified
|
|
895
|
+
? {
|
|
896
|
+
windowChanged: {
|
|
897
|
+
shift,
|
|
898
|
+
...(unverified
|
|
899
|
+
? {
|
|
900
|
+
unverified: true,
|
|
901
|
+
unverifiedNote: `More than ${APPENDED_SCAN_CAP} messages were appended since the previous call; only the newest ` +
|
|
902
|
+
'were checked, so messages may have been added behind the resume point unseen. Restart without ' +
|
|
903
|
+
'windowOffset, or narrow with from/to/channelId, if that matters.',
|
|
904
|
+
}
|
|
905
|
+
: {}),
|
|
906
|
+
...(channelId === undefined
|
|
907
|
+
? {
|
|
908
|
+
addedBefore: missed.length,
|
|
909
|
+
// the ones this filter would have kept — fetch them with extract({aroundId})
|
|
910
|
+
missedIds: missed.filter(authorFilter).map((m) => String(m.id)),
|
|
911
|
+
}
|
|
912
|
+
: {}),
|
|
913
|
+
note: missed.length > 0 || shift > 0
|
|
914
|
+
? `${missed.length || shift} message(s) were added to the window before the resume point since the previous call; this scan chain did not see them` +
|
|
915
|
+
(channelId === undefined ? ' (matching ones listed in missedIds).' : '.')
|
|
916
|
+
: shift < 0
|
|
917
|
+
? `${-shift} message(s) before the resume point were removed since the previous call; the resume was re-anchored, nothing skipped.`
|
|
918
|
+
: 'No change found behind the resume point, but the check was incomplete (see unverifiedNote).',
|
|
919
|
+
},
|
|
920
|
+
}
|
|
921
|
+
: {}),
|
|
922
|
+
...(!exhausted
|
|
923
|
+
? (() => {
|
|
924
|
+
// pageFull: resume just past the last returned message, no
|
|
925
|
+
// skip. maxScan: resume past everything scanned, still owing
|
|
926
|
+
// whatever part of `offset` was not yet consumed.
|
|
927
|
+
// afterId = the message at position windowOffset-1 (see
|
|
928
|
+
// relocateWindowOffset). When nothing was scanned (no
|
|
929
|
+
// chunk came back), carry the incoming anchor forward.
|
|
930
|
+
const mark = seqMarkNow !== undefined ? { seqMark: seqMarkNow } : {};
|
|
931
|
+
const resume = pageFull
|
|
932
|
+
? { windowOffset: afterPage, offset: 0, afterId: idOrNull(lastOnPage) ?? input.afterId ?? null, ...mark }
|
|
933
|
+
: {
|
|
934
|
+
windowOffset: windowOffset + scanned,
|
|
935
|
+
offset: Math.max(0, offset - kept),
|
|
936
|
+
afterId: idOrNull(lastScanned) ?? input.afterId ?? null,
|
|
937
|
+
...mark,
|
|
938
|
+
};
|
|
939
|
+
return {
|
|
940
|
+
resume,
|
|
941
|
+
hint: (pageFull
|
|
942
|
+
? 'More matching messages exist. '
|
|
943
|
+
: `Stopped after scanning ${scanned} messages of the window without reaching its end. `) +
|
|
944
|
+
`Continue by repeating this call with the same from/to/channelId/author plus ` +
|
|
945
|
+
`the fields of \`resume\` (windowOffset:${resume.windowOffset}, offset:${resume.offset}, afterId).`,
|
|
946
|
+
};
|
|
947
|
+
})()
|
|
948
|
+
: {}),
|
|
949
|
+
messages: page.map((msg) => projectMessage(msg, format)),
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
// One extra row answers "is there another page" in every case. The
|
|
954
|
+
// native matchedCount is a true total only for channel-scoped queries;
|
|
955
|
+
// for time-only/unfiltered ones it is just the page size (see
|
|
956
|
+
// edgeWindow), so it is reported only where it means what it says.
|
|
392
957
|
const result = cm.queryMessagesByTimeAndChannel({
|
|
393
958
|
fromMs,
|
|
394
959
|
toMs,
|
|
395
960
|
channelId,
|
|
396
|
-
limit,
|
|
961
|
+
limit: limit + 1,
|
|
397
962
|
offset,
|
|
398
963
|
});
|
|
964
|
+
const page = result.messages.slice(0, limit);
|
|
399
965
|
return {
|
|
400
966
|
success: true,
|
|
401
967
|
data: {
|
|
402
|
-
matchedCount: result.matchedCount,
|
|
403
|
-
returned:
|
|
404
|
-
|
|
968
|
+
...(channelId !== undefined ? { matchedCount: result.matchedCount } : {}),
|
|
969
|
+
returned: page.length,
|
|
970
|
+
hasMore: result.messages.length > limit,
|
|
971
|
+
messages: page.map((msg) => projectMessage(msg, format)),
|
|
972
|
+
},
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* `extract({aroundId})` — the conversation around one message, like
|
|
977
|
+
* Discord's fetch_around. Neighbours are by (timestamp, sequence): first
|
|
978
|
+
* the anchor's own millisecond in sequence order, then, for whatever a
|
|
979
|
+
* side still needs, the nearest messages strictly before/after it (both
|
|
980
|
+
* via edgeWindow, which is exact by timestamp).
|
|
981
|
+
*/
|
|
982
|
+
handleExtractAround(input) {
|
|
983
|
+
if (input.from !== undefined ||
|
|
984
|
+
input.to !== undefined ||
|
|
985
|
+
input.offset !== undefined ||
|
|
986
|
+
input.windowOffset !== undefined ||
|
|
987
|
+
input.limit !== undefined) {
|
|
988
|
+
throw new Error('"aroundId" cannot be combined with from/to/offset/windowOffset/limit — it picks its own window; size it with before/after.');
|
|
989
|
+
}
|
|
990
|
+
if (input.author !== undefined || input.excludeAuthor !== undefined) {
|
|
991
|
+
throw new Error('"aroundId" returns the whole conversation around a message; author filters do not apply. Use search/extract with author instead.');
|
|
992
|
+
}
|
|
993
|
+
if (input.allChannels && input.channelId !== undefined) {
|
|
994
|
+
throw new Error('Pass either "channelId" or "allChannels", not both.');
|
|
995
|
+
}
|
|
996
|
+
const before = clampCount(input.before, AROUND_DEFAULT, AROUND_MAX, 'before');
|
|
997
|
+
const after = clampCount(input.after, AROUND_DEFAULT, AROUND_MAX, 'after');
|
|
998
|
+
const format = input.format ?? 'text';
|
|
999
|
+
const cm = this.cm;
|
|
1000
|
+
// semantic_search (#173) hands out `msg:<id>` / `sum:<id>`; accept the
|
|
1001
|
+
// message form as-is, and say plainly what a summary id is.
|
|
1002
|
+
const rawId = String(input.aroundId);
|
|
1003
|
+
if (/^sum:/.test(rawId)) {
|
|
1004
|
+
throw new Error(`${rawId} is a compression summary, not a message — use overview for summaries, or aroundId with a msg: hit.`);
|
|
1005
|
+
}
|
|
1006
|
+
const anchor = cm.getMessage(rawId.replace(/^msg:/, ''));
|
|
1007
|
+
if (!anchor) {
|
|
1008
|
+
throw new Error(`No message with id ${JSON.stringify(input.aroundId)} in this history.`);
|
|
1009
|
+
}
|
|
1010
|
+
const anchorId = String(anchor.id);
|
|
1011
|
+
const anchorChannel = getChannelId(anchor);
|
|
1012
|
+
const channelId = input.allChannels ? undefined : (this.resolveChannel(input.channelId) ?? anchorChannel);
|
|
1013
|
+
const t = anchor.timestamp.getTime();
|
|
1014
|
+
// The anchor's own millisecond first, whole and in sequence order —
|
|
1015
|
+
// that is the (timestamp, sequence) order the neighbours are defined
|
|
1016
|
+
// by, and it may hold many messages (a backfill burst). Then only if a
|
|
1017
|
+
// side still needs more, the nearest messages strictly before / after
|
|
1018
|
+
// that millisecond (edgeWindow is exact by timestamp).
|
|
1019
|
+
let ties;
|
|
1020
|
+
if (channelId !== undefined) {
|
|
1021
|
+
const n = cm.queryMessagesByTimeAndChannel({ fromMs: t, toMs: t, channelId, limit: 0 }).matchedCount;
|
|
1022
|
+
if (n > AROUND_TIE_CAP)
|
|
1023
|
+
throw tieCapError(anchorId, n);
|
|
1024
|
+
ties = cm.queryMessagesByTimeAndChannel({ fromMs: t, toMs: t, channelId, limit: n }).messages;
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
ties = cm.queryMessagesByTime({ fromMs: t, toMs: t, limit: AROUND_TIE_CAP + 1 }).messages;
|
|
1028
|
+
if (ties.length > AROUND_TIE_CAP)
|
|
1029
|
+
throw tieCapError(anchorId, ties.length);
|
|
1030
|
+
}
|
|
1031
|
+
ties = [...ties].sort((x, y) => x.sequence - y.sequence);
|
|
1032
|
+
const idx = ties.findIndex((m) => String(m.id) === anchorId);
|
|
1033
|
+
if (idx === -1) {
|
|
1034
|
+
// The anchor is in the store and every message of its millisecond in
|
|
1035
|
+
// `channelId` was fetched, so only an explicit channelId excludes it.
|
|
1036
|
+
throw new Error(`Message ${anchorId} is not in channel ${JSON.stringify(input.channelId)}` +
|
|
1037
|
+
(anchorChannel ? ` (it is in ${anchorChannel}).` : '.') +
|
|
1038
|
+
' Omit channelId to use the anchor\'s own channel.');
|
|
1039
|
+
}
|
|
1040
|
+
const tiesBefore = ties.slice(Math.max(0, idx - before), idx);
|
|
1041
|
+
const tiesAfter = ties.slice(idx + 1, idx + 1 + after);
|
|
1042
|
+
const needBefore = before - tiesBefore.length;
|
|
1043
|
+
const needAfter = after - tiesAfter.length;
|
|
1044
|
+
const earlier = needBefore > 0 ? this.edgeWindow({ toMs: t - 1, channelId, n: needBefore, side: 'newest' }).messages.reverse() : [];
|
|
1045
|
+
const later = needAfter > 0 ? this.edgeWindow({ fromMs: t + 1, channelId, n: needAfter, side: 'oldest' }).messages : [];
|
|
1046
|
+
const window = [...earlier, ...tiesBefore, ties[idx], ...tiesAfter, ...later];
|
|
1047
|
+
return {
|
|
1048
|
+
success: true,
|
|
1049
|
+
data: {
|
|
1050
|
+
anchorId,
|
|
1051
|
+
channelId: channelId ?? null,
|
|
1052
|
+
returned: window.length,
|
|
1053
|
+
messages: window.map((m) => ({
|
|
1054
|
+
...projectMessage(m, format),
|
|
1055
|
+
...(String(m.id) === anchorId ? { anchor: true } : {}),
|
|
1056
|
+
})),
|
|
405
1057
|
},
|
|
406
1058
|
};
|
|
407
1059
|
}
|
|
@@ -419,6 +1071,14 @@ export class HistoryModule {
|
|
|
419
1071
|
const maxScan = clampCount(input.maxScan, SEARCH_DEFAULT_MAX_SCAN, SEARCH_MAX_MAX_SCAN, 'maxScan');
|
|
420
1072
|
const caseSensitive = input.caseSensitive ?? false;
|
|
421
1073
|
const flags = caseSensitive ? '' : 'i';
|
|
1074
|
+
const order = input.order ?? 'oldest';
|
|
1075
|
+
if (order !== 'oldest' && order !== 'newest') {
|
|
1076
|
+
throw new Error(`"order" must be "oldest" or "newest", got ${JSON.stringify(input.order)}.`);
|
|
1077
|
+
}
|
|
1078
|
+
if (input.wholeWord && input.regex) {
|
|
1079
|
+
throw new Error('"wholeWord" applies to substring search only; in regex mode write \\b around the pattern instead.');
|
|
1080
|
+
}
|
|
1081
|
+
const authorFilter = buildAuthorFilter(input.author, input.excludeAuthor);
|
|
422
1082
|
// Validate regex SYNTAX up front — an invalid pattern is a clean tool
|
|
423
1083
|
// error, not a crash mid-scan. This does NOT bound match TIME (a
|
|
424
1084
|
// syntactically valid pattern can still backtrack catastrophically),
|
|
@@ -434,19 +1094,94 @@ export class HistoryModule {
|
|
|
434
1094
|
}
|
|
435
1095
|
const needle = caseSensitive ? input.query : input.query.toLowerCase();
|
|
436
1096
|
const cm = this.cm;
|
|
437
|
-
//
|
|
438
|
-
//
|
|
1097
|
+
// The candidate window is the oldest (order:"oldest") or newest
|
|
1098
|
+
// (order:"newest", via edgeWindow — see there for why not an offset
|
|
1099
|
+
// tail) maxScan messages of the time/channel range. Oversize is
|
|
1100
|
+
// detected up front rather than
|
|
439
1101
|
// silently scanning maxScan candidates and returning as if that were the
|
|
440
1102
|
// whole window. See the tool description's `truncated` contract.
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
1103
|
+
if (maxScan === 0)
|
|
1104
|
+
throw new Error('"maxScan" must be at least 1 — a zero-message scan can never make progress.');
|
|
1105
|
+
// Continuation: `skipSequences` names what the previous call already
|
|
1106
|
+
// scanned at exactly the resume bound's millisecond (the bound is
|
|
1107
|
+
// inclusive). Identified by sequence range rather than a count, so a
|
|
1108
|
+
// message removed at that millisecond, or appended there (a new
|
|
1109
|
+
// message always gets the highest sequence), between calls neither
|
|
1110
|
+
// shifts the skip onto an unscanned message nor hides the new one.
|
|
1111
|
+
const skipRange = parseSkipSequences(input.skipSequences);
|
|
1112
|
+
const edgeMs = order === 'oldest' ? fromMs : toMs;
|
|
1113
|
+
if (skipRange && edgeMs === undefined) {
|
|
1114
|
+
throw new Error(`"skipSequences" only applies together with "${order === 'oldest' ? 'from' : 'to'}" — pass the whole \`resume\` object back.`);
|
|
1115
|
+
}
|
|
1116
|
+
const isSkipped = (m) => !!skipRange && m.timestamp.getTime() === edgeMs && m.sequence >= skipRange[0] && m.sequence <= skipRange[1];
|
|
1117
|
+
let windowMessages;
|
|
1118
|
+
let truncated;
|
|
1119
|
+
{
|
|
1120
|
+
// Over-fetch by the number of messages at the edge millisecond (the
|
|
1121
|
+
// only ones that can be skipped), then drop the skipped ones.
|
|
1122
|
+
const edgeTies = skipRange ? this.countAtMs(edgeMs, channelId) : 0;
|
|
1123
|
+
const w = this.edgeWindow({ fromMs, toMs, channelId, n: maxScan + edgeTies, side: order });
|
|
1124
|
+
const pool = skipRange ? w.messages.filter((m) => !isSkipped(m)) : w.messages;
|
|
1125
|
+
truncated = w.more || pool.length > maxScan;
|
|
1126
|
+
windowMessages = pool.slice(0, maxScan);
|
|
1127
|
+
}
|
|
1128
|
+
const candidatePoolSize = windowMessages.length;
|
|
1129
|
+
const candidates = authorFilter ? windowMessages.filter(authorFilter) : windowMessages;
|
|
1130
|
+
const poolInfo = {
|
|
1131
|
+
candidatePoolSize,
|
|
1132
|
+
...(authorFilter ? { afterAuthorFilter: candidates.length } : {}),
|
|
1133
|
+
order,
|
|
1134
|
+
};
|
|
1135
|
+
// Where the scan stopped, when it stopped before the end of the window
|
|
1136
|
+
// the caller asked about: either `limit` matches came first (the rest of
|
|
1137
|
+
// the pool was never looked at) or the pool itself was truncated. The
|
|
1138
|
+
// timestamp is the continuation point — from: (oldest) / to: (newest).
|
|
1139
|
+
// Boundary is inclusive, so a message at exactly that instant may repeat.
|
|
1140
|
+
const resumeInfo = (scanned) => {
|
|
1141
|
+
const stoppedEarly = scanned < candidates.length;
|
|
1142
|
+
if (!stoppedEarly && !truncated)
|
|
1143
|
+
return {};
|
|
1144
|
+
// Stopped at limit: the last candidate actually looked at. Scanned the
|
|
1145
|
+
// whole (truncated) pool: the pool's own far edge — which may lie past
|
|
1146
|
+
// the last author-filtered candidate.
|
|
1147
|
+
const last = stoppedEarly ? candidates[scanned - 1] : windowMessages[windowMessages.length - 1];
|
|
1148
|
+
if (!last)
|
|
1149
|
+
return {};
|
|
1150
|
+
const at = last.timestamp.toISOString();
|
|
1151
|
+
const bound = order === 'oldest' ? 'from' : 'to';
|
|
1152
|
+
// The continuation bound is inclusive, so the next window starts with
|
|
1153
|
+
// every message at exactly `at`. Hand back the sequence range already
|
|
1154
|
+
// scanned there: this call's messages at `at` up to the stop, plus —
|
|
1155
|
+
// when `at` is still the incoming bound — the range the previous
|
|
1156
|
+
// call had already covered. Within one millisecond the pool is in
|
|
1157
|
+
// sequence order, so every message at `at` whose sequence lies in the
|
|
1158
|
+
// union was scanned; anything appended later sorts outside it.
|
|
1159
|
+
const atMs = last.timestamp.getTime();
|
|
1160
|
+
const stop = windowMessages.indexOf(last);
|
|
1161
|
+
let lo = Infinity;
|
|
1162
|
+
let hi = -Infinity;
|
|
1163
|
+
for (let i = 0; i <= stop; i++) {
|
|
1164
|
+
const m = windowMessages[i];
|
|
1165
|
+
if (m.timestamp.getTime() !== atMs)
|
|
1166
|
+
continue;
|
|
1167
|
+
lo = Math.min(lo, m.sequence);
|
|
1168
|
+
hi = Math.max(hi, m.sequence);
|
|
1169
|
+
}
|
|
1170
|
+
if (skipRange && atMs === edgeMs) {
|
|
1171
|
+
lo = Math.min(lo, skipRange[0]);
|
|
1172
|
+
hi = Math.max(hi, skipRange[1]);
|
|
1173
|
+
}
|
|
1174
|
+
const resume = { [bound]: at, skipSequences: [lo, hi] };
|
|
1175
|
+
return {
|
|
1176
|
+
scannedThrough: at,
|
|
1177
|
+
resume,
|
|
1178
|
+
hint: (stoppedEarly
|
|
1179
|
+
? 'Stopped at limit; more candidates remain. '
|
|
1180
|
+
: `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
|
|
1181
|
+
`Continue by repeating the call with the fields of \`resume\` (${bound}:"${at}" and skipSequences), ` +
|
|
1182
|
+
'or raise limit/maxScan, or narrow with channelId/author/dates.',
|
|
1183
|
+
};
|
|
1184
|
+
};
|
|
450
1185
|
if (input.regex) {
|
|
451
1186
|
// Regex matching against caller-supplied patterns is ReDoS-shaped: a
|
|
452
1187
|
// pathological pattern (e.g. `(a+)+$`) can take catastrophically long
|
|
@@ -458,7 +1193,7 @@ export class HistoryModule {
|
|
|
458
1193
|
// on a deadline. See search-regex-worker.ts's header for the full
|
|
459
1194
|
// rationale.
|
|
460
1195
|
const { matches, scanned } = await this.searchWithRegexWorker(candidates, input.query, flags, limit);
|
|
461
|
-
return { success: true, data: { scanned,
|
|
1196
|
+
return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches } };
|
|
462
1197
|
}
|
|
463
1198
|
// Plain substring search (String.prototype.indexOf) is inherently
|
|
464
1199
|
// linear in input length — no ReDoS-equivalent risk — so it stays
|
|
@@ -476,20 +1211,21 @@ export class HistoryModule {
|
|
|
476
1211
|
break;
|
|
477
1212
|
scanned++;
|
|
478
1213
|
const text = flattenContent(msg.content);
|
|
479
|
-
const hit = matchSubstring(text, needle, caseSensitive);
|
|
1214
|
+
const hit = matchSubstring(text, needle, caseSensitive, input.wholeWord ?? false);
|
|
480
1215
|
if (!hit)
|
|
481
1216
|
continue;
|
|
482
1217
|
matches.push({
|
|
483
1218
|
id: String(msg.id),
|
|
484
1219
|
timestamp: msg.timestamp.toISOString(),
|
|
485
1220
|
participant: msg.participant,
|
|
1221
|
+
author: authorName(msg),
|
|
486
1222
|
channelId: getChannelId(msg) ?? null,
|
|
487
1223
|
snippet: snippetAround(text, hit.index, hit.length),
|
|
488
1224
|
});
|
|
489
1225
|
}
|
|
490
1226
|
return {
|
|
491
1227
|
success: true,
|
|
492
|
-
data: { scanned,
|
|
1228
|
+
data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches },
|
|
493
1229
|
};
|
|
494
1230
|
}
|
|
495
1231
|
/**
|
|
@@ -538,6 +1274,7 @@ export class HistoryModule {
|
|
|
538
1274
|
id: String(msg.id),
|
|
539
1275
|
timestamp: msg.timestamp.toISOString(),
|
|
540
1276
|
participant: msg.participant,
|
|
1277
|
+
author: authorName(msg),
|
|
541
1278
|
channelId: getChannelId(msg) ?? null,
|
|
542
1279
|
snippet: snippetAround(text, matchIndex, matchLength),
|
|
543
1280
|
};
|
|
@@ -555,6 +1292,142 @@ export class HistoryModule {
|
|
|
555
1292
|
// ==========================================================================
|
|
556
1293
|
// overview
|
|
557
1294
|
// ==========================================================================
|
|
1295
|
+
async handleSemanticSearch(input) {
|
|
1296
|
+
if (!this.semanticCfg || !this.semanticClient) {
|
|
1297
|
+
throw new Error('semantic_search is not configured for this resident (no embed-service in the recipe).');
|
|
1298
|
+
}
|
|
1299
|
+
if (typeof input.query !== 'string' || !input.query.trim())
|
|
1300
|
+
throw new Error('query must be a non-empty string');
|
|
1301
|
+
const limit = clampCount(input.limit, SEMANTIC_DEFAULT_LIMIT, SEMANTIC_MAX_LIMIT, 'limit');
|
|
1302
|
+
if (limit < 1)
|
|
1303
|
+
throw new Error('limit must be at least 1');
|
|
1304
|
+
const fromMs = parseIsoDate(input.from, 'from');
|
|
1305
|
+
const toMs = parseIsoDate(input.to, 'to');
|
|
1306
|
+
if (fromMs !== undefined && toMs !== undefined && fromMs > toMs)
|
|
1307
|
+
throw new Error('`from` must not be after `to`');
|
|
1308
|
+
const channelId = this.resolveChannel(input.channelId);
|
|
1309
|
+
// Summaries carry no channel, so a channel filter on a summaries-only
|
|
1310
|
+
// search can only ever match nothing. Say so instead of returning [].
|
|
1311
|
+
const wantsSummariesOnly = input.level !== undefined || input.kinds === 'summaries';
|
|
1312
|
+
if (channelId && wantsSummariesOnly) {
|
|
1313
|
+
throw new Error('channelId cannot be combined with kinds="summaries" or level: summaries are not per-channel. Drop channelId, or search kinds="messages".');
|
|
1314
|
+
}
|
|
1315
|
+
if (input.level !== undefined && input.kinds === 'messages') {
|
|
1316
|
+
throw new Error('level applies to summaries only and cannot be combined with kinds="messages".');
|
|
1317
|
+
}
|
|
1318
|
+
let kinds;
|
|
1319
|
+
if (wantsSummariesOnly)
|
|
1320
|
+
kinds = ['summary'];
|
|
1321
|
+
else if (input.kinds === 'messages' || channelId)
|
|
1322
|
+
kinds = ['message'];
|
|
1323
|
+
if (input.level !== undefined && (!Number.isInteger(input.level) || input.level < 0))
|
|
1324
|
+
throw new Error('level must be a non-negative integer');
|
|
1325
|
+
if (input.minScore !== undefined && (typeof input.minScore !== 'number' || input.minScore < -1 || input.minScore > 1)) {
|
|
1326
|
+
throw new Error('minScore must be a number in -1..1');
|
|
1327
|
+
}
|
|
1328
|
+
// Bounded catch-up so the newest messages are searchable; never block on a backlog.
|
|
1329
|
+
let sync = null;
|
|
1330
|
+
if (this.indexer) {
|
|
1331
|
+
try {
|
|
1332
|
+
sync = await this.indexer.catchUp(this.semanticCfg.maxSyncBeforeSearch ?? 256);
|
|
1333
|
+
}
|
|
1334
|
+
catch { /* reported via indexer.lastError */ }
|
|
1335
|
+
}
|
|
1336
|
+
const runSearch = (k) => this.semanticClient.search({
|
|
1337
|
+
query: input.query, k,
|
|
1338
|
+
ts_from: fromMs === undefined ? undefined : fromMs / 1000,
|
|
1339
|
+
ts_to: toMs === undefined ? undefined : toMs / 1000,
|
|
1340
|
+
channel: channelId, kinds, level: input.level, min_score: input.minScore, snippet: SEMANTIC_SNIPPET_CHARS,
|
|
1341
|
+
});
|
|
1342
|
+
// The remote index is append-only and knows nothing about branches: a
|
|
1343
|
+
// message removed from the agent's reach by /undo, /checkout, /restore or
|
|
1344
|
+
// /newtopic keeps its id (a new message gets a fresh one) and stays
|
|
1345
|
+
// indexed. Check every hit against the CURRENT branch before it goes back
|
|
1346
|
+
// to the model — getMessage/getSummary rebuild on branch switch, so an
|
|
1347
|
+
// undone message or a summary minted on another branch answers null.
|
|
1348
|
+
// k is small, so this is at most `limit` local lookups.
|
|
1349
|
+
// A message hit whose current text has nothing left to index (edited
|
|
1350
|
+
// blank, delete not yet applied) is dropped too, so its stale snippet is
|
|
1351
|
+
// never served. Dropped hits are replaced by over-fetching: when the
|
|
1352
|
+
// top k held droppable entries, re-query with a larger k (service cap
|
|
1353
|
+
// 200) so lower-ranked on-branch matches still fill `limit`.
|
|
1354
|
+
const cm = this.cm;
|
|
1355
|
+
const includePrivate = this.semanticCfg.includePrivateTools ?? true;
|
|
1356
|
+
// A message hit's snippet is rebuilt from the CURRENT local text, never
|
|
1357
|
+
// the remote copy: an edit not yet synced (or one the index never got)
|
|
1358
|
+
// must not leak a passage the edit removed. Only the ranking can be stale.
|
|
1359
|
+
const currentText = new Map();
|
|
1360
|
+
const keep = (h) => {
|
|
1361
|
+
const msgId = /^msg:(.+)$/.exec(h.id)?.[1];
|
|
1362
|
+
const sumId = /^sum:(.+)$/.exec(h.id)?.[1];
|
|
1363
|
+
if (msgId !== undefined) {
|
|
1364
|
+
const m = cm.getMessage(msgId);
|
|
1365
|
+
if (!m)
|
|
1366
|
+
return 'offBranch';
|
|
1367
|
+
const text = messageIndexText(m, includePrivate).slice(0, this.semanticCfg.maxChars ?? 32_000);
|
|
1368
|
+
if (!text)
|
|
1369
|
+
return 'stale';
|
|
1370
|
+
currentText.set(h.id, text);
|
|
1371
|
+
return 'ok';
|
|
1372
|
+
}
|
|
1373
|
+
if (sumId !== undefined)
|
|
1374
|
+
return cm.getSummary(sumId) !== null ? 'ok' : 'offBranch';
|
|
1375
|
+
return 'ok';
|
|
1376
|
+
};
|
|
1377
|
+
let k = limit;
|
|
1378
|
+
let res = await runSearch(k);
|
|
1379
|
+
let droppedOffBranch = 0;
|
|
1380
|
+
let droppedStale = 0;
|
|
1381
|
+
let onBranch = [];
|
|
1382
|
+
for (;;) {
|
|
1383
|
+
droppedOffBranch = 0;
|
|
1384
|
+
droppedStale = 0;
|
|
1385
|
+
onBranch = res.hits.filter((h) => {
|
|
1386
|
+
const v = keep(h);
|
|
1387
|
+
if (v === 'offBranch')
|
|
1388
|
+
droppedOffBranch++;
|
|
1389
|
+
else if (v === 'stale')
|
|
1390
|
+
droppedStale++;
|
|
1391
|
+
return v === 'ok';
|
|
1392
|
+
});
|
|
1393
|
+
if (onBranch.length >= limit || res.hits.length < k || k >= SEMANTIC_MAX_FETCH)
|
|
1394
|
+
break;
|
|
1395
|
+
k = Math.min(SEMANTIC_MAX_FETCH, k * 4);
|
|
1396
|
+
res = await runSearch(k);
|
|
1397
|
+
}
|
|
1398
|
+
onBranch = onBranch.slice(0, limit);
|
|
1399
|
+
const hits = onBranch.map((h) => ({
|
|
1400
|
+
id: h.id,
|
|
1401
|
+
/** Raw message id for a `msg:` hit (null for summaries) — pass it to extract({ aroundId }). */
|
|
1402
|
+
messageId: h.id.startsWith('msg:') ? h.id.slice(4) : null,
|
|
1403
|
+
kind: h.kind,
|
|
1404
|
+
level: h.level,
|
|
1405
|
+
score: h.score,
|
|
1406
|
+
timestamp: h.ts === null ? null : new Date(h.ts * 1000).toISOString(),
|
|
1407
|
+
channelId: h.channel,
|
|
1408
|
+
participant: h.meta.participant ?? null,
|
|
1409
|
+
author: h.meta.author ?? null,
|
|
1410
|
+
snippet: currentText.has(h.id) ? snippetOf(currentText.get(h.id)) : (h.text ?? ''),
|
|
1411
|
+
chars: currentText.has(h.id) ? currentText.get(h.id).length : h.chars,
|
|
1412
|
+
}));
|
|
1413
|
+
return {
|
|
1414
|
+
success: true,
|
|
1415
|
+
data: {
|
|
1416
|
+
hits,
|
|
1417
|
+
index: {
|
|
1418
|
+
indexed: res.count_indexed,
|
|
1419
|
+
behind: sync ? sync.more : this.indexer === null ? null : true,
|
|
1420
|
+
syncedThisCall: sync ? sync.pushed : 0,
|
|
1421
|
+
lastError: this.indexer?.lastError ?? null,
|
|
1422
|
+
/** Hits the index returned for messages/summaries not on the current branch (undone, checked out past). */
|
|
1423
|
+
droppedOffBranch,
|
|
1424
|
+
/** Hits for messages whose current text has nothing indexable (edited blank; the index delete is pending). */
|
|
1425
|
+
droppedStale,
|
|
1426
|
+
},
|
|
1427
|
+
timingMs: res.timing_ms,
|
|
1428
|
+
},
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
558
1431
|
handleOverview(input) {
|
|
559
1432
|
const channelId = this.resolveChannel(input.channelId);
|
|
560
1433
|
const fromMs = parseIsoDate(input.from, 'from');
|
|
@@ -1089,6 +1962,7 @@ function projectMessage(msg, format) {
|
|
|
1089
1962
|
id: String(msg.id),
|
|
1090
1963
|
timestamp: msg.timestamp.toISOString(),
|
|
1091
1964
|
participant: msg.participant,
|
|
1965
|
+
author: authorName(msg),
|
|
1092
1966
|
channelId: getChannelId(msg) ?? null,
|
|
1093
1967
|
content: format === 'raw' ? msg.content : flattenContent(msg.content),
|
|
1094
1968
|
};
|
|
@@ -1127,12 +2001,133 @@ function blockLabel(block) {
|
|
|
1127
2001
|
return `[${block.type}]`;
|
|
1128
2002
|
}
|
|
1129
2003
|
}
|
|
1130
|
-
|
|
2004
|
+
/** Letters, combining marks (NFD accents, Indic vowel signs), digits, underscore. */
|
|
2005
|
+
const WORD_CHAR_RE = /[\p{L}\p{M}\p{N}_]/u;
|
|
2006
|
+
function isWordChar(ch) {
|
|
2007
|
+
return ch !== undefined && WORD_CHAR_RE.test(ch);
|
|
2008
|
+
}
|
|
2009
|
+
/** The whole code point starting at `i` (astral letters are two UTF-16 units). */
|
|
2010
|
+
function codePointAt(s, i) {
|
|
2011
|
+
const cp = s.codePointAt(i);
|
|
2012
|
+
return cp === undefined ? undefined : String.fromCodePoint(cp);
|
|
2013
|
+
}
|
|
2014
|
+
/** The whole code point ending just before `i`. */
|
|
2015
|
+
function codePointBefore(s, i) {
|
|
2016
|
+
if (i <= 0)
|
|
2017
|
+
return undefined;
|
|
2018
|
+
const lo = s.charCodeAt(i - 1);
|
|
2019
|
+
if (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) {
|
|
2020
|
+
const hi = s.charCodeAt(i - 2);
|
|
2021
|
+
if (hi >= 0xd800 && hi <= 0xdbff)
|
|
2022
|
+
return s.slice(i - 2, i);
|
|
2023
|
+
}
|
|
2024
|
+
return s[i - 1];
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* First occurrence of `needle` in `text`. With `wholeWord`, an occurrence
|
|
2028
|
+
* only counts when it isn't glued to a letter/digit/underscore on either
|
|
2029
|
+
* side (Unicode-aware, so Cyrillic words work too) — "mission" no longer
|
|
2030
|
+
* hits inside "uncommissioned". Word-ness is checked only on the sides
|
|
2031
|
+
* where the needle itself starts/ends with a word character, so a needle
|
|
2032
|
+
* like "#general" or "v2." still matches the way a person would expect.
|
|
2033
|
+
*/
|
|
2034
|
+
function matchSubstring(text, needle, caseSensitive, wholeWord = false) {
|
|
1131
2035
|
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
1132
|
-
|
|
1133
|
-
|
|
2036
|
+
if (!wholeWord) {
|
|
2037
|
+
const index = haystack.indexOf(needle);
|
|
2038
|
+
return index === -1 ? null : { index, length: needle.length };
|
|
2039
|
+
}
|
|
2040
|
+
const checkLeft = isWordChar(codePointAt(needle, 0));
|
|
2041
|
+
const checkRight = isWordChar(codePointBefore(needle, needle.length));
|
|
2042
|
+
for (let from = 0;;) {
|
|
2043
|
+
const index = haystack.indexOf(needle, from);
|
|
2044
|
+
if (index === -1)
|
|
2045
|
+
return null;
|
|
2046
|
+
const end = index + needle.length;
|
|
2047
|
+
if ((!checkLeft || !isWordChar(codePointBefore(haystack, index))) && (!checkRight || !isWordChar(codePointAt(haystack, end)))) {
|
|
2048
|
+
return { index, length: needle.length };
|
|
2049
|
+
}
|
|
2050
|
+
from = index + 1;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
// ============================================================================
|
|
2054
|
+
// author filtering
|
|
2055
|
+
// ============================================================================
|
|
2056
|
+
/** metadata.author as written by MCPL channel ingestion ({id, name}). Every
|
|
2057
|
+
* incoming channel message is stored with participant "user" — the real
|
|
2058
|
+
* author lives only here. */
|
|
2059
|
+
function authorOf(msg) {
|
|
2060
|
+
const a = msg.metadata?.author;
|
|
2061
|
+
if (!a || typeof a !== 'object')
|
|
2062
|
+
return undefined;
|
|
2063
|
+
const { id, name } = a;
|
|
2064
|
+
return {
|
|
2065
|
+
...(typeof id === 'string' || typeof id === 'number' ? { id: String(id) } : {}),
|
|
2066
|
+
...(typeof name === 'string' ? { name } : {}),
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
function parseSkipSequences(v) {
|
|
2070
|
+
if (v === undefined || v === null)
|
|
1134
2071
|
return null;
|
|
1135
|
-
|
|
2072
|
+
if (!Array.isArray(v) ||
|
|
2073
|
+
v.length !== 2 ||
|
|
2074
|
+
!v.every((x) => typeof x === 'number' && Number.isInteger(x) && x >= 0) ||
|
|
2075
|
+
v[0] > v[1]) {
|
|
2076
|
+
throw new Error('"skipSequences" must be [lo, hi], two non-negative integers with lo <= hi — copy it from `resume`.');
|
|
2077
|
+
}
|
|
2078
|
+
return [v[0], v[1]];
|
|
2079
|
+
}
|
|
2080
|
+
function idOrNull(msg) {
|
|
2081
|
+
return msg ? String(msg.id) : null;
|
|
2082
|
+
}
|
|
2083
|
+
function authorName(msg) {
|
|
2084
|
+
return authorOf(msg)?.name ?? null;
|
|
2085
|
+
}
|
|
2086
|
+
/** Normalize one author spec: trim, case-fold, strip a leading "@", unwrap a
|
|
2087
|
+
* <@id>/<@!id> mention to its id. */
|
|
2088
|
+
function normalizeAuthorSpec(spec) {
|
|
2089
|
+
const s = spec.trim();
|
|
2090
|
+
const mention = /^<@!?([^\s<>@]+)>$/.exec(s);
|
|
2091
|
+
if (mention)
|
|
2092
|
+
return mention[1].toLowerCase();
|
|
2093
|
+
return s.replace(/^@/, '').toLowerCase();
|
|
2094
|
+
}
|
|
2095
|
+
function toAuthorSet(spec, field) {
|
|
2096
|
+
if (spec === undefined)
|
|
2097
|
+
return null;
|
|
2098
|
+
const list = Array.isArray(spec) ? spec : [spec];
|
|
2099
|
+
if (list.some((s) => typeof s !== 'string')) {
|
|
2100
|
+
throw new Error(`"${field}" must be a string or an array of strings.`);
|
|
2101
|
+
}
|
|
2102
|
+
const set = new Set(list.map(normalizeAuthorSpec).filter((s) => s.length > 0));
|
|
2103
|
+
if (set.size === 0)
|
|
2104
|
+
throw new Error(`"${field}" must name at least one author.`);
|
|
2105
|
+
return set;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Predicate for `author`/`excludeAuthor`, or null when neither is given.
|
|
2109
|
+
* A message's identities are its metadata.author name and id plus its
|
|
2110
|
+
* stored participant — the last is what identifies the agent's own turns
|
|
2111
|
+
* (and anything else ingested without author metadata). Exact match after
|
|
2112
|
+
* normalization, never substring: "ann" must not pull in "joanne".
|
|
2113
|
+
*/
|
|
2114
|
+
function buildAuthorFilter(include, exclude) {
|
|
2115
|
+
const inc = toAuthorSet(include, 'author');
|
|
2116
|
+
const exc = toAuthorSet(exclude, 'excludeAuthor');
|
|
2117
|
+
if (!inc && !exc)
|
|
2118
|
+
return null;
|
|
2119
|
+
return (m) => {
|
|
2120
|
+
const a = authorOf(m);
|
|
2121
|
+
// participant only for messages WITHOUT author metadata (the agent's own
|
|
2122
|
+
// turns): every MCPL-ingested message has participant "user", which
|
|
2123
|
+
// would otherwise make author:"user" match everyone.
|
|
2124
|
+
const keys = (a ? [a.name?.toLowerCase(), a.id?.toLowerCase()] : [m.participant?.toLowerCase()]).filter((k) => !!k);
|
|
2125
|
+
if (inc && !keys.some((k) => inc.has(k)))
|
|
2126
|
+
return false;
|
|
2127
|
+
if (exc && keys.some((k) => exc.has(k)))
|
|
2128
|
+
return false;
|
|
2129
|
+
return true;
|
|
2130
|
+
};
|
|
1136
2131
|
}
|
|
1137
2132
|
/** ~SNIPPET_CONTEXT_CHARS of surrounding context on each side of a match, or
|
|
1138
2133
|
* the first ~SNIPPET_FALLBACK_CHARS of content when no meaningful match
|