@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.
Files changed (193) hide show
  1. package/README.md +29 -0
  2. package/dist/src/agent.d.ts +22 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +128 -10
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/types.d.ts +1 -1
  7. package/dist/src/api/types.d.ts.map +1 -1
  8. package/dist/src/code-execution/py-runner.d.ts +10 -1
  9. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  10. package/dist/src/code-execution/py-runner.js +27 -2
  11. package/dist/src/code-execution/py-runner.js.map +1 -1
  12. package/dist/src/code-execution/tool-definition.d.ts +21 -0
  13. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  14. package/dist/src/code-execution/tool-definition.js +34 -0
  15. package/dist/src/code-execution/tool-definition.js.map +1 -1
  16. package/dist/src/framework.d.ts +153 -2
  17. package/dist/src/framework.d.ts.map +1 -1
  18. package/dist/src/framework.js +1169 -76
  19. package/dist/src/framework.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +4 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/capability-grant.d.ts +1 -1
  25. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  26. package/dist/src/mcpl/capability-grant.js +49 -5
  27. package/dist/src/mcpl/capability-grant.js.map +1 -1
  28. package/dist/src/mcpl/channel-registry.d.ts +59 -2
  29. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  30. package/dist/src/mcpl/channel-registry.js +106 -6
  31. package/dist/src/mcpl/channel-registry.js.map +1 -1
  32. package/dist/src/mcpl/index.d.ts +3 -0
  33. package/dist/src/mcpl/index.d.ts.map +1 -1
  34. package/dist/src/mcpl/index.js +3 -0
  35. package/dist/src/mcpl/index.js.map +1 -1
  36. package/dist/src/mcpl/push-coalescer.d.ts +318 -0
  37. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
  38. package/dist/src/mcpl/push-coalescer.js +709 -0
  39. package/dist/src/mcpl/push-coalescer.js.map +1 -0
  40. package/dist/src/mcpl/push-handler.d.ts +7 -2
  41. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  42. package/dist/src/mcpl/push-handler.js +43 -4
  43. package/dist/src/mcpl/push-handler.js.map +1 -1
  44. package/dist/src/mcpl/server-connection.d.ts +18 -0
  45. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  46. package/dist/src/mcpl/server-connection.js +44 -1
  47. package/dist/src/mcpl/server-connection.js.map +1 -1
  48. package/dist/src/mcpl/tool-classes.d.ts +71 -0
  49. package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
  50. package/dist/src/mcpl/tool-classes.js +170 -0
  51. package/dist/src/mcpl/tool-classes.js.map +1 -0
  52. package/dist/src/mcpl/tool-glob.d.ts +16 -0
  53. package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
  54. package/dist/src/mcpl/tool-glob.js +41 -0
  55. package/dist/src/mcpl/tool-glob.js.map +1 -0
  56. package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
  57. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
  58. package/dist/src/mcpl/tool-lifecycle.js +592 -0
  59. package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
  60. package/dist/src/mcpl/transport.d.ts +12 -0
  61. package/dist/src/mcpl/transport.d.ts.map +1 -1
  62. package/dist/src/mcpl/transport.js +33 -1
  63. package/dist/src/mcpl/transport.js.map +1 -1
  64. package/dist/src/mcpl/types.d.ts +66 -1
  65. package/dist/src/mcpl/types.d.ts.map +1 -1
  66. package/dist/src/mcpl/types.js +6 -0
  67. package/dist/src/mcpl/types.js.map +1 -1
  68. package/dist/src/modules/discord/index.js +1 -1
  69. package/dist/src/modules/discord/index.js.map +1 -1
  70. package/dist/src/modules/history/index.d.ts +85 -0
  71. package/dist/src/modules/history/index.d.ts.map +1 -1
  72. package/dist/src/modules/history/index.js +1019 -24
  73. package/dist/src/modules/history/index.js.map +1 -1
  74. package/dist/src/modules/history/semantic.d.ts +202 -0
  75. package/dist/src/modules/history/semantic.d.ts.map +1 -0
  76. package/dist/src/modules/history/semantic.js +418 -0
  77. package/dist/src/modules/history/semantic.js.map +1 -0
  78. package/dist/src/modules/index.d.ts +2 -1
  79. package/dist/src/modules/index.d.ts.map +1 -1
  80. package/dist/src/modules/index.js.map +1 -1
  81. package/dist/src/tool-result-guard.d.ts +82 -0
  82. package/dist/src/tool-result-guard.d.ts.map +1 -0
  83. package/dist/src/tool-result-guard.js +306 -0
  84. package/dist/src/tool-result-guard.js.map +1 -0
  85. package/dist/src/types/agent.d.ts +14 -0
  86. package/dist/src/types/agent.d.ts.map +1 -1
  87. package/dist/src/types/framework.d.ts +21 -0
  88. package/dist/src/types/framework.d.ts.map +1 -1
  89. package/dist/src/types/trace.d.ts +17 -0
  90. package/dist/src/types/trace.d.ts.map +1 -1
  91. package/dist/test/channel-registry-routing.test.js +59 -0
  92. package/dist/test/channel-registry-routing.test.js.map +1 -1
  93. package/dist/test/code-execution.test.js +139 -0
  94. package/dist/test/code-execution.test.js.map +1 -1
  95. package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
  96. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
  97. package/dist/test/helpers/coalescing-fixture.js +145 -0
  98. package/dist/test/helpers/coalescing-fixture.js.map +1 -0
  99. package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
  100. package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
  101. package/dist/test/helpers/coalescing-kill-child.js +24 -0
  102. package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
  103. package/dist/test/history-module-realstore.test.d.ts +2 -0
  104. package/dist/test/history-module-realstore.test.d.ts.map +1 -0
  105. package/dist/test/history-module-realstore.test.js +363 -0
  106. package/dist/test/history-module-realstore.test.js.map +1 -0
  107. package/dist/test/history-module-semantic.test.d.ts +2 -0
  108. package/dist/test/history-module-semantic.test.d.ts.map +1 -0
  109. package/dist/test/history-module-semantic.test.js +213 -0
  110. package/dist/test/history-module-semantic.test.js.map +1 -0
  111. package/dist/test/history-module-ux.test.d.ts +2 -0
  112. package/dist/test/history-module-ux.test.d.ts.map +1 -0
  113. package/dist/test/history-module-ux.test.js +304 -0
  114. package/dist/test/history-module-ux.test.js.map +1 -0
  115. package/dist/test/history-module.test.js +25 -1
  116. package/dist/test/history-module.test.js.map +1 -1
  117. package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
  118. package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
  119. package/dist/test/history-semantic-review-findings.test.js +338 -0
  120. package/dist/test/history-semantic-review-findings.test.js.map +1 -0
  121. package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
  122. package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
  123. package/dist/test/history-semantic-sync-converges.test.js +109 -0
  124. package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
  125. package/dist/test/mcpl-capability-grant.test.js +3 -1
  126. package/dist/test/mcpl-capability-grant.test.js.map +1 -1
  127. package/dist/test/mcpl-child-env.test.d.ts +2 -0
  128. package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
  129. package/dist/test/mcpl-child-env.test.js +69 -0
  130. package/dist/test/mcpl-child-env.test.js.map +1 -0
  131. package/dist/test/mcpl-coalescing.test.d.ts +2 -0
  132. package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
  133. package/dist/test/mcpl-coalescing.test.js +1050 -0
  134. package/dist/test/mcpl-coalescing.test.js.map +1 -0
  135. package/dist/test/mcpl-list-status.test.js +4 -0
  136. package/dist/test/mcpl-list-status.test.js.map +1 -1
  137. package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
  138. package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
  139. package/dist/test/mcpl-tool-class-listing.test.js +147 -0
  140. package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
  141. package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
  142. package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
  143. package/dist/test/mcpl-tool-classes.test.js +80 -0
  144. package/dist/test/mcpl-tool-classes.test.js.map +1 -0
  145. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
  146. package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
  147. package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
  148. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
  149. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
  150. package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
  151. package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
  152. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
  153. package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
  154. package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
  155. package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
  156. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
  157. package/dist/test/silent-heartbeat.test.d.ts +2 -0
  158. package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
  159. package/dist/test/silent-heartbeat.test.js +154 -0
  160. package/dist/test/silent-heartbeat.test.js.map +1 -0
  161. package/dist/test/tool-result-guard.test.d.ts +2 -0
  162. package/dist/test/tool-result-guard.test.d.ts.map +1 -0
  163. package/dist/test/tool-result-guard.test.js +1081 -0
  164. package/dist/test/tool-result-guard.test.js.map +1 -0
  165. package/dist/test/trunk-channel-routing.test.js +67 -0
  166. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  167. package/dist/tsconfig.tsbuildinfo +1 -1
  168. package/package.json +2 -2
  169. package/src/agent.ts +128 -10
  170. package/src/api/types.ts +2 -0
  171. package/src/code-execution/py-runner.ts +34 -2
  172. package/src/code-execution/tool-definition.ts +46 -0
  173. package/src/framework.ts +1201 -80
  174. package/src/index.ts +13 -0
  175. package/src/mcpl/capability-grant.ts +54 -6
  176. package/src/mcpl/channel-registry.ts +115 -7
  177. package/src/mcpl/index.ts +29 -0
  178. package/src/mcpl/push-coalescer.ts +821 -0
  179. package/src/mcpl/push-handler.ts +40 -4
  180. package/src/mcpl/server-connection.ts +48 -2
  181. package/src/mcpl/tool-classes.ts +188 -0
  182. package/src/mcpl/tool-glob.ts +36 -0
  183. package/src/mcpl/tool-lifecycle.ts +730 -0
  184. package/src/mcpl/transport.ts +35 -1
  185. package/src/mcpl/types.ts +69 -1
  186. package/src/modules/discord/index.ts +1 -1
  187. package/src/modules/history/index.ts +1089 -24
  188. package/src/modules/history/semantic.ts +473 -0
  189. package/src/modules/index.ts +2 -1
  190. package/src/tool-result-guard.ts +318 -0
  191. package/src/types/agent.ts +15 -0
  192. package/src/types/framework.ts +23 -0
  193. 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. regex:true matching runs under a wall-clock deadline and is cleanly failed ' +
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
- matchedCount: result.matchedCount,
482
- returned: result.messages.length,
483
- messages: result.messages.map((msg) => projectMessage(msg, format)),
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
- // Fetch one more than maxScan so an oversized candidate window is
520
- // detected up front (before any matching is attempted), rather than
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
- const probe = cm.queryMessagesByTimeAndChannel({
524
- fromMs,
525
- toMs,
526
- channelId,
527
- limit: maxScan + 1,
528
- offset: 0,
529
- });
530
- const truncated = probe.messages.length > maxScan;
531
- const candidates = truncated ? probe.messages.slice(0, maxScan) : probe.messages;
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, candidatePoolSize: candidates.length, truncated, matches } };
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, candidatePoolSize: candidates.length, truncated, matches },
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
- function matchSubstring(text: string, needle: string, caseSensitive: boolean): MatchHit | null {
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
- const index = haystack.indexOf(needle);
1262
- if (index === -1) return null;
1263
- return { index, length: needle.length };
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