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