@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
|
@@ -49,6 +49,7 @@ import type { ToolDefinition, ToolCall, ToolResult, ProcessEvent } from '../../t
|
|
|
49
49
|
import type { EventResponse, ProcessState } from '../../types/module.js';
|
|
50
50
|
import type { SearchWorkerMessage, SearchWorkerMatch } from './search-regex-worker.js';
|
|
51
51
|
import type { ChannelRegistry } from '../../mcpl/channel-registry.js';
|
|
52
|
+
import { SemanticIndexClient, SemanticIndexer, messageIndexText, type SemanticIndexConfig, type SyncReport, type PendingChanges } from './semantic.js';
|
|
52
53
|
|
|
53
54
|
// ============================================================================
|
|
54
55
|
// Tool input shapes
|
|
@@ -60,6 +61,9 @@ interface StatsInput {
|
|
|
60
61
|
channelId?: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** One author spec or several — see `matchesAuthorFilter`. */
|
|
65
|
+
type AuthorSpec = string | string[];
|
|
66
|
+
|
|
63
67
|
interface ExtractInput {
|
|
64
68
|
from?: string;
|
|
65
69
|
to?: string;
|
|
@@ -67,15 +71,37 @@ interface ExtractInput {
|
|
|
67
71
|
limit?: number;
|
|
68
72
|
offset?: number;
|
|
69
73
|
format?: 'text' | 'raw';
|
|
74
|
+
author?: AuthorSpec;
|
|
75
|
+
excludeAuthor?: AuthorSpec;
|
|
76
|
+
maxScan?: number;
|
|
77
|
+
/** Resume point of an author-filtered scan (from a previous `resume`). */
|
|
78
|
+
windowOffset?: number;
|
|
79
|
+
/** Id of the message just before `windowOffset` (from a previous `resume`). */
|
|
80
|
+
afterId?: string | null;
|
|
81
|
+
/** Time-ordered (no channelId) resume only: the store's highest sequence when the previous call ran. */
|
|
82
|
+
seqMark?: number;
|
|
83
|
+
aroundId?: string;
|
|
84
|
+
before?: number;
|
|
85
|
+
after?: number;
|
|
86
|
+
allChannels?: boolean;
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
interface SearchInput {
|
|
73
90
|
query: string;
|
|
74
91
|
regex?: boolean;
|
|
75
92
|
caseSensitive?: boolean;
|
|
93
|
+
wholeWord?: boolean;
|
|
76
94
|
from?: string;
|
|
77
95
|
to?: string;
|
|
78
96
|
channelId?: string;
|
|
97
|
+
author?: AuthorSpec;
|
|
98
|
+
excludeAuthor?: AuthorSpec;
|
|
99
|
+
order?: 'oldest' | 'newest';
|
|
100
|
+
/** Continuation only (from a previous `resume`): messages at exactly the
|
|
101
|
+
* resume bound's millisecond whose sequence lies in [lo, hi] were already
|
|
102
|
+
* scanned. A range, not a count, so a message added or removed at that
|
|
103
|
+
* millisecond between calls can't shift what gets skipped. */
|
|
104
|
+
skipSequences?: [number, number];
|
|
79
105
|
limit?: number;
|
|
80
106
|
maxScan?: number;
|
|
81
107
|
}
|
|
@@ -88,6 +114,22 @@ interface OverviewInput {
|
|
|
88
114
|
limit?: number;
|
|
89
115
|
}
|
|
90
116
|
|
|
117
|
+
interface SemanticSearchInput {
|
|
118
|
+
query: string;
|
|
119
|
+
limit?: number;
|
|
120
|
+
from?: string;
|
|
121
|
+
to?: string;
|
|
122
|
+
channelId?: string;
|
|
123
|
+
kinds?: 'messages' | 'summaries' | 'both';
|
|
124
|
+
level?: number;
|
|
125
|
+
minScore?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface HistoryModuleOptions {
|
|
129
|
+
/** Enable `semantic_search` against a shared embed-service (see ./semantic.ts). Absent = tool not offered. */
|
|
130
|
+
semantic?: SemanticIndexConfig;
|
|
131
|
+
}
|
|
132
|
+
|
|
91
133
|
// ============================================================================
|
|
92
134
|
// Limits
|
|
93
135
|
// ============================================================================
|
|
@@ -109,6 +151,42 @@ const EXTRACT_MAX_LIMIT = 200;
|
|
|
109
151
|
*/
|
|
110
152
|
const NATIVE_OFFSET_MAX = 0xffffffff; // 4294967295
|
|
111
153
|
|
|
154
|
+
/**
|
|
155
|
+
* `extract` with an author filter can't lean on a native index (chronicle
|
|
156
|
+
* indexes time and channel, not author), so it pages through the time/channel
|
|
157
|
+
* window and filters in-process. Bounded like `search`'s maxScan, with the
|
|
158
|
+
* same explicit truncated/scannedThrough signal when the window is larger.
|
|
159
|
+
*/
|
|
160
|
+
const EXTRACT_FILTER_DEFAULT_MAX_SCAN = 10000;
|
|
161
|
+
const EXTRACT_FILTER_MAX_MAX_SCAN = 50000;
|
|
162
|
+
/** How far (in window positions) a filtered-extract resume looks for its
|
|
163
|
+
* `afterId` when the window moved since the previous call. */
|
|
164
|
+
const RELOCATE_RADIUS = 1000;
|
|
165
|
+
/** Most appends a no-channel resume walks back through to check for
|
|
166
|
+
* messages added behind its cursor; past it the check is reported as
|
|
167
|
+
* unverified rather than failing. */
|
|
168
|
+
const APPENDED_SCAN_CAP = 200_000;
|
|
169
|
+
/** Native page size for the in-process filtered scans above. */
|
|
170
|
+
const FILTER_SCAN_PAGE = 1000;
|
|
171
|
+
|
|
172
|
+
/** `extract({aroundId})` window sizes, per side. */
|
|
173
|
+
const AROUND_DEFAULT = 10;
|
|
174
|
+
const AROUND_MAX = 100;
|
|
175
|
+
/** Upper bound on messages sharing the anchor's millisecond that
|
|
176
|
+
* `extract({aroundId})` will fetch to order them by sequence — real stores
|
|
177
|
+
* see a handful; past this the call fails loudly rather than guessing. */
|
|
178
|
+
const AROUND_TIE_CAP = 5000;
|
|
179
|
+
|
|
180
|
+
function tieCapError(anchorId: string, n: number): Error {
|
|
181
|
+
return new Error(
|
|
182
|
+
`Message ${anchorId} shares its timestamp with ${n} messages (more than ${AROUND_TIE_CAP}), too many to order ` +
|
|
183
|
+
'around it. Use extract with from/to set to that instant instead.',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Largest valid Date value (ms) — an open newest edge. */
|
|
188
|
+
const MAX_DATE_MS = 8.64e15;
|
|
189
|
+
|
|
112
190
|
const SEARCH_DEFAULT_LIMIT = 20;
|
|
113
191
|
const SEARCH_MAX_LIMIT = 100;
|
|
114
192
|
const SEARCH_DEFAULT_MAX_SCAN = 5000;
|
|
@@ -126,6 +204,16 @@ const SEARCH_MAX_MAX_SCAN = 50000;
|
|
|
126
204
|
const OVERVIEW_DEFAULT_LIMIT = 50;
|
|
127
205
|
const OVERVIEW_MAX_LIMIT = 200;
|
|
128
206
|
|
|
207
|
+
const SEMANTIC_DEFAULT_LIMIT = 10;
|
|
208
|
+
const SEMANTIC_MAX_LIMIT = 50;
|
|
209
|
+
/** Largest k sent to the embed-service when over-fetching past dropped hits (its own cap is 200). */
|
|
210
|
+
const SEMANTIC_MAX_FETCH = 200;
|
|
211
|
+
/** Local snippet: the leading SEMANTIC_SNIPPET_CHARS of the current text. */
|
|
212
|
+
function snippetOf(text: string): string {
|
|
213
|
+
return text.length <= SEMANTIC_SNIPPET_CHARS ? text : `${text.slice(0, SEMANTIC_SNIPPET_CHARS)}…`;
|
|
214
|
+
}
|
|
215
|
+
const SEMANTIC_SNIPPET_CHARS = 400;
|
|
216
|
+
|
|
129
217
|
/**
|
|
130
218
|
* A `<@id>`/`<@!id>` Discord-style mention — same shape `ChannelRegistry`
|
|
131
219
|
* uses for its own DM mention-form matching. A string matching this can
|
|
@@ -157,12 +245,37 @@ const SEARCH_REGEX_TIMEOUT_MS = 2000;
|
|
|
157
245
|
* directory this module's own compiled output lives in. */
|
|
158
246
|
const SEARCH_WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'search-regex-worker.js');
|
|
159
247
|
|
|
248
|
+
// Declared as an array (tool-schema dialects vary on anyOf); a bare string is
|
|
249
|
+
// also accepted at runtime — see toAuthorSet.
|
|
250
|
+
const AUTHOR_SPEC_JSON_SCHEMA = { type: 'array', items: { type: 'string' } };
|
|
251
|
+
const AUTHOR_SCHEMA = {
|
|
252
|
+
...AUTHOR_SPEC_JSON_SCHEMA,
|
|
253
|
+
description:
|
|
254
|
+
'Only messages written by one of these authors (an array of names or ids). Matches, case-insensitively and exactly, ' +
|
|
255
|
+
'the author\'s display name or user id (a leading "@" or a <@id> mention is accepted), or the ' +
|
|
256
|
+
'stored participant for messages without author metadata — e.g. your own turns, under your name.',
|
|
257
|
+
};
|
|
258
|
+
const EXCLUDE_AUTHOR_SCHEMA = {
|
|
259
|
+
...AUTHOR_SPEC_JSON_SCHEMA,
|
|
260
|
+
description: 'Drop messages written by any of these authors (an array of names or ids). Same matching as `author`.',
|
|
261
|
+
};
|
|
262
|
+
|
|
160
263
|
export class HistoryModule implements Module {
|
|
161
264
|
readonly name = 'history';
|
|
162
265
|
|
|
163
266
|
private ctx: ModuleContext | null = null;
|
|
164
267
|
private cm: ContextManager | null = null;
|
|
165
268
|
private channelRegistry: ChannelRegistry | null = null;
|
|
269
|
+
private readonly semanticCfg: SemanticIndexConfig | null;
|
|
270
|
+
private semanticClient: SemanticIndexClient | null = null;
|
|
271
|
+
private indexer: SemanticIndexer | null = null;
|
|
272
|
+
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
273
|
+
private firstSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
274
|
+
|
|
275
|
+
constructor(options: HistoryModuleOptions = {}) {
|
|
276
|
+
this.semanticCfg = options.semantic ?? null;
|
|
277
|
+
if (this.semanticCfg) this.semanticClient = new SemanticIndexClient(this.semanticCfg);
|
|
278
|
+
}
|
|
166
279
|
|
|
167
280
|
/**
|
|
168
281
|
* Wire the context-manager instance, and optionally the host's
|
|
@@ -180,6 +293,15 @@ export class HistoryModule implements Module {
|
|
|
180
293
|
bind(contextManager: ContextManager, channelRegistry?: ChannelRegistry): void {
|
|
181
294
|
this.cm = contextManager;
|
|
182
295
|
this.channelRegistry = channelRegistry ?? null;
|
|
296
|
+
if (this.semanticCfg && this.semanticClient) {
|
|
297
|
+
this.indexer?.dispose();
|
|
298
|
+
this.indexer = new SemanticIndexer(contextManager, this.semanticClient, this.semanticCfg, (m) => console.warn(m));
|
|
299
|
+
// start() may already have run (framework start before host bind):
|
|
300
|
+
// wire persistence now, or pending changes are neither restored nor saved.
|
|
301
|
+
if (this.ctx) this.wireIndexer(this.ctx);
|
|
302
|
+
else this.indexer.attach();
|
|
303
|
+
this.startSyncTimer();
|
|
304
|
+
}
|
|
183
305
|
}
|
|
184
306
|
|
|
185
307
|
/**
|
|
@@ -258,12 +380,255 @@ export class HistoryModule implements Module {
|
|
|
258
380
|
return cm.queryMessagesByTime({ fromMs: ms, toMs: ms }).messages;
|
|
259
381
|
}
|
|
260
382
|
|
|
383
|
+
/** Messages at exactly `ms` (in `channelId` when given). Bounded by
|
|
384
|
+
* AROUND_TIE_CAP: past it a same-millisecond group is too large to step
|
|
385
|
+
* through by sequence, and the caller should narrow instead. */
|
|
386
|
+
private countAtMs(ms: number, channelId: string | undefined): number {
|
|
387
|
+
const cm = this.cm as ContextManager;
|
|
388
|
+
const n =
|
|
389
|
+
channelId !== undefined
|
|
390
|
+
? cm.queryMessagesByTimeAndChannel({ fromMs: ms, toMs: ms, channelId, limit: 0 }).matchedCount
|
|
391
|
+
: cm.queryMessagesByTime({ fromMs: ms, toMs: ms, limit: AROUND_TIE_CAP + 1 }).messages.length;
|
|
392
|
+
if (n > AROUND_TIE_CAP) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`More than ${AROUND_TIE_CAP} messages share the instant ${new Date(ms).toISOString()}; a continuation can't step ` +
|
|
395
|
+
'through them. Narrow the search with channelId/author instead.',
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return n;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Highest sequence in the store right now (the last append), or -1 when empty. */
|
|
402
|
+
private storeSeqMark(): number {
|
|
403
|
+
const cm = this.cm as ContextManager;
|
|
404
|
+
const count = cm.getMessageCount();
|
|
405
|
+
if (count === 0) return -1;
|
|
406
|
+
return cm.getMessageWindow(count - 1, 1).messages[0]?.sequence ?? -1;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Of the messages appended since `seqMark` (the store's append tail above
|
|
411
|
+
* it, walked back in pages), the ones `relevant` keeps. Unrelated appends
|
|
412
|
+
* cost a look but never count. `complete` is false when the walk stopped
|
|
413
|
+
* at APPENDED_SCAN_CAP before reaching seqMark — the result is then a
|
|
414
|
+
* lower bound, not a verification.
|
|
415
|
+
*/
|
|
416
|
+
private appendedSince(
|
|
417
|
+
seqMark: number,
|
|
418
|
+
relevant: (m: StoredMessage) => boolean,
|
|
419
|
+
): { messages: StoredMessage[]; complete: boolean } {
|
|
420
|
+
const cm = this.cm as ContextManager;
|
|
421
|
+
const out: StoredMessage[] = [];
|
|
422
|
+
let looked = 0;
|
|
423
|
+
let end = cm.getMessageCount();
|
|
424
|
+
while (end > 0) {
|
|
425
|
+
const start = Math.max(0, end - 1000);
|
|
426
|
+
const w = cm.getMessageWindow(start, end - start).messages;
|
|
427
|
+
for (let i = w.length - 1; i >= 0; i--) {
|
|
428
|
+
if (w[i]!.sequence <= seqMark) return { messages: out, complete: true };
|
|
429
|
+
if (relevant(w[i]!)) out.push(w[i]!);
|
|
430
|
+
if (++looked >= APPENDED_SCAN_CAP) return { messages: out, complete: false };
|
|
431
|
+
}
|
|
432
|
+
end = start;
|
|
433
|
+
}
|
|
434
|
+
return { messages: out, complete: true };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Validate a filtered-extract resume position against the window as it is
|
|
439
|
+
* NOW. `afterId` names the message the previous call saw at position
|
|
440
|
+
* `windowOffset - 1`. If it is still there, resume as asked. If the window
|
|
441
|
+
* moved (a removal or an insertion before that point since the previous
|
|
442
|
+
* call), look for it within RELOCATE_RADIUS positions either side and
|
|
443
|
+
* resume just past it, reporting the shift. If it can't be found — it was
|
|
444
|
+
* itself removed, or the window moved further than that — fail loudly:
|
|
445
|
+
* guessing is how a message gets silently skipped.
|
|
446
|
+
*/
|
|
447
|
+
private relocateWindowOffset(
|
|
448
|
+
windowOffset: number,
|
|
449
|
+
afterId: string | null | undefined,
|
|
450
|
+
q: { fromMs?: number; toMs?: number; channelId?: string },
|
|
451
|
+
): { windowOffset: number; shift: number } {
|
|
452
|
+
if (windowOffset === 0) {
|
|
453
|
+
if (afterId != null) throw new Error('"afterId" only applies together with a windowOffset > 0.');
|
|
454
|
+
return { windowOffset, shift: 0 };
|
|
455
|
+
}
|
|
456
|
+
if (afterId == null) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
'"windowOffset" needs the "afterId" from the same `resume` object — without it a message added or removed ' +
|
|
459
|
+
'since the previous call would be silently skipped. Pass the whole `resume` object back.',
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const cm = this.cm as ContextManager;
|
|
463
|
+
const at = cm.queryMessagesByTimeAndChannel({ ...q, limit: 1, offset: windowOffset - 1 }).messages[0];
|
|
464
|
+
if (at && String(at.id) === afterId) return { windowOffset, shift: 0 };
|
|
465
|
+
const lo = Math.max(0, windowOffset - 1 - RELOCATE_RADIUS);
|
|
466
|
+
const around = cm.queryMessagesByTimeAndChannel({ ...q, limit: 2 * RELOCATE_RADIUS + 1, offset: lo }).messages;
|
|
467
|
+
const i = around.findIndex((m) => String(m.id) === afterId);
|
|
468
|
+
if (i === -1) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`The window changed since the previous call: message ${afterId}, where that scan stopped, is no longer ` +
|
|
471
|
+
`within ${RELOCATE_RADIUS} positions of windowOffset ${windowOffset} (it may have been deleted). ` +
|
|
472
|
+
'Restart the scan without windowOffset/afterId, or narrow it with from/to.',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const relocated = lo + i + 1;
|
|
476
|
+
return { windowOffset: relocated, shift: relocated - windowOffset };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The `n` messages of a time/channel range nearest one of its edges, in
|
|
481
|
+
* TIMESTAMP order walking away from that edge (newest-first for
|
|
482
|
+
* side:"newest", oldest-first for side:"oldest"), plus whether the range
|
|
483
|
+
* holds more than `n`.
|
|
484
|
+
*
|
|
485
|
+
* Why not just `queryMessagesByTimeAndChannel` + offset: (a) a time-only
|
|
486
|
+
* query's `matchedCount` is the returned PAGE size, not a total (see
|
|
487
|
+
* context-manager MessageStore.queryByTime), so it can't locate a tail;
|
|
488
|
+
* (b) channel-scoped results come back in APPEND (ordinal) order, and
|
|
489
|
+
* Discord catch-up appends old-timestamped messages late — an ordinal
|
|
490
|
+
* tail is not the timestamp tail.
|
|
491
|
+
*
|
|
492
|
+
* No channel: chronicle's timestamp index answers directly (`reverse`
|
|
493
|
+
* for the newest edge). With a channel: channel queries report an exact
|
|
494
|
+
* matchedCount, so grow a time window out from the edge (×4 per step)
|
|
495
|
+
* until it holds ≥ n channel messages or covers the whole range. If that
|
|
496
|
+
* window holds more than `fetchCap` (a density jump across one step, or a
|
|
497
|
+
* burst), bisect its far bound between the last step that held < n and
|
|
498
|
+
* this one — counts are exact, so it lands on a window of [n, fetchCap].
|
|
499
|
+
* Only when a single millisecond holds the overflow does bisection bottom
|
|
500
|
+
* out; then the window is everything strictly nearer the edge plus that
|
|
501
|
+
* millisecond's nearest messages by sequence, which within one millisecond
|
|
502
|
+
* IS the (timestamp, sequence) order — still exact. The newest side is
|
|
503
|
+
* left open when `toMs` is (clock-skewed future stamps stay visible).
|
|
504
|
+
*/
|
|
505
|
+
private edgeWindow(opts: {
|
|
506
|
+
fromMs?: number;
|
|
507
|
+
toMs?: number;
|
|
508
|
+
channelId?: string;
|
|
509
|
+
n: number;
|
|
510
|
+
side: 'newest' | 'oldest';
|
|
511
|
+
}): { messages: StoredMessage[]; more: boolean } {
|
|
512
|
+
const cm = this.cm as ContextManager;
|
|
513
|
+
const { fromMs, toMs, channelId, n, side } = opts;
|
|
514
|
+
const newest = side === 'newest';
|
|
515
|
+
const cmp = (a: StoredMessage, b: StoredMessage) =>
|
|
516
|
+
a.timestamp.getTime() - b.timestamp.getTime() || a.sequence - b.sequence;
|
|
517
|
+
const dirSort = (ms: StoredMessage[]) => ms.sort(newest ? (a, b) => cmp(b, a) : cmp);
|
|
518
|
+
|
|
519
|
+
if (channelId === undefined) {
|
|
520
|
+
const r = cm.queryMessagesByTime({ fromMs, toMs, limit: n + 1, reverse: newest });
|
|
521
|
+
return { messages: r.messages.slice(0, n), more: r.messages.length > n };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const count = (f?: number, t?: number) =>
|
|
525
|
+
cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit: 0 }).matchedCount;
|
|
526
|
+
const fetch = (f: number | undefined, t: number | undefined, limit: number, offset = 0) =>
|
|
527
|
+
limit <= 0 ? [] : cm.queryMessagesByTimeAndChannel({ fromMs: f, toMs: t, channelId, limit, offset }).messages;
|
|
528
|
+
const total = count(fromMs, toMs);
|
|
529
|
+
if (n === 0) return { messages: [], more: total > 0 };
|
|
530
|
+
if (total <= n) return { messages: dirSort(fetch(fromMs, toMs, total)), more: false };
|
|
531
|
+
|
|
532
|
+
// The window is parameterised by its FAR bound `e`: [e, toMs] for the
|
|
533
|
+
// newest side, [fromMs, e] for the oldest; its count is monotone in e.
|
|
534
|
+
const win = (e: number): [number | undefined, number | undefined] => (newest ? [e, toMs] : [fromMs, e]);
|
|
535
|
+
const cnt = (e: number) => count(...win(e));
|
|
536
|
+
const lo = fromMs ?? this.earliestMessageMs(toMs) ?? 0;
|
|
537
|
+
const hi = toMs ?? Date.now(); // growth anchor only — never a bound
|
|
538
|
+
// `outside`: a far bound whose window holds < n (initially empty).
|
|
539
|
+
// `inside`: one whose window holds ≥ n (initially the whole range).
|
|
540
|
+
let outside: number | null = null;
|
|
541
|
+
let inside = newest ? lo : (toMs ?? MAX_DATE_MS);
|
|
542
|
+
let inCount = total;
|
|
543
|
+
for (let width = 10 * 60_000; ; width *= 4) {
|
|
544
|
+
const e = newest ? hi - width : lo + width;
|
|
545
|
+
if (newest ? e <= lo : e >= hi) break; // covered: keep the whole range
|
|
546
|
+
const c = cnt(e);
|
|
547
|
+
if (c >= n) {
|
|
548
|
+
inside = e;
|
|
549
|
+
inCount = c;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
outside = e;
|
|
553
|
+
}
|
|
554
|
+
const fetchCap = Math.max(4 * n, n + 1000);
|
|
555
|
+
if (inCount <= fetchCap) {
|
|
556
|
+
return { messages: dirSort(fetch(...win(inside), inCount)).slice(0, n), more: true };
|
|
557
|
+
}
|
|
558
|
+
// Overshoot: bisect the far bound. The empty side starts one past the
|
|
559
|
+
// range's near end (newest: past toMs / any date; oldest: before lo).
|
|
560
|
+
const emptyOut = newest ? (toMs ?? MAX_DATE_MS) + 1 : lo - 1;
|
|
561
|
+
let out = outside ?? emptyOut;
|
|
562
|
+
while (Math.abs(inside - out) > 1) {
|
|
563
|
+
const mid = Math.floor((inside + out) / 2);
|
|
564
|
+
const c = cnt(mid);
|
|
565
|
+
if (c < n) {
|
|
566
|
+
out = mid;
|
|
567
|
+
} else {
|
|
568
|
+
inside = mid;
|
|
569
|
+
inCount = c;
|
|
570
|
+
if (c <= fetchCap) {
|
|
571
|
+
return { messages: dirSort(fetch(...win(mid), c)).slice(0, n), more: true };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Millisecond `inside` alone carries the overflow. Everything nearer the
|
|
576
|
+
// edge (the `out` window, < n messages) plus the nearest-by-sequence
|
|
577
|
+
// messages of that millisecond (channel pages are append = sequence
|
|
578
|
+
// ordered, which is the tie order within one millisecond).
|
|
579
|
+
const near = out === emptyOut ? [] : fetch(...win(out), cnt(out));
|
|
580
|
+
const need = n - near.length;
|
|
581
|
+
const ties = count(inside, inside);
|
|
582
|
+
const edgeMs = fetch(inside, inside, need, newest ? Math.max(0, ties - need) : 0);
|
|
583
|
+
return { messages: dirSort([...near, ...edgeMs]).slice(0, n), more: true };
|
|
584
|
+
}
|
|
585
|
+
|
|
261
586
|
async start(ctx: ModuleContext): Promise<void> {
|
|
262
587
|
this.ctx = ctx;
|
|
588
|
+
this.wireIndexer(ctx);
|
|
589
|
+
this.startSyncTimer();
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Pending edits/removals survive a restart through module state. Called from whichever of start()/bind() runs second. */
|
|
593
|
+
private wireIndexer(ctx: ModuleContext): void {
|
|
594
|
+
if (!this.indexer) return;
|
|
595
|
+
this.indexer.restorePending(ctx.getState<{ semanticPending?: PendingChanges }>()?.semanticPending);
|
|
596
|
+
this.indexer.onPendingChange = (p) => {
|
|
597
|
+
const prev = this.ctx?.getState<Record<string, unknown>>() ?? {};
|
|
598
|
+
this.ctx?.setState({ ...prev, semanticPending: p });
|
|
599
|
+
};
|
|
600
|
+
this.indexer.attach();
|
|
263
601
|
}
|
|
264
602
|
|
|
265
603
|
async stop(): Promise<void> {
|
|
266
604
|
this.ctx = null;
|
|
605
|
+
if (this.syncTimer) { clearInterval(this.syncTimer); this.syncTimer = null; }
|
|
606
|
+
if (this.firstSyncTimer) { clearTimeout(this.firstSyncTimer); this.firstSyncTimer = null; }
|
|
607
|
+
if (this.indexer) this.indexer.onPendingChange = null;
|
|
608
|
+
this.indexer?.dispose();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Background sync into the semantic index: every `syncIntervalMs` (default
|
|
613
|
+
* 60 s) push up to `maxSyncPerTick` new items. Idempotent to call — starts
|
|
614
|
+
* once we have both a context-manager (bind) and a config; `unref`'d so it
|
|
615
|
+
* never keeps a shutting-down process alive. A first tick runs after 5 s so
|
|
616
|
+
* a fresh store starts backfilling immediately rather than a minute later.
|
|
617
|
+
*/
|
|
618
|
+
private startSyncTimer(): void {
|
|
619
|
+
if (this.syncTimer || !this.indexer || !this.semanticCfg) return;
|
|
620
|
+
const interval = this.semanticCfg.syncIntervalMs ?? 60_000;
|
|
621
|
+
if (interval <= 0) return;
|
|
622
|
+
const perTick = this.semanticCfg.maxSyncPerTick ?? 1024;
|
|
623
|
+
const tick = (): void => { void this.indexer?.catchUp(perTick); };
|
|
624
|
+
this.firstSyncTimer = setTimeout(() => { this.firstSyncTimer = null; tick(); }, 5_000);
|
|
625
|
+
this.firstSyncTimer.unref?.();
|
|
626
|
+
this.syncTimer = setInterval(tick, interval); this.syncTimer.unref?.();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Exposed for hosts/tests: one sync pass now. */
|
|
630
|
+
syncSemanticIndex(maxItems = 1024): Promise<SyncReport> | null {
|
|
631
|
+
return this.indexer ? this.indexer.catchUp(maxItems) : null;
|
|
267
632
|
}
|
|
268
633
|
|
|
269
634
|
getTools(): ToolDefinition[] {
|
|
@@ -291,15 +656,33 @@ export class HistoryModule implements Module {
|
|
|
291
656
|
'Fetch raw, uncompressed messages for a time range and/or channel, paginated oldest-first. ' +
|
|
292
657
|
'Use after `stats` to pull the actual content. format:"text" flattens each message to a short ' +
|
|
293
658
|
'readable string (tool_use/tool_result/thinking/images rendered as bracketed labels); ' +
|
|
294
|
-
'format:"raw" returns the content blocks unmodified.'
|
|
659
|
+
'format:"raw" returns the content blocks unmodified. ' +
|
|
660
|
+
'Two extra modes: (1) aroundId — pass a message id (e.g. from `search`) to get the conversation ' +
|
|
661
|
+
'around it: `before` messages before it, the message itself (anchor:true), and `after` messages ' +
|
|
662
|
+
'after it, in the anchor\'s own channel unless channelId/allChannels says otherwise; from/to/offset/limit ' +
|
|
663
|
+
'do not apply in this mode (at most before+after+1 messages). (2) author/excludeAuthor — keep only (or drop) messages by these ' +
|
|
664
|
+
'authors; this is filtered in-process over at most maxScan messages of the window, so a response ' +
|
|
665
|
+
'may report truncated:true with a `resume` object ({windowOffset, offset, afterId}) — repeat the call with ' +
|
|
666
|
+
'those fields added to continue exactly where it stopped. If messages were added or removed in the window ' +
|
|
667
|
+
'meanwhile, the resume re-anchors on afterId and says so (windowChanged), or fails loudly when it can\'t.',
|
|
295
668
|
inputSchema: {
|
|
296
669
|
type: 'object' as const,
|
|
297
670
|
properties: {
|
|
298
671
|
from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
|
|
299
672
|
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
300
673
|
channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
|
|
674
|
+
author: AUTHOR_SCHEMA,
|
|
675
|
+
excludeAuthor: EXCLUDE_AUTHOR_SCHEMA,
|
|
676
|
+
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.' },
|
|
677
|
+
before: { type: 'number', description: `With aroundId: messages to include before the anchor (default ${AROUND_DEFAULT}, cap ${AROUND_MAX}).` },
|
|
678
|
+
after: { type: 'number', description: `With aroundId: messages to include after the anchor (default ${AROUND_DEFAULT}, cap ${AROUND_MAX}).` },
|
|
679
|
+
allChannels: { type: 'boolean', description: 'With aroundId: interleave every channel around the anchor\'s time instead of staying in its channel (default false).' },
|
|
301
680
|
limit: { type: 'number', description: `Max messages to return (default ${EXTRACT_DEFAULT_LIMIT}, hard cap ${EXTRACT_MAX_LIMIT}). Must be a non-negative integer.` },
|
|
302
681
|
offset: { type: 'number', description: `Number of matching messages to skip (default 0, capped to ${NATIVE_OFFSET_MAX}). Must be a non-negative integer.` },
|
|
682
|
+
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}).` },
|
|
683
|
+
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`.' },
|
|
684
|
+
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.' },
|
|
685
|
+
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.' },
|
|
303
686
|
format: { type: 'string', enum: ['text', 'raw'], description: 'Content rendering (default "text").' },
|
|
304
687
|
},
|
|
305
688
|
},
|
|
@@ -311,7 +694,14 @@ export class HistoryModule implements Module {
|
|
|
311
694
|
'range/channel window. Narrows candidates via the same query as `extract` before matching, up ' +
|
|
312
695
|
'to maxScan candidates — if the narrowed window is larger than maxScan, the response reports ' +
|
|
313
696
|
'truncated:true up front rather than silently missing later matches; narrow the filter or raise ' +
|
|
314
|
-
'maxScan and retry.
|
|
697
|
+
'maxScan and retry. When the scan stops early (truncated, or `limit` matches reached) the response ' +
|
|
698
|
+
'carries a `resume` object ({from|to, skipSequences}): repeat the call with its fields to continue ' +
|
|
699
|
+
'(scannedThrough is the same instant, for reading). ' +
|
|
700
|
+
'order:"newest" scans the most recent maxScan messages of the window first — usually what you want ' +
|
|
701
|
+
'for "when did this last come up". author/excludeAuthor narrow by who wrote the message; ' +
|
|
702
|
+
'wholeWord:true stops "mission" from matching inside "uncommissioned". Each match carries an `id` ' +
|
|
703
|
+
'you can hand to extract({aroundId}) to read the conversation around it. ' +
|
|
704
|
+
'regex:true matching runs under a wall-clock deadline and is cleanly failed ' +
|
|
315
705
|
`(not silently empty) if a pattern is too slow — avoid nested-quantifier patterns like (a+)+.`,
|
|
316
706
|
inputSchema: {
|
|
317
707
|
type: 'object' as const,
|
|
@@ -319,11 +709,20 @@ export class HistoryModule implements Module {
|
|
|
319
709
|
query: { type: 'string', description: 'Substring (or regex source, when regex:true) to search for.' },
|
|
320
710
|
regex: { type: 'boolean', description: 'Treat query as a regular expression (default false).' },
|
|
321
711
|
caseSensitive: { type: 'boolean', description: 'Case-sensitive match (default false).' },
|
|
712
|
+
wholeWord: { type: 'boolean', description: 'Substring mode only: match whole words, not inside longer words (default false). In regex mode use \\b yourself.' },
|
|
322
713
|
from: { type: 'string', description: 'ISO 8601 inclusive lower bound. Omit for open-ended.' },
|
|
323
714
|
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
324
715
|
channelId: { type: 'string', description: 'Restrict to one channel. Accepts a channel label (e.g. "#general") or the raw internal channel id.' },
|
|
716
|
+
author: AUTHOR_SCHEMA,
|
|
717
|
+
excludeAuthor: EXCLUDE_AUTHOR_SCHEMA,
|
|
718
|
+
order: { type: 'string', enum: ['oldest', 'newest'], description: 'Scan and return oldest-first (default) or newest-first.' },
|
|
325
719
|
limit: { type: 'number', description: `Max matches to return (default ${SEARCH_DEFAULT_LIMIT}, hard cap ${SEARCH_MAX_LIMIT}). Must be a non-negative integer.` },
|
|
326
720
|
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.` },
|
|
721
|
+
skipSequences: {
|
|
722
|
+
type: 'array',
|
|
723
|
+
items: { type: 'number' },
|
|
724
|
+
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.',
|
|
725
|
+
},
|
|
327
726
|
},
|
|
328
727
|
required: ['query'],
|
|
329
728
|
},
|
|
@@ -373,9 +772,45 @@ export class HistoryModule implements Module {
|
|
|
373
772
|
},
|
|
374
773
|
},
|
|
375
774
|
},
|
|
775
|
+
...(this.semanticCfg ? [this.semanticSearchTool()] : []),
|
|
376
776
|
];
|
|
377
777
|
}
|
|
378
778
|
|
|
779
|
+
private semanticSearchTool(): ToolDefinition {
|
|
780
|
+
return {
|
|
781
|
+
name: 'semantic_search',
|
|
782
|
+
description:
|
|
783
|
+
'Search your own history by MEANING, not exact words: an embedding index over your raw messages ' +
|
|
784
|
+
'(text plus your own think/journal/skip_reply notes) and every compression summary. Use it when you ' +
|
|
785
|
+
'remember roughly what something was about but not the words — "the night the fluid sim was read ' +
|
|
786
|
+
'back to me as art" — then narrow with `from`/`to`/`channelId` and drill into the exact span with ' +
|
|
787
|
+
'`extract` or `overview` (for a message hit, `extract({ aroundId: messageId })` opens the conversation ' +
|
|
788
|
+
'around it — use the raw `messageId` field, not the `msg:` id). Results are ranked by cosine similarity (score ~0.6+ is a strong match, ' +
|
|
789
|
+
'~0.3 is thematic, below ~0.2 is noise); each hit carries its id (`msg:<id>` or `sum:<id>`), ' +
|
|
790
|
+
'timestamp, channel, kind/level and a snippet. The index catches up with recent messages before ' +
|
|
791
|
+
'searching (bounded, so a huge backlog is reported as `index.behind` rather than blocking; if a ' +
|
|
792
|
+
'background sync is already running, the search waits for that run to finish instead). ' +
|
|
793
|
+
'Purely a read: nothing is written to your history. Hits are checked against your current branch ' +
|
|
794
|
+
'before they come back: a message you undid or a summary from a branch you left is dropped and ' +
|
|
795
|
+
'counted in index.droppedOffBranch; a message edited down to nothing searchable is dropped and ' +
|
|
796
|
+
'counted in index.droppedStale. Message snippets always show the current text of the message.',
|
|
797
|
+
inputSchema: {
|
|
798
|
+
type: 'object' as const,
|
|
799
|
+
properties: {
|
|
800
|
+
query: { type: 'string', description: 'What you are looking for, in natural language. A sentence works better than keywords.' },
|
|
801
|
+
limit: { type: 'number', description: `Max hits (default ${SEMANTIC_DEFAULT_LIMIT}, cap ${SEMANTIC_MAX_LIMIT}).` },
|
|
802
|
+
from: { type: 'string', description: 'ISO 8601 inclusive lower bound on the message timestamp / summary span start. Omit for open-ended.' },
|
|
803
|
+
to: { type: 'string', description: 'ISO 8601 inclusive upper bound. Omit for open-ended.' },
|
|
804
|
+
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.' },
|
|
805
|
+
kinds: { type: 'string', enum: ['messages', 'summaries', 'both'], description: 'What to search: raw messages, compression summaries, or both (default).' },
|
|
806
|
+
level: { type: 'number', description: 'Only summaries of this exact level (implies kinds=summaries).' },
|
|
807
|
+
minScore: { type: 'number', description: 'Drop hits below this cosine score (0..1). Default none.' },
|
|
808
|
+
},
|
|
809
|
+
required: ['query'],
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
379
814
|
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
380
815
|
try {
|
|
381
816
|
if (!this.cm) {
|
|
@@ -390,6 +825,8 @@ export class HistoryModule implements Module {
|
|
|
390
825
|
return await this.handleSearch((call.input ?? {}) as SearchInput);
|
|
391
826
|
case 'overview':
|
|
392
827
|
return this.handleOverview((call.input ?? {}) as OverviewInput);
|
|
828
|
+
case 'semantic_search':
|
|
829
|
+
return await this.handleSemanticSearch((call.input ?? {}) as SemanticSearchInput);
|
|
393
830
|
default:
|
|
394
831
|
return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
|
|
395
832
|
}
|
|
@@ -459,28 +896,320 @@ export class HistoryModule implements Module {
|
|
|
459
896
|
// ==========================================================================
|
|
460
897
|
|
|
461
898
|
private handleExtract(input: ExtractInput): ToolResult {
|
|
899
|
+
if (input.aroundId !== undefined) return this.handleExtractAround(input);
|
|
900
|
+
if (input.before !== undefined || input.after !== undefined || input.allChannels !== undefined) {
|
|
901
|
+
throw new Error('"before"/"after"/"allChannels" only apply together with "aroundId".');
|
|
902
|
+
}
|
|
462
903
|
const channelId = this.resolveChannel(input.channelId);
|
|
463
904
|
const fromMs = parseIsoDate(input.from, 'from');
|
|
464
905
|
const toMs = parseIsoDate(input.to, 'to');
|
|
465
906
|
const limit = clampCount(input.limit, EXTRACT_DEFAULT_LIMIT, EXTRACT_MAX_LIMIT, 'limit');
|
|
466
907
|
const offset = clampCount(input.offset, 0, NATIVE_OFFSET_MAX, 'offset');
|
|
467
908
|
const format = input.format ?? 'text';
|
|
909
|
+
const authorFilter = buildAuthorFilter(input.author, input.excludeAuthor);
|
|
468
910
|
|
|
469
911
|
const cm = this.cm as ContextManager;
|
|
912
|
+
if (!authorFilter && (input.windowOffset !== undefined || input.afterId !== undefined || input.seqMark !== undefined)) {
|
|
913
|
+
throw new Error('"windowOffset"/"afterId" only apply together with author/excludeAuthor (they resume a filtered scan). Use offset.');
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (authorFilter) {
|
|
917
|
+
// No native author index: page through the time/channel window and
|
|
918
|
+
// filter here. offset/limit apply to the FILTERED sequence. Stop as
|
|
919
|
+
// soon as the requested page is full; otherwise scan to maxScan. Only
|
|
920
|
+
// an exhausted window yields an exact matchedCount — anything else is
|
|
921
|
+
// reported as truncated with a resume point, never as a total.
|
|
922
|
+
//
|
|
923
|
+
// Resume is by WINDOW POSITION (`windowOffset`), never by timestamp: a
|
|
924
|
+
// channel-scoped window pages in append order, and Discord catch-up
|
|
925
|
+
// appends old-timestamped messages late, so "continue from the last
|
|
926
|
+
// timestamp seen" would skip them.
|
|
927
|
+
const maxScan = clampCount(input.maxScan, EXTRACT_FILTER_DEFAULT_MAX_SCAN, EXTRACT_FILTER_MAX_MAX_SCAN, 'maxScan');
|
|
928
|
+
if (maxScan === 0) throw new Error('"maxScan" must be at least 1 for an author-filtered extract.');
|
|
929
|
+
const requestedOffset = clampCount(input.windowOffset, 0, NATIVE_OFFSET_MAX, 'windowOffset');
|
|
930
|
+
// A position is only meaningful against the window it was taken in:
|
|
931
|
+
// a removal before it (every discord:delete, host hide, /undo) shifts
|
|
932
|
+
// later positions left, and a message inserted before it (a
|
|
933
|
+
// time-ordered window with backfill) shifts them right. Either would
|
|
934
|
+
// silently skip or repeat a message. afterId pins the position to the
|
|
935
|
+
// message that was there; relocate it if the window moved.
|
|
936
|
+
const { windowOffset, shift } = this.relocateWindowOffset(
|
|
937
|
+
requestedOffset,
|
|
938
|
+
input.afterId,
|
|
939
|
+
{ fromMs, toMs, channelId },
|
|
940
|
+
);
|
|
941
|
+
// A time-ordered window (no channelId) can also take an older-stamped
|
|
942
|
+
// insertion BEFORE the cursor; paired with a removal there, positions
|
|
943
|
+
// balance out (shift 0) and afterId alone can't see it. Appends carry
|
|
944
|
+
// the highest sequences, so everything appended since the previous
|
|
945
|
+
// call is the store's append tail above its seqMark: check those
|
|
946
|
+
// directly. (A channel window pages in append order, so an append
|
|
947
|
+
// always lands after the cursor and is simply scanned.)
|
|
948
|
+
const seqMarkNow = channelId === undefined ? this.storeSeqMark() : undefined;
|
|
949
|
+
let missed: StoredMessage[] = [];
|
|
950
|
+
let unverified = false;
|
|
951
|
+
if (channelId === undefined && windowOffset > 0) {
|
|
952
|
+
if (typeof input.seqMark !== 'number') {
|
|
953
|
+
throw new Error(
|
|
954
|
+
'"windowOffset" without channelId needs the "seqMark" from the same `resume` object. Pass the whole `resume` object back.',
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
const cursor = cm.queryMessagesByTimeAndChannel({ fromMs, toMs, limit: 1, offset: windowOffset - 1 }).messages[0];
|
|
958
|
+
if (cursor) {
|
|
959
|
+
const ct = cursor.timestamp.getTime();
|
|
960
|
+
const r = this.appendedSince(input.seqMark, (m) => {
|
|
961
|
+
const ts = m.timestamp.getTime();
|
|
962
|
+
if ((fromMs !== undefined && ts < fromMs) || (toMs !== undefined && ts > toMs)) return false;
|
|
963
|
+
return ts < ct || (ts === ct && m.sequence < cursor.sequence);
|
|
964
|
+
});
|
|
965
|
+
missed = r.messages;
|
|
966
|
+
unverified = !r.complete;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const page: StoredMessage[] = [];
|
|
970
|
+
let kept = 0;
|
|
971
|
+
let scanned = 0;
|
|
972
|
+
let lastScanned: StoredMessage | undefined;
|
|
973
|
+
// Window position just past the last message put on the page, and that message.
|
|
974
|
+
let afterPage = windowOffset;
|
|
975
|
+
let lastOnPage: StoredMessage | undefined;
|
|
976
|
+
let exhausted = false;
|
|
977
|
+
let pageFull = false;
|
|
978
|
+
outer: for (;;) {
|
|
979
|
+
const want = Math.min(FILTER_SCAN_PAGE, maxScan - scanned);
|
|
980
|
+
if (want <= 0) break;
|
|
981
|
+
const chunk = cm.queryMessagesByTimeAndChannel({
|
|
982
|
+
fromMs,
|
|
983
|
+
toMs,
|
|
984
|
+
channelId,
|
|
985
|
+
limit: want,
|
|
986
|
+
offset: Math.min(windowOffset + scanned, NATIVE_OFFSET_MAX),
|
|
987
|
+
}).messages;
|
|
988
|
+
for (const m of chunk) {
|
|
989
|
+
scanned++;
|
|
990
|
+
lastScanned = m;
|
|
991
|
+
if (!authorFilter(m)) continue;
|
|
992
|
+
if (kept >= offset && page.length < limit) {
|
|
993
|
+
page.push(m);
|
|
994
|
+
afterPage = windowOffset + scanned;
|
|
995
|
+
lastOnPage = m;
|
|
996
|
+
}
|
|
997
|
+
kept++;
|
|
998
|
+
if (limit > 0 && page.length >= limit && kept > offset + limit) {
|
|
999
|
+
// One filtered match beyond the page proves there is more;
|
|
1000
|
+
// no need to keep scanning.
|
|
1001
|
+
pageFull = true;
|
|
1002
|
+
break outer;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (chunk.length < want) {
|
|
1006
|
+
exhausted = true;
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (!exhausted && !pageFull && scanned >= maxScan) {
|
|
1011
|
+
// Probe one past maxScan: a window of exactly maxScan is complete.
|
|
1012
|
+
const next = cm.queryMessagesByTimeAndChannel({
|
|
1013
|
+
fromMs,
|
|
1014
|
+
toMs,
|
|
1015
|
+
channelId,
|
|
1016
|
+
limit: 1,
|
|
1017
|
+
offset: Math.min(windowOffset + scanned, NATIVE_OFFSET_MAX),
|
|
1018
|
+
}).messages;
|
|
1019
|
+
if (next.length === 0) exhausted = true;
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
success: true,
|
|
1023
|
+
data: {
|
|
1024
|
+
// A resumed scan (windowOffset > 0) only saw the window from that
|
|
1025
|
+
// position on, so its count is named for that — never a total. A
|
|
1026
|
+
// page-full stop's lookahead match lies past the resume point and
|
|
1027
|
+
// is left out, so counts add up across a chain of resumes.
|
|
1028
|
+
...(() => {
|
|
1029
|
+
const n = pageFull ? kept - 1 : kept;
|
|
1030
|
+
const base = windowOffset > 0 ? 'matchedSinceWindowOffset' : 'matchedCount';
|
|
1031
|
+
return { [exhausted ? base : `${base}AtLeast`]: n };
|
|
1032
|
+
})(),
|
|
1033
|
+
returned: page.length,
|
|
1034
|
+
scanned,
|
|
1035
|
+
truncated: !exhausted,
|
|
1036
|
+
...(shift !== 0 || missed.length > 0 || unverified
|
|
1037
|
+
? {
|
|
1038
|
+
windowChanged: {
|
|
1039
|
+
shift,
|
|
1040
|
+
...(unverified
|
|
1041
|
+
? {
|
|
1042
|
+
unverified: true,
|
|
1043
|
+
unverifiedNote:
|
|
1044
|
+
`More than ${APPENDED_SCAN_CAP} messages were appended since the previous call; only the newest ` +
|
|
1045
|
+
'were checked, so messages may have been added behind the resume point unseen. Restart without ' +
|
|
1046
|
+
'windowOffset, or narrow with from/to/channelId, if that matters.',
|
|
1047
|
+
}
|
|
1048
|
+
: {}),
|
|
1049
|
+
...(channelId === undefined
|
|
1050
|
+
? {
|
|
1051
|
+
addedBefore: missed.length,
|
|
1052
|
+
// the ones this filter would have kept — fetch them with extract({aroundId})
|
|
1053
|
+
missedIds: missed.filter(authorFilter).map((m) => String(m.id)),
|
|
1054
|
+
}
|
|
1055
|
+
: {}),
|
|
1056
|
+
note:
|
|
1057
|
+
missed.length > 0 || shift > 0
|
|
1058
|
+
? `${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` +
|
|
1059
|
+
(channelId === undefined ? ' (matching ones listed in missedIds).' : '.')
|
|
1060
|
+
: shift < 0
|
|
1061
|
+
? `${-shift} message(s) before the resume point were removed since the previous call; the resume was re-anchored, nothing skipped.`
|
|
1062
|
+
: 'No change found behind the resume point, but the check was incomplete (see unverifiedNote).',
|
|
1063
|
+
},
|
|
1064
|
+
}
|
|
1065
|
+
: {}),
|
|
1066
|
+
...(!exhausted
|
|
1067
|
+
? (() => {
|
|
1068
|
+
// pageFull: resume just past the last returned message, no
|
|
1069
|
+
// skip. maxScan: resume past everything scanned, still owing
|
|
1070
|
+
// whatever part of `offset` was not yet consumed.
|
|
1071
|
+
// afterId = the message at position windowOffset-1 (see
|
|
1072
|
+
// relocateWindowOffset). When nothing was scanned (no
|
|
1073
|
+
// chunk came back), carry the incoming anchor forward.
|
|
1074
|
+
const mark = seqMarkNow !== undefined ? { seqMark: seqMarkNow } : {};
|
|
1075
|
+
const resume = pageFull
|
|
1076
|
+
? { windowOffset: afterPage, offset: 0, afterId: idOrNull(lastOnPage) ?? input.afterId ?? null, ...mark }
|
|
1077
|
+
: {
|
|
1078
|
+
windowOffset: windowOffset + scanned,
|
|
1079
|
+
offset: Math.max(0, offset - kept),
|
|
1080
|
+
afterId: idOrNull(lastScanned) ?? input.afterId ?? null,
|
|
1081
|
+
...mark,
|
|
1082
|
+
};
|
|
1083
|
+
return {
|
|
1084
|
+
resume,
|
|
1085
|
+
hint:
|
|
1086
|
+
(pageFull
|
|
1087
|
+
? 'More matching messages exist. '
|
|
1088
|
+
: `Stopped after scanning ${scanned} messages of the window without reaching its end. `) +
|
|
1089
|
+
`Continue by repeating this call with the same from/to/channelId/author plus ` +
|
|
1090
|
+
`the fields of \`resume\` (windowOffset:${resume.windowOffset}, offset:${resume.offset}, afterId).`,
|
|
1091
|
+
};
|
|
1092
|
+
})()
|
|
1093
|
+
: {}),
|
|
1094
|
+
messages: page.map((msg) => projectMessage(msg, format)),
|
|
1095
|
+
},
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// One extra row answers "is there another page" in every case. The
|
|
1100
|
+
// native matchedCount is a true total only for channel-scoped queries;
|
|
1101
|
+
// for time-only/unfiltered ones it is just the page size (see
|
|
1102
|
+
// edgeWindow), so it is reported only where it means what it says.
|
|
470
1103
|
const result = cm.queryMessagesByTimeAndChannel({
|
|
471
1104
|
fromMs,
|
|
472
1105
|
toMs,
|
|
473
1106
|
channelId,
|
|
474
|
-
limit,
|
|
1107
|
+
limit: limit + 1,
|
|
475
1108
|
offset,
|
|
476
1109
|
});
|
|
1110
|
+
const page = result.messages.slice(0, limit);
|
|
1111
|
+
|
|
1112
|
+
return {
|
|
1113
|
+
success: true,
|
|
1114
|
+
data: {
|
|
1115
|
+
...(channelId !== undefined ? { matchedCount: result.matchedCount } : {}),
|
|
1116
|
+
returned: page.length,
|
|
1117
|
+
hasMore: result.messages.length > limit,
|
|
1118
|
+
messages: page.map((msg) => projectMessage(msg, format)),
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* `extract({aroundId})` — the conversation around one message, like
|
|
1125
|
+
* Discord's fetch_around. Neighbours are by (timestamp, sequence): first
|
|
1126
|
+
* the anchor's own millisecond in sequence order, then, for whatever a
|
|
1127
|
+
* side still needs, the nearest messages strictly before/after it (both
|
|
1128
|
+
* via edgeWindow, which is exact by timestamp).
|
|
1129
|
+
*/
|
|
1130
|
+
private handleExtractAround(input: ExtractInput): ToolResult {
|
|
1131
|
+
if (
|
|
1132
|
+
input.from !== undefined ||
|
|
1133
|
+
input.to !== undefined ||
|
|
1134
|
+
input.offset !== undefined ||
|
|
1135
|
+
input.windowOffset !== undefined ||
|
|
1136
|
+
input.limit !== undefined
|
|
1137
|
+
) {
|
|
1138
|
+
throw new Error(
|
|
1139
|
+
'"aroundId" cannot be combined with from/to/offset/windowOffset/limit — it picks its own window; size it with before/after.',
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
if (input.author !== undefined || input.excludeAuthor !== undefined) {
|
|
1143
|
+
throw new Error('"aroundId" returns the whole conversation around a message; author filters do not apply. Use search/extract with author instead.');
|
|
1144
|
+
}
|
|
1145
|
+
if (input.allChannels && input.channelId !== undefined) {
|
|
1146
|
+
throw new Error('Pass either "channelId" or "allChannels", not both.');
|
|
1147
|
+
}
|
|
1148
|
+
const before = clampCount(input.before, AROUND_DEFAULT, AROUND_MAX, 'before');
|
|
1149
|
+
const after = clampCount(input.after, AROUND_DEFAULT, AROUND_MAX, 'after');
|
|
1150
|
+
const format = input.format ?? 'text';
|
|
1151
|
+
const cm = this.cm as ContextManager;
|
|
477
1152
|
|
|
1153
|
+
// semantic_search (#173) hands out `msg:<id>` / `sum:<id>`; accept the
|
|
1154
|
+
// message form as-is, and say plainly what a summary id is.
|
|
1155
|
+
const rawId = String(input.aroundId);
|
|
1156
|
+
if (/^sum:/.test(rawId)) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`${rawId} is a compression summary, not a message — use overview for summaries, or aroundId with a msg: hit.`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
const anchor = cm.getMessage(rawId.replace(/^msg:/, ''));
|
|
1162
|
+
if (!anchor) {
|
|
1163
|
+
throw new Error(`No message with id ${JSON.stringify(input.aroundId)} in this history.`);
|
|
1164
|
+
}
|
|
1165
|
+
const anchorId = String(anchor.id);
|
|
1166
|
+
const anchorChannel = getChannelId(anchor);
|
|
1167
|
+
const channelId = input.allChannels ? undefined : (this.resolveChannel(input.channelId) ?? anchorChannel);
|
|
1168
|
+
const t = anchor.timestamp.getTime();
|
|
1169
|
+
|
|
1170
|
+
// The anchor's own millisecond first, whole and in sequence order —
|
|
1171
|
+
// that is the (timestamp, sequence) order the neighbours are defined
|
|
1172
|
+
// by, and it may hold many messages (a backfill burst). Then only if a
|
|
1173
|
+
// side still needs more, the nearest messages strictly before / after
|
|
1174
|
+
// that millisecond (edgeWindow is exact by timestamp).
|
|
1175
|
+
let ties: StoredMessage[];
|
|
1176
|
+
if (channelId !== undefined) {
|
|
1177
|
+
const n = cm.queryMessagesByTimeAndChannel({ fromMs: t, toMs: t, channelId, limit: 0 }).matchedCount;
|
|
1178
|
+
if (n > AROUND_TIE_CAP) throw tieCapError(anchorId, n);
|
|
1179
|
+
ties = cm.queryMessagesByTimeAndChannel({ fromMs: t, toMs: t, channelId, limit: n }).messages;
|
|
1180
|
+
} else {
|
|
1181
|
+
ties = cm.queryMessagesByTime({ fromMs: t, toMs: t, limit: AROUND_TIE_CAP + 1 }).messages;
|
|
1182
|
+
if (ties.length > AROUND_TIE_CAP) throw tieCapError(anchorId, ties.length);
|
|
1183
|
+
}
|
|
1184
|
+
ties = [...ties].sort((x, y) => x.sequence - y.sequence);
|
|
1185
|
+
const idx = ties.findIndex((m) => String(m.id) === anchorId);
|
|
1186
|
+
if (idx === -1) {
|
|
1187
|
+
// The anchor is in the store and every message of its millisecond in
|
|
1188
|
+
// `channelId` was fetched, so only an explicit channelId excludes it.
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`Message ${anchorId} is not in channel ${JSON.stringify(input.channelId)}` +
|
|
1191
|
+
(anchorChannel ? ` (it is in ${anchorChannel}).` : '.') +
|
|
1192
|
+
' Omit channelId to use the anchor\'s own channel.',
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
const tiesBefore = ties.slice(Math.max(0, idx - before), idx);
|
|
1196
|
+
const tiesAfter = ties.slice(idx + 1, idx + 1 + after);
|
|
1197
|
+
const needBefore = before - tiesBefore.length;
|
|
1198
|
+
const needAfter = after - tiesAfter.length;
|
|
1199
|
+
const earlier =
|
|
1200
|
+
needBefore > 0 ? this.edgeWindow({ toMs: t - 1, channelId, n: needBefore, side: 'newest' }).messages.reverse() : [];
|
|
1201
|
+
const later = needAfter > 0 ? this.edgeWindow({ fromMs: t + 1, channelId, n: needAfter, side: 'oldest' }).messages : [];
|
|
1202
|
+
const window = [...earlier, ...tiesBefore, ties[idx]!, ...tiesAfter, ...later];
|
|
478
1203
|
return {
|
|
479
1204
|
success: true,
|
|
480
1205
|
data: {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1206
|
+
anchorId,
|
|
1207
|
+
channelId: channelId ?? null,
|
|
1208
|
+
returned: window.length,
|
|
1209
|
+
messages: window.map((m) => ({
|
|
1210
|
+
...projectMessage(m, format),
|
|
1211
|
+
...(String(m.id) === anchorId ? { anchor: true } : {}),
|
|
1212
|
+
})),
|
|
484
1213
|
},
|
|
485
1214
|
};
|
|
486
1215
|
}
|
|
@@ -500,6 +1229,14 @@ export class HistoryModule implements Module {
|
|
|
500
1229
|
const maxScan = clampCount(input.maxScan, SEARCH_DEFAULT_MAX_SCAN, SEARCH_MAX_MAX_SCAN, 'maxScan');
|
|
501
1230
|
const caseSensitive = input.caseSensitive ?? false;
|
|
502
1231
|
const flags = caseSensitive ? '' : 'i';
|
|
1232
|
+
const order = input.order ?? 'oldest';
|
|
1233
|
+
if (order !== 'oldest' && order !== 'newest') {
|
|
1234
|
+
throw new Error(`"order" must be "oldest" or "newest", got ${JSON.stringify(input.order)}.`);
|
|
1235
|
+
}
|
|
1236
|
+
if (input.wholeWord && input.regex) {
|
|
1237
|
+
throw new Error('"wholeWord" applies to substring search only; in regex mode write \\b around the pattern instead.');
|
|
1238
|
+
}
|
|
1239
|
+
const authorFilter = buildAuthorFilter(input.author, input.excludeAuthor);
|
|
503
1240
|
|
|
504
1241
|
// Validate regex SYNTAX up front — an invalid pattern is a clean tool
|
|
505
1242
|
// error, not a crash mid-scan. This does NOT bound match TIME (a
|
|
@@ -516,19 +1253,92 @@ export class HistoryModule implements Module {
|
|
|
516
1253
|
const needle = caseSensitive ? input.query : input.query.toLowerCase();
|
|
517
1254
|
|
|
518
1255
|
const cm = this.cm as ContextManager;
|
|
519
|
-
//
|
|
520
|
-
//
|
|
1256
|
+
// The candidate window is the oldest (order:"oldest") or newest
|
|
1257
|
+
// (order:"newest", via edgeWindow — see there for why not an offset
|
|
1258
|
+
// tail) maxScan messages of the time/channel range. Oversize is
|
|
1259
|
+
// detected up front rather than
|
|
521
1260
|
// silently scanning maxScan candidates and returning as if that were the
|
|
522
1261
|
// whole window. See the tool description's `truncated` contract.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
const
|
|
1262
|
+
if (maxScan === 0) throw new Error('"maxScan" must be at least 1 — a zero-message scan can never make progress.');
|
|
1263
|
+
// Continuation: `skipSequences` names what the previous call already
|
|
1264
|
+
// scanned at exactly the resume bound's millisecond (the bound is
|
|
1265
|
+
// inclusive). Identified by sequence range rather than a count, so a
|
|
1266
|
+
// message removed at that millisecond, or appended there (a new
|
|
1267
|
+
// message always gets the highest sequence), between calls neither
|
|
1268
|
+
// shifts the skip onto an unscanned message nor hides the new one.
|
|
1269
|
+
const skipRange = parseSkipSequences(input.skipSequences);
|
|
1270
|
+
const edgeMs = order === 'oldest' ? fromMs : toMs;
|
|
1271
|
+
if (skipRange && edgeMs === undefined) {
|
|
1272
|
+
throw new Error(`"skipSequences" only applies together with "${order === 'oldest' ? 'from' : 'to'}" — pass the whole \`resume\` object back.`);
|
|
1273
|
+
}
|
|
1274
|
+
const isSkipped = (m: StoredMessage) =>
|
|
1275
|
+
!!skipRange && m.timestamp.getTime() === edgeMs && m.sequence >= skipRange[0] && m.sequence <= skipRange[1];
|
|
1276
|
+
let windowMessages: StoredMessage[];
|
|
1277
|
+
let truncated: boolean;
|
|
1278
|
+
{
|
|
1279
|
+
// Over-fetch by the number of messages at the edge millisecond (the
|
|
1280
|
+
// only ones that can be skipped), then drop the skipped ones.
|
|
1281
|
+
const edgeTies = skipRange ? this.countAtMs(edgeMs!, channelId) : 0;
|
|
1282
|
+
const w = this.edgeWindow({ fromMs, toMs, channelId, n: maxScan + edgeTies, side: order });
|
|
1283
|
+
const pool = skipRange ? w.messages.filter((m) => !isSkipped(m)) : w.messages;
|
|
1284
|
+
truncated = w.more || pool.length > maxScan;
|
|
1285
|
+
windowMessages = pool.slice(0, maxScan);
|
|
1286
|
+
}
|
|
1287
|
+
const candidatePoolSize = windowMessages.length;
|
|
1288
|
+
const candidates = authorFilter ? windowMessages.filter(authorFilter) : windowMessages;
|
|
1289
|
+
const poolInfo = {
|
|
1290
|
+
candidatePoolSize,
|
|
1291
|
+
...(authorFilter ? { afterAuthorFilter: candidates.length } : {}),
|
|
1292
|
+
order,
|
|
1293
|
+
};
|
|
1294
|
+
// Where the scan stopped, when it stopped before the end of the window
|
|
1295
|
+
// the caller asked about: either `limit` matches came first (the rest of
|
|
1296
|
+
// the pool was never looked at) or the pool itself was truncated. The
|
|
1297
|
+
// timestamp is the continuation point — from: (oldest) / to: (newest).
|
|
1298
|
+
// Boundary is inclusive, so a message at exactly that instant may repeat.
|
|
1299
|
+
const resumeInfo = (scanned: number): Record<string, unknown> => {
|
|
1300
|
+
const stoppedEarly = scanned < candidates.length;
|
|
1301
|
+
if (!stoppedEarly && !truncated) return {};
|
|
1302
|
+
// Stopped at limit: the last candidate actually looked at. Scanned the
|
|
1303
|
+
// whole (truncated) pool: the pool's own far edge — which may lie past
|
|
1304
|
+
// the last author-filtered candidate.
|
|
1305
|
+
const last = stoppedEarly ? candidates[scanned - 1] : windowMessages[windowMessages.length - 1];
|
|
1306
|
+
if (!last) return {};
|
|
1307
|
+
const at = last.timestamp.toISOString();
|
|
1308
|
+
const bound = order === 'oldest' ? 'from' : 'to';
|
|
1309
|
+
// The continuation bound is inclusive, so the next window starts with
|
|
1310
|
+
// every message at exactly `at`. Hand back the sequence range already
|
|
1311
|
+
// scanned there: this call's messages at `at` up to the stop, plus —
|
|
1312
|
+
// when `at` is still the incoming bound — the range the previous
|
|
1313
|
+
// call had already covered. Within one millisecond the pool is in
|
|
1314
|
+
// sequence order, so every message at `at` whose sequence lies in the
|
|
1315
|
+
// union was scanned; anything appended later sorts outside it.
|
|
1316
|
+
const atMs = last.timestamp.getTime();
|
|
1317
|
+
const stop = windowMessages.indexOf(last);
|
|
1318
|
+
let lo = Infinity;
|
|
1319
|
+
let hi = -Infinity;
|
|
1320
|
+
for (let i = 0; i <= stop; i++) {
|
|
1321
|
+
const m = windowMessages[i]!;
|
|
1322
|
+
if (m.timestamp.getTime() !== atMs) continue;
|
|
1323
|
+
lo = Math.min(lo, m.sequence);
|
|
1324
|
+
hi = Math.max(hi, m.sequence);
|
|
1325
|
+
}
|
|
1326
|
+
if (skipRange && atMs === edgeMs) {
|
|
1327
|
+
lo = Math.min(lo, skipRange[0]);
|
|
1328
|
+
hi = Math.max(hi, skipRange[1]);
|
|
1329
|
+
}
|
|
1330
|
+
const resume = { [bound]: at, skipSequences: [lo, hi] as [number, number] };
|
|
1331
|
+
return {
|
|
1332
|
+
scannedThrough: at,
|
|
1333
|
+
resume,
|
|
1334
|
+
hint:
|
|
1335
|
+
(stoppedEarly
|
|
1336
|
+
? 'Stopped at limit; more candidates remain. '
|
|
1337
|
+
: `Only the ${order} ${candidatePoolSize} messages of this range were scanned (maxScan). `) +
|
|
1338
|
+
`Continue by repeating the call with the fields of \`resume\` (${bound}:"${at}" and skipSequences), ` +
|
|
1339
|
+
'or raise limit/maxScan, or narrow with channelId/author/dates.',
|
|
1340
|
+
};
|
|
1341
|
+
};
|
|
532
1342
|
|
|
533
1343
|
if (input.regex) {
|
|
534
1344
|
// Regex matching against caller-supplied patterns is ReDoS-shaped: a
|
|
@@ -541,7 +1351,7 @@ export class HistoryModule implements Module {
|
|
|
541
1351
|
// on a deadline. See search-regex-worker.ts's header for the full
|
|
542
1352
|
// rationale.
|
|
543
1353
|
const { matches, scanned } = await this.searchWithRegexWorker(candidates, input.query, flags, limit);
|
|
544
|
-
return { success: true, data: { scanned,
|
|
1354
|
+
return { success: true, data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches } };
|
|
545
1355
|
}
|
|
546
1356
|
|
|
547
1357
|
// Plain substring search (String.prototype.indexOf) is inherently
|
|
@@ -559,12 +1369,13 @@ export class HistoryModule implements Module {
|
|
|
559
1369
|
if (matches.length >= limit) break;
|
|
560
1370
|
scanned++;
|
|
561
1371
|
const text = flattenContent(msg.content);
|
|
562
|
-
const hit = matchSubstring(text, needle, caseSensitive);
|
|
1372
|
+
const hit = matchSubstring(text, needle, caseSensitive, input.wholeWord ?? false);
|
|
563
1373
|
if (!hit) continue;
|
|
564
1374
|
matches.push({
|
|
565
1375
|
id: String(msg.id),
|
|
566
1376
|
timestamp: msg.timestamp.toISOString(),
|
|
567
1377
|
participant: msg.participant,
|
|
1378
|
+
author: authorName(msg),
|
|
568
1379
|
channelId: getChannelId(msg) ?? null,
|
|
569
1380
|
snippet: snippetAround(text, hit.index, hit.length),
|
|
570
1381
|
});
|
|
@@ -572,7 +1383,7 @@ export class HistoryModule implements Module {
|
|
|
572
1383
|
|
|
573
1384
|
return {
|
|
574
1385
|
success: true,
|
|
575
|
-
data: { scanned,
|
|
1386
|
+
data: { scanned, ...poolInfo, truncated, ...resumeInfo(scanned), matches },
|
|
576
1387
|
};
|
|
577
1388
|
}
|
|
578
1389
|
|
|
@@ -632,6 +1443,7 @@ export class HistoryModule implements Module {
|
|
|
632
1443
|
id: String(msg.id),
|
|
633
1444
|
timestamp: msg.timestamp.toISOString(),
|
|
634
1445
|
participant: msg.participant,
|
|
1446
|
+
author: authorName(msg),
|
|
635
1447
|
channelId: getChannelId(msg) ?? null,
|
|
636
1448
|
snippet: snippetAround(text, matchIndex, matchLength),
|
|
637
1449
|
};
|
|
@@ -649,6 +1461,129 @@ export class HistoryModule implements Module {
|
|
|
649
1461
|
// overview
|
|
650
1462
|
// ==========================================================================
|
|
651
1463
|
|
|
1464
|
+
private async handleSemanticSearch(input: SemanticSearchInput): Promise<ToolResult> {
|
|
1465
|
+
if (!this.semanticCfg || !this.semanticClient) {
|
|
1466
|
+
throw new Error('semantic_search is not configured for this resident (no embed-service in the recipe).');
|
|
1467
|
+
}
|
|
1468
|
+
if (typeof input.query !== 'string' || !input.query.trim()) throw new Error('query must be a non-empty string');
|
|
1469
|
+
const limit = clampCount(input.limit, SEMANTIC_DEFAULT_LIMIT, SEMANTIC_MAX_LIMIT, 'limit');
|
|
1470
|
+
if (limit < 1) throw new Error('limit must be at least 1');
|
|
1471
|
+
const fromMs = parseIsoDate(input.from, 'from');
|
|
1472
|
+
const toMs = parseIsoDate(input.to, 'to');
|
|
1473
|
+
if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) throw new Error('`from` must not be after `to`');
|
|
1474
|
+
const channelId = this.resolveChannel(input.channelId);
|
|
1475
|
+
// Summaries carry no channel, so a channel filter on a summaries-only
|
|
1476
|
+
// search can only ever match nothing. Say so instead of returning [].
|
|
1477
|
+
const wantsSummariesOnly = input.level !== undefined || input.kinds === 'summaries';
|
|
1478
|
+
if (channelId && wantsSummariesOnly) {
|
|
1479
|
+
throw new Error('channelId cannot be combined with kinds="summaries" or level: summaries are not per-channel. Drop channelId, or search kinds="messages".');
|
|
1480
|
+
}
|
|
1481
|
+
if (input.level !== undefined && input.kinds === 'messages') {
|
|
1482
|
+
throw new Error('level applies to summaries only and cannot be combined with kinds="messages".');
|
|
1483
|
+
}
|
|
1484
|
+
let kinds: string[] | undefined;
|
|
1485
|
+
if (wantsSummariesOnly) kinds = ['summary'];
|
|
1486
|
+
else if (input.kinds === 'messages' || channelId) kinds = ['message'];
|
|
1487
|
+
if (input.level !== undefined && (!Number.isInteger(input.level) || input.level < 0)) throw new Error('level must be a non-negative integer');
|
|
1488
|
+
if (input.minScore !== undefined && (typeof input.minScore !== 'number' || input.minScore < -1 || input.minScore > 1)) {
|
|
1489
|
+
throw new Error('minScore must be a number in -1..1');
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
// Bounded catch-up so the newest messages are searchable; never block on a backlog.
|
|
1493
|
+
let sync: SyncReport | null = null;
|
|
1494
|
+
if (this.indexer) {
|
|
1495
|
+
try { sync = await this.indexer.catchUp(this.semanticCfg.maxSyncBeforeSearch ?? 256); } catch { /* reported via indexer.lastError */ }
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const runSearch = (k: number) => this.semanticClient!.search({
|
|
1499
|
+
query: input.query, k,
|
|
1500
|
+
ts_from: fromMs === undefined ? undefined : fromMs / 1000,
|
|
1501
|
+
ts_to: toMs === undefined ? undefined : toMs / 1000,
|
|
1502
|
+
channel: channelId, kinds, level: input.level, min_score: input.minScore, snippet: SEMANTIC_SNIPPET_CHARS,
|
|
1503
|
+
});
|
|
1504
|
+
// The remote index is append-only and knows nothing about branches: a
|
|
1505
|
+
// message removed from the agent's reach by /undo, /checkout, /restore or
|
|
1506
|
+
// /newtopic keeps its id (a new message gets a fresh one) and stays
|
|
1507
|
+
// indexed. Check every hit against the CURRENT branch before it goes back
|
|
1508
|
+
// to the model — getMessage/getSummary rebuild on branch switch, so an
|
|
1509
|
+
// undone message or a summary minted on another branch answers null.
|
|
1510
|
+
// k is small, so this is at most `limit` local lookups.
|
|
1511
|
+
// A message hit whose current text has nothing left to index (edited
|
|
1512
|
+
// blank, delete not yet applied) is dropped too, so its stale snippet is
|
|
1513
|
+
// never served. Dropped hits are replaced by over-fetching: when the
|
|
1514
|
+
// top k held droppable entries, re-query with a larger k (service cap
|
|
1515
|
+
// 200) so lower-ranked on-branch matches still fill `limit`.
|
|
1516
|
+
const cm = this.cm as ContextManager;
|
|
1517
|
+
const includePrivate = this.semanticCfg.includePrivateTools ?? true;
|
|
1518
|
+
// A message hit's snippet is rebuilt from the CURRENT local text, never
|
|
1519
|
+
// the remote copy: an edit not yet synced (or one the index never got)
|
|
1520
|
+
// must not leak a passage the edit removed. Only the ranking can be stale.
|
|
1521
|
+
const currentText = new Map<string, string>();
|
|
1522
|
+
const keep = (h: { id: string }): 'ok' | 'offBranch' | 'stale' => {
|
|
1523
|
+
const msgId = /^msg:(.+)$/.exec(h.id)?.[1];
|
|
1524
|
+
const sumId = /^sum:(.+)$/.exec(h.id)?.[1];
|
|
1525
|
+
if (msgId !== undefined) {
|
|
1526
|
+
const m = cm.getMessage(msgId);
|
|
1527
|
+
if (!m) return 'offBranch';
|
|
1528
|
+
const text = messageIndexText(m, includePrivate).slice(0, this.semanticCfg!.maxChars ?? 32_000);
|
|
1529
|
+
if (!text) return 'stale';
|
|
1530
|
+
currentText.set(h.id, text);
|
|
1531
|
+
return 'ok';
|
|
1532
|
+
}
|
|
1533
|
+
if (sumId !== undefined) return cm.getSummary(sumId) !== null ? 'ok' : 'offBranch';
|
|
1534
|
+
return 'ok';
|
|
1535
|
+
};
|
|
1536
|
+
let k = limit;
|
|
1537
|
+
let res = await runSearch(k);
|
|
1538
|
+
let droppedOffBranch = 0;
|
|
1539
|
+
let droppedStale = 0;
|
|
1540
|
+
let onBranch: typeof res.hits = [];
|
|
1541
|
+
for (;;) {
|
|
1542
|
+
droppedOffBranch = 0; droppedStale = 0;
|
|
1543
|
+
onBranch = res.hits.filter((h) => {
|
|
1544
|
+
const v = keep(h);
|
|
1545
|
+
if (v === 'offBranch') droppedOffBranch++;
|
|
1546
|
+
else if (v === 'stale') droppedStale++;
|
|
1547
|
+
return v === 'ok';
|
|
1548
|
+
});
|
|
1549
|
+
if (onBranch.length >= limit || res.hits.length < k || k >= SEMANTIC_MAX_FETCH) break;
|
|
1550
|
+
k = Math.min(SEMANTIC_MAX_FETCH, k * 4);
|
|
1551
|
+
res = await runSearch(k);
|
|
1552
|
+
}
|
|
1553
|
+
onBranch = onBranch.slice(0, limit);
|
|
1554
|
+
const hits = onBranch.map((h) => ({
|
|
1555
|
+
id: h.id,
|
|
1556
|
+
/** Raw message id for a `msg:` hit (null for summaries) — pass it to extract({ aroundId }). */
|
|
1557
|
+
messageId: h.id.startsWith('msg:') ? h.id.slice(4) : null,
|
|
1558
|
+
kind: h.kind,
|
|
1559
|
+
level: h.level,
|
|
1560
|
+
score: h.score,
|
|
1561
|
+
timestamp: h.ts === null ? null : new Date(h.ts * 1000).toISOString(),
|
|
1562
|
+
channelId: h.channel,
|
|
1563
|
+
participant: (h.meta as { participant?: unknown }).participant ?? null,
|
|
1564
|
+
author: (h.meta as { author?: unknown }).author ?? null,
|
|
1565
|
+
snippet: currentText.has(h.id) ? snippetOf(currentText.get(h.id)!) : (h.text ?? ''),
|
|
1566
|
+
chars: currentText.has(h.id) ? currentText.get(h.id)!.length : h.chars,
|
|
1567
|
+
}));
|
|
1568
|
+
return {
|
|
1569
|
+
success: true,
|
|
1570
|
+
data: {
|
|
1571
|
+
hits,
|
|
1572
|
+
index: {
|
|
1573
|
+
indexed: res.count_indexed,
|
|
1574
|
+
behind: sync ? sync.more : this.indexer === null ? null : true,
|
|
1575
|
+
syncedThisCall: sync ? sync.pushed : 0,
|
|
1576
|
+
lastError: this.indexer?.lastError ?? null,
|
|
1577
|
+
/** Hits the index returned for messages/summaries not on the current branch (undone, checked out past). */
|
|
1578
|
+
droppedOffBranch,
|
|
1579
|
+
/** Hits for messages whose current text has nothing indexable (edited blank; the index delete is pending). */
|
|
1580
|
+
droppedStale,
|
|
1581
|
+
},
|
|
1582
|
+
timingMs: res.timing_ms,
|
|
1583
|
+
},
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
|
|
652
1587
|
private handleOverview(input: OverviewInput): ToolResult {
|
|
653
1588
|
const channelId = this.resolveChannel(input.channelId);
|
|
654
1589
|
const fromMs = parseIsoDate(input.from, 'from');
|
|
@@ -1200,6 +2135,7 @@ function projectMessage(msg: StoredMessage, format: 'text' | 'raw'): Record<stri
|
|
|
1200
2135
|
id: String(msg.id),
|
|
1201
2136
|
timestamp: msg.timestamp.toISOString(),
|
|
1202
2137
|
participant: msg.participant,
|
|
2138
|
+
author: authorName(msg),
|
|
1203
2139
|
channelId: getChannelId(msg) ?? null,
|
|
1204
2140
|
content: format === 'raw' ? msg.content : flattenContent(msg.content),
|
|
1205
2141
|
};
|
|
@@ -1252,15 +2188,144 @@ interface SearchMatch {
|
|
|
1252
2188
|
id: string;
|
|
1253
2189
|
timestamp: string;
|
|
1254
2190
|
participant: string;
|
|
2191
|
+
/** Display name from metadata.author (null when absent — e.g. the agent's own turns; see participant). */
|
|
2192
|
+
author: string | null;
|
|
1255
2193
|
channelId: string | null;
|
|
1256
2194
|
snippet: string;
|
|
1257
2195
|
}
|
|
1258
2196
|
|
|
1259
|
-
|
|
2197
|
+
/** Letters, combining marks (NFD accents, Indic vowel signs), digits, underscore. */
|
|
2198
|
+
const WORD_CHAR_RE = /[\p{L}\p{M}\p{N}_]/u;
|
|
2199
|
+
|
|
2200
|
+
function isWordChar(ch: string | undefined): boolean {
|
|
2201
|
+
return ch !== undefined && WORD_CHAR_RE.test(ch);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
/** The whole code point starting at `i` (astral letters are two UTF-16 units). */
|
|
2205
|
+
function codePointAt(s: string, i: number): string | undefined {
|
|
2206
|
+
const cp = s.codePointAt(i);
|
|
2207
|
+
return cp === undefined ? undefined : String.fromCodePoint(cp);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/** The whole code point ending just before `i`. */
|
|
2211
|
+
function codePointBefore(s: string, i: number): string | undefined {
|
|
2212
|
+
if (i <= 0) return undefined;
|
|
2213
|
+
const lo = s.charCodeAt(i - 1);
|
|
2214
|
+
if (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) {
|
|
2215
|
+
const hi = s.charCodeAt(i - 2);
|
|
2216
|
+
if (hi >= 0xd800 && hi <= 0xdbff) return s.slice(i - 2, i);
|
|
2217
|
+
}
|
|
2218
|
+
return s[i - 1];
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* First occurrence of `needle` in `text`. With `wholeWord`, an occurrence
|
|
2223
|
+
* only counts when it isn't glued to a letter/digit/underscore on either
|
|
2224
|
+
* side (Unicode-aware, so Cyrillic words work too) — "mission" no longer
|
|
2225
|
+
* hits inside "uncommissioned". Word-ness is checked only on the sides
|
|
2226
|
+
* where the needle itself starts/ends with a word character, so a needle
|
|
2227
|
+
* like "#general" or "v2." still matches the way a person would expect.
|
|
2228
|
+
*/
|
|
2229
|
+
function matchSubstring(text: string, needle: string, caseSensitive: boolean, wholeWord = false): MatchHit | null {
|
|
1260
2230
|
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
2231
|
+
if (!wholeWord) {
|
|
2232
|
+
const index = haystack.indexOf(needle);
|
|
2233
|
+
return index === -1 ? null : { index, length: needle.length };
|
|
2234
|
+
}
|
|
2235
|
+
const checkLeft = isWordChar(codePointAt(needle, 0));
|
|
2236
|
+
const checkRight = isWordChar(codePointBefore(needle, needle.length));
|
|
2237
|
+
for (let from = 0; ; ) {
|
|
2238
|
+
const index = haystack.indexOf(needle, from);
|
|
2239
|
+
if (index === -1) return null;
|
|
2240
|
+
const end = index + needle.length;
|
|
2241
|
+
if ((!checkLeft || !isWordChar(codePointBefore(haystack, index))) && (!checkRight || !isWordChar(codePointAt(haystack, end)))) {
|
|
2242
|
+
return { index, length: needle.length };
|
|
2243
|
+
}
|
|
2244
|
+
from = index + 1;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ============================================================================
|
|
2249
|
+
// author filtering
|
|
2250
|
+
// ============================================================================
|
|
2251
|
+
|
|
2252
|
+
/** metadata.author as written by MCPL channel ingestion ({id, name}). Every
|
|
2253
|
+
* incoming channel message is stored with participant "user" — the real
|
|
2254
|
+
* author lives only here. */
|
|
2255
|
+
function authorOf(msg: StoredMessage): { id?: string; name?: string } | undefined {
|
|
2256
|
+
const a = (msg.metadata as { author?: unknown } | undefined)?.author;
|
|
2257
|
+
if (!a || typeof a !== 'object') return undefined;
|
|
2258
|
+
const { id, name } = a as { id?: unknown; name?: unknown };
|
|
2259
|
+
return {
|
|
2260
|
+
...(typeof id === 'string' || typeof id === 'number' ? { id: String(id) } : {}),
|
|
2261
|
+
...(typeof name === 'string' ? { name } : {}),
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
function parseSkipSequences(v: unknown): [number, number] | null {
|
|
2266
|
+
if (v === undefined || v === null) return null;
|
|
2267
|
+
if (
|
|
2268
|
+
!Array.isArray(v) ||
|
|
2269
|
+
v.length !== 2 ||
|
|
2270
|
+
!v.every((x) => typeof x === 'number' && Number.isInteger(x) && x >= 0) ||
|
|
2271
|
+
v[0] > v[1]
|
|
2272
|
+
) {
|
|
2273
|
+
throw new Error('"skipSequences" must be [lo, hi], two non-negative integers with lo <= hi — copy it from `resume`.');
|
|
2274
|
+
}
|
|
2275
|
+
return [v[0], v[1]];
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function idOrNull(msg: StoredMessage | undefined): string | null {
|
|
2279
|
+
return msg ? String(msg.id) : null;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
function authorName(msg: StoredMessage): string | null {
|
|
2283
|
+
return authorOf(msg)?.name ?? null;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
/** Normalize one author spec: trim, case-fold, strip a leading "@", unwrap a
|
|
2287
|
+
* <@id>/<@!id> mention to its id. */
|
|
2288
|
+
function normalizeAuthorSpec(spec: string): string {
|
|
2289
|
+
const s = spec.trim();
|
|
2290
|
+
const mention = /^<@!?([^\s<>@]+)>$/.exec(s);
|
|
2291
|
+
if (mention) return mention[1]!.toLowerCase();
|
|
2292
|
+
return s.replace(/^@/, '').toLowerCase();
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function toAuthorSet(spec: AuthorSpec | undefined, field: string): Set<string> | null {
|
|
2296
|
+
if (spec === undefined) return null;
|
|
2297
|
+
const list = Array.isArray(spec) ? spec : [spec];
|
|
2298
|
+
if (list.some((s) => typeof s !== 'string')) {
|
|
2299
|
+
throw new Error(`"${field}" must be a string or an array of strings.`);
|
|
2300
|
+
}
|
|
2301
|
+
const set = new Set(list.map(normalizeAuthorSpec).filter((s) => s.length > 0));
|
|
2302
|
+
if (set.size === 0) throw new Error(`"${field}" must name at least one author.`);
|
|
2303
|
+
return set;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* Predicate for `author`/`excludeAuthor`, or null when neither is given.
|
|
2308
|
+
* A message's identities are its metadata.author name and id plus its
|
|
2309
|
+
* stored participant — the last is what identifies the agent's own turns
|
|
2310
|
+
* (and anything else ingested without author metadata). Exact match after
|
|
2311
|
+
* normalization, never substring: "ann" must not pull in "joanne".
|
|
2312
|
+
*/
|
|
2313
|
+
function buildAuthorFilter(include: AuthorSpec | undefined, exclude: AuthorSpec | undefined): ((m: StoredMessage) => boolean) | null {
|
|
2314
|
+
const inc = toAuthorSet(include, 'author');
|
|
2315
|
+
const exc = toAuthorSet(exclude, 'excludeAuthor');
|
|
2316
|
+
if (!inc && !exc) return null;
|
|
2317
|
+
return (m) => {
|
|
2318
|
+
const a = authorOf(m);
|
|
2319
|
+
// participant only for messages WITHOUT author metadata (the agent's own
|
|
2320
|
+
// turns): every MCPL-ingested message has participant "user", which
|
|
2321
|
+
// would otherwise make author:"user" match everyone.
|
|
2322
|
+
const keys = (a ? [a.name?.toLowerCase(), a.id?.toLowerCase()] : [m.participant?.toLowerCase()]).filter(
|
|
2323
|
+
(k): k is string => !!k,
|
|
2324
|
+
);
|
|
2325
|
+
if (inc && !keys.some((k) => inc.has(k))) return false;
|
|
2326
|
+
if (exc && keys.some((k) => exc.has(k))) return false;
|
|
2327
|
+
return true;
|
|
2328
|
+
};
|
|
1264
2329
|
}
|
|
1265
2330
|
|
|
1266
2331
|
/** ~SNIPPET_CONTEXT_CHARS of surrounding context on each side of a match, or
|