@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
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Semantic (embedding) search for HistoryModule, backed by a shared remote
3
+ * embed-service (one per fleet; the index lives server-side, keyed by a
4
+ * per-store namespace).
5
+ *
6
+ * Two pieces:
7
+ * - `SemanticIndexClient` — thin HTTP client for the service's index API
8
+ * (`/v1/index/{ns}/upsert|search|stats`).
9
+ * - `SemanticIndexer` — incremental sync from the resident's chronicle into
10
+ * that namespace. Messages are watermarked by TIMESTAMP (chronicle's native
11
+ * time index is the only cheap "what's new" query), with a re-scan overlap
12
+ * so a message stamped slightly out of order is still picked up; the
13
+ * service dedups by id + text hash, so re-sending is free. Summaries are
14
+ * watermarked by `createdMs` (a fresh L3 spans months of old timestamps —
15
+ * its creation time is the only monotonic signal).
16
+ *
17
+ * What gets embedded per message: text blocks verbatim, plus the string
18
+ * arguments of the agent's private-prose tools (think / journal / skip_reply
19
+ * / private_note) labelled `[think] …` — that is the resident's own diary and
20
+ * is exactly what "what did I think about X" should find. tool_result
21
+ * payloads, other tool arguments, thinking blocks and media are skipped.
22
+ *
23
+ * Failure posture: the service being down never breaks the module. Sync ticks
24
+ * log and back off; `semantic_search` returns a clean tool error.
25
+ */
26
+
27
+ import type { ContextManager, StoredMessage } from '@animalabs/context-manager';
28
+ import type { ContentBlock } from '@animalabs/membrane';
29
+
30
+ export interface SemanticIndexConfig {
31
+ /** Base URL of the embed-service, e.g. `http://100.90.161.34:8804`. */
32
+ url: string;
33
+ /** Bearer token (the service's EMBED_TOKEN). Optional if the service runs open. */
34
+ token?: string;
35
+ /** Index namespace for this store — must be unique fleet-wide (e.g. `linn/9f9857cd`). */
36
+ namespace: string;
37
+ /** Per-request timeout. Default 30 s (bulk upserts of long messages can take a while). */
38
+ requestTimeoutMs?: number;
39
+ /** Background sync cadence. Default 60 s. 0 disables background sync (search still catches up). */
40
+ syncIntervalMs?: number;
41
+ /** Items per upsert request. Default 128, max 256 (service limit). */
42
+ syncBatch?: number;
43
+ /** Max items one background tick will push. Default 1024. */
44
+ maxSyncPerTick?: number;
45
+ /** Max items a pre-search catch-up will push before searching anyway. Default 256. */
46
+ maxSyncBeforeSearch?: number;
47
+ /** Re-scan window behind the message watermark, ms. Default 10 min. */
48
+ overlapMs?: number;
49
+ /** Include private-prose tool arguments (think/journal/skip_reply/private_note). Default true. */
50
+ includePrivateTools?: boolean;
51
+ /** Per-item text cap in chars (service caps at 200k; model truncates at its max_seq_length). Default 32k. */
52
+ maxChars?: number;
53
+ }
54
+
55
+ export interface IndexItem {
56
+ id: string;
57
+ text: string;
58
+ ts?: number;
59
+ channel?: string | null;
60
+ kind: 'message' | 'summary';
61
+ level?: number;
62
+ cursor?: number;
63
+ meta?: Record<string, unknown>;
64
+ }
65
+
66
+ export interface IndexStats {
67
+ namespace: string;
68
+ model: string;
69
+ dim: number;
70
+ count: number;
71
+ by_kind: Record<string, { count: number; max_ts: number | null; min_ts: number | null; max_cursor: number | null }>;
72
+ }
73
+
74
+ export interface SearchHit {
75
+ id: string;
76
+ score: number;
77
+ ts: number | null;
78
+ channel: string | null;
79
+ kind: string;
80
+ level: number | null;
81
+ meta: Record<string, unknown>;
82
+ text?: string;
83
+ chars: number;
84
+ }
85
+
86
+ export interface SearchRequest {
87
+ query: string;
88
+ k?: number;
89
+ ts_from?: number;
90
+ ts_to?: number;
91
+ channel?: string;
92
+ kinds?: string[];
93
+ level?: number;
94
+ min_score?: number;
95
+ snippet?: number;
96
+ }
97
+
98
+ export interface SearchResponse {
99
+ namespace: string;
100
+ hits: SearchHit[];
101
+ count_indexed: number;
102
+ timing_ms: { embed: number; search: number };
103
+ }
104
+
105
+ const PRIVATE_PROSE_TOOLS = new Set(['think', 'journal', 'skip_reply', 'private_note']);
106
+
107
+ export class SemanticIndexClient {
108
+ private readonly base: string;
109
+ private readonly ns: string;
110
+ private readonly timeoutMs: number;
111
+ constructor(private readonly cfg: SemanticIndexConfig) {
112
+ this.base = cfg.url.replace(/\/+$/, '');
113
+ this.ns = encodeURIComponent(cfg.namespace);
114
+ this.timeoutMs = cfg.requestTimeoutMs ?? 30_000;
115
+ }
116
+
117
+ private async call<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
118
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
119
+ if (this.cfg.token) headers.authorization = `Bearer ${this.cfg.token}`;
120
+ const ctrl = new AbortController();
121
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
122
+ try {
123
+ const res = await fetch(`${this.base}${path}`, {
124
+ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: ctrl.signal,
125
+ });
126
+ const text = await res.text();
127
+ let json: unknown;
128
+ try { json = JSON.parse(text); } catch { json = undefined; }
129
+ if (!res.ok) {
130
+ const msg = (json as { error?: { message?: string } } | undefined)?.error?.message ?? text.slice(0, 200);
131
+ throw new Error(`embed-service ${method} ${path} → ${res.status}: ${msg}`);
132
+ }
133
+ return json as T;
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+
139
+ /** Stats for this namespace; `null` when the namespace does not exist yet. */
140
+ async stats(): Promise<IndexStats | null> {
141
+ try {
142
+ return await this.call<IndexStats>('GET', `/v1/index/${this.ns}/stats`);
143
+ } catch (e) {
144
+ if (e instanceof Error && /→ 404/.test(e.message)) return null;
145
+ throw e;
146
+ }
147
+ }
148
+
149
+ async upsert(items: IndexItem[]): Promise<{ inserted: number; updated: number; unchanged: number; count: number }> {
150
+ return this.call('POST', `/v1/index/${this.ns}/upsert`, { items });
151
+ }
152
+
153
+ async delete(ids: string[]): Promise<{ deleted: number }> {
154
+ return this.call('POST', `/v1/index/${this.ns}/delete`, { ids });
155
+ }
156
+
157
+ async search(req: SearchRequest): Promise<SearchResponse> {
158
+ return this.call<SearchResponse>('POST', `/v1/index/${this.ns}/search`, req);
159
+ }
160
+ }
161
+
162
+ /** Text to embed for one message, or '' when there is nothing worth indexing. */
163
+ export function messageIndexText(msg: StoredMessage, includePrivateTools = true): string {
164
+ const parts: string[] = [];
165
+ for (const block of msg.content as ContentBlock[]) {
166
+ if (block.type === 'text' && typeof block.text === 'string') {
167
+ parts.push(block.text);
168
+ } else if (includePrivateTools && block.type === 'tool_use' && PRIVATE_PROSE_TOOLS.has(block.name)) {
169
+ const input = (block as { input?: Record<string, unknown> }).input ?? {};
170
+ for (const v of Object.values(input)) {
171
+ if (typeof v === 'string' && v.trim().length > 0) parts.push(`[${block.name}] ${v}`);
172
+ }
173
+ }
174
+ }
175
+ return parts.join('\n').trim();
176
+ }
177
+
178
+ /** Same two metadata shapes HistoryModule's getChannelId reads: MCPL ingestion writes `metadata.channelId`, older paths `metadata.external.channelId`. */
179
+ function channelOf(msg: StoredMessage): string | null {
180
+ const md = msg.metadata as { channelId?: unknown; external?: { channelId?: unknown } } | undefined;
181
+ if (typeof md?.channelId === 'string') return md.channelId;
182
+ return typeof md?.external?.channelId === 'string' ? md.external.channelId : null;
183
+ }
184
+
185
+ function authorOf(msg: StoredMessage): string | null {
186
+ const md = msg.metadata as { external?: { authorName?: unknown }; authorName?: unknown } | undefined;
187
+ const a = md?.external?.authorName ?? md?.authorName;
188
+ return typeof a === 'string' ? a : null;
189
+ }
190
+
191
+ export function messageToItem(msg: StoredMessage, cfg: { includePrivateTools?: boolean; maxChars?: number }): IndexItem | null {
192
+ const text = messageIndexText(msg, cfg.includePrivateTools ?? true);
193
+ if (!text) return null;
194
+ const tsMs = msg.timestamp.getTime();
195
+ return {
196
+ id: `msg:${String(msg.id)}`,
197
+ text: text.slice(0, cfg.maxChars ?? 32_000),
198
+ ts: tsMs / 1000,
199
+ channel: channelOf(msg),
200
+ kind: 'message',
201
+ cursor: tsMs,
202
+ meta: { participant: msg.participant, author: authorOf(msg), seq: msg.sequence },
203
+ };
204
+ }
205
+
206
+ export interface SummaryLike {
207
+ id: string;
208
+ level: number;
209
+ content: string;
210
+ tokens: number;
211
+ startMs: number;
212
+ endMs: number;
213
+ firstSequence: number;
214
+ lastSequence: number;
215
+ createdMs: number;
216
+ parentId?: string;
217
+ }
218
+
219
+ export function summaryToItem(s: SummaryLike, cfg: { maxChars?: number }): IndexItem | null {
220
+ const text = s.content.trim();
221
+ if (!text) return null;
222
+ return {
223
+ id: `sum:${s.id}`,
224
+ text: text.slice(0, cfg.maxChars ?? 32_000),
225
+ ts: s.startMs / 1000,
226
+ kind: 'summary',
227
+ level: s.level,
228
+ cursor: s.createdMs,
229
+ meta: { endTs: s.endMs / 1000, firstSequence: s.firstSequence, lastSequence: s.lastSequence, tokens: s.tokens, parentId: s.parentId ?? null },
230
+ };
231
+ }
232
+
233
+ /** Edits/removals not yet applied to the index (persisted by the host across restarts). */
234
+ export interface PendingChanges { edited: string[]; removed: string[] }
235
+
236
+ export interface SyncReport {
237
+ pushed: number;
238
+ inserted: number;
239
+ updated: number;
240
+ unchanged: number;
241
+ /** True when the tick hit its item cap before reaching the end of the store. */
242
+ more: boolean;
243
+ messagesScanned: number;
244
+ summariesScanned: number;
245
+ }
246
+
247
+ export class SemanticIndexer {
248
+ private inFlight: Promise<SyncReport> | null = null;
249
+ private consecutiveFailures = 0;
250
+ private backoffUntil = 0;
251
+ lastError: string | null = null;
252
+ lastSyncAt: number | null = null;
253
+
254
+ constructor(
255
+ private readonly cm: ContextManager,
256
+ private readonly client: SemanticIndexClient,
257
+ private readonly cfg: SemanticIndexConfig,
258
+ private readonly log: (msg: string) => void = () => {},
259
+ ) {}
260
+
261
+ /**
262
+ * Edits and removals since the last successful sync. The time-cursor walk
263
+ * only revisits the overlap window, so an edit to an older message (its
264
+ * timestamp does not change) or a removal would otherwise never reach the
265
+ * index. The host persists the pending sets through `onPendingChange` /
266
+ * `restorePending` (HistoryModule keeps them in module state), so edits
267
+ * made during an embed-service outage survive a restart. Events that land
268
+ * while the module is detached are not replayed (removals are still hidden
269
+ * at search time by the current-branch check in HistoryModule).
270
+ */
271
+ private readonly editedIds = new Set<string>();
272
+ private readonly removedIds = new Set<string>();
273
+ private detach: (() => void) | null = null;
274
+ /** Set once the service answers 404 to /delete (a build without the route): removals are dropped from then on. */
275
+ private deleteUnsupported = false;
276
+ /** Called whenever the pending edit/remove sets change. */
277
+ onPendingChange: ((p: PendingChanges) => void) | null = null;
278
+
279
+ pendingSnapshot(): PendingChanges { return { edited: [...this.editedIds], removed: [...this.removedIds] }; }
280
+
281
+ restorePending(p: PendingChanges | null | undefined): void {
282
+ for (const id of p?.removed ?? []) { this.removedIds.add(id); this.editedIds.delete(id); }
283
+ for (const id of p?.edited ?? []) if (!this.removedIds.has(id)) this.editedIds.add(id);
284
+ }
285
+
286
+ private pendingChanged(): void { this.onPendingChange?.(this.pendingSnapshot()); }
287
+
288
+ /** Subscribe to message-store edits/removals. Idempotent. */
289
+ attach(): void {
290
+ if (this.detach) return;
291
+ const on = (this.cm as { onMessage?: ContextManager['onMessage'] }).onMessage;
292
+ if (typeof on !== 'function') {
293
+ this.log('[history/semantic] context-manager has no onMessage: edits and removals will not reach the index');
294
+ return;
295
+ }
296
+ this.detach = on.call(this.cm, (e) => {
297
+ if (e.type === 'edit') { this.editedIds.add(String(e.messageId)); this.pendingChanged(); }
298
+ else if (e.type === 'remove') {
299
+ const id = String(e.messageId); this.editedIds.delete(id);
300
+ if (!this.deleteUnsupported) this.removedIds.add(id);
301
+ this.pendingChanged();
302
+ }
303
+ // removeRange carries only its endpoints; removed ids in it are hidden at search time instead.
304
+ });
305
+ }
306
+
307
+ dispose(): void { this.detach?.(); this.detach = null; }
308
+
309
+ get backingOff(): boolean { return Date.now() < this.backoffUntil; }
310
+
311
+ /**
312
+ * Push what the index is missing, up to `maxItems`. Coalesces: a call while
313
+ * a sync is already running returns that run's promise instead of racing it.
314
+ */
315
+ catchUp(maxItems: number): Promise<SyncReport> {
316
+ if (this.inFlight) return this.inFlight;
317
+ const run = this.runCatchUp(maxItems).finally(() => { this.inFlight = null; });
318
+ this.inFlight = run;
319
+ return run;
320
+ }
321
+
322
+ private async runCatchUp(maxItems: number): Promise<SyncReport> {
323
+ const report: SyncReport = { pushed: 0, inserted: 0, updated: 0, unchanged: 0, more: false, messagesScanned: 0, summariesScanned: 0 };
324
+ if (this.backingOff) { report.more = true; return report; }
325
+ const batchSize = Math.min(256, Math.max(1, this.cfg.syncBatch ?? 128));
326
+ try {
327
+ const stats = await this.client.stats();
328
+ const msgWm = stats?.by_kind.message?.max_cursor ?? null;
329
+ const sumWm = stats?.by_kind.summary?.max_cursor ?? null;
330
+ let budget = maxItems;
331
+ let pending: IndexItem[] = [];
332
+ const flush = async (): Promise<void> => {
333
+ if (pending.length === 0) return;
334
+ const r = await this.client.upsert(pending);
335
+ report.pushed += pending.length; report.inserted += r.inserted; report.updated += r.updated; report.unchanged += r.unchanged;
336
+ pending = [];
337
+ };
338
+
339
+ // Edits and removals first: they never come back round in the time walk.
340
+ // This phase fails on its own: a failure re-queues its ids, is logged
341
+ // and reported via lastError, and the tick falls through to summaries
342
+ // and the time walk, so one bad delete/upsert cannot freeze indexing.
343
+ let phaseError: string | null = null;
344
+ if (this.removedIds.size > 0 || this.editedIds.size > 0) {
345
+ const removed = [...this.removedIds];
346
+ // Edits spend the budget like any other upsert, so a large queue left
347
+ // by an outage cannot make a search's bounded catch-up unbounded; the
348
+ // rest stay queued for the next pass.
349
+ // Only actionable edits (an upsert or a delete) spend it: one whose
350
+ // message left the branch is dropped for free.
351
+ this.removedIds.clear();
352
+ const edited: string[] = [];
353
+ const toDelete = removed.map((id) => `msg:${id}`);
354
+ const blanked: string[] = [];
355
+ const toUpsert: IndexItem[] = [];
356
+ for (const id of [...this.editedIds]) {
357
+ if (budget <= 0) break;
358
+ this.editedIds.delete(id);
359
+ const m = this.cm.getMessage(id as never);
360
+ // Not on the current branch (e.g. /undo after the edit): drop it,
361
+ // never delete — /redo brings the message back and the time walk
362
+ // will not revisit it. Search-time branch checks hide it meanwhile.
363
+ if (!m) continue;
364
+ edited.push(id); budget--;
365
+ const item = messageToItem(m, this.cfg);
366
+ // On the branch but edited down to nothing indexable: delete.
367
+ if (item) toUpsert.push(item); else { toDelete.push(`msg:${id}`); blanked.push(id); }
368
+ }
369
+ if (this.editedIds.size > 0) report.more = true;
370
+ if (toDelete.length > 0) {
371
+ try {
372
+ await this.client.delete(toDelete);
373
+ } catch (e) {
374
+ const msg = e instanceof Error ? e.message : String(e);
375
+ if (/→ 404/.test(msg)) {
376
+ // A service build without /delete (or a namespace that does
377
+ // not exist). Drop the removals and blanked edits: search-time
378
+ // checks (on-branch, still indexable) keep them out of results.
379
+ this.deleteUnsupported = true;
380
+ this.log(`[history/semantic] /delete unsupported by the embed-service; dropping ${toDelete.length} removal(s): ${msg}`);
381
+ } else {
382
+ for (const id of removed) this.removedIds.add(id);
383
+ for (const id of blanked) if (!this.removedIds.has(id)) this.editedIds.add(id);
384
+ phaseError = msg;
385
+ this.log(`[history/semantic] delete failed, ${removed.length} removal(s) re-queued: ${msg}`);
386
+ }
387
+ }
388
+ }
389
+ try {
390
+ for (let i = 0; i < toUpsert.length; i += batchSize) {
391
+ const chunk = toUpsert.slice(i, i + batchSize);
392
+ const r = await this.client.upsert(chunk);
393
+ report.pushed += chunk.length; report.inserted += r.inserted; report.updated += r.updated; report.unchanged += r.unchanged;
394
+ }
395
+ } catch (e) {
396
+ const msg = e instanceof Error ? e.message : String(e);
397
+ for (const id of edited) if (!this.removedIds.has(id)) this.editedIds.add(id);
398
+ phaseError = msg;
399
+ this.log(`[history/semantic] edit re-upsert failed, ${edited.length} edit(s) re-queued: ${msg}`);
400
+ }
401
+ this.pendingChanged();
402
+ }
403
+
404
+ // Summaries before messages: they are few, and a message backlog that
405
+ // exceeds every tick's budget must not keep new summaries out forever.
406
+ {
407
+ const all = this.cm.getSummariesInRange({ fromMs: 0, toMs: Number.MAX_SAFE_INTEGER }) as SummaryLike[];
408
+ report.summariesScanned = all.length;
409
+ // `>=`: summaries sharing the watermark's millisecond may be only
410
+ // partly indexed (a bounded pass can stop inside a tie). Re-sending
411
+ // them is a dedup no-op for the service and costs no budget.
412
+ const fresh = all.filter((s) => sumWm === null || s.createdMs >= sumWm).sort((a, b) => a.createdMs - b.createdMs);
413
+ for (const s of fresh) {
414
+ if (budget <= 0) { report.more = true; break; }
415
+ const item = summaryToItem(s, this.cfg);
416
+ if (!item) continue;
417
+ pending.push(item);
418
+ if (sumWm === null || s.createdMs > sumWm) budget--;
419
+ if (pending.length >= batchSize) await flush();
420
+ }
421
+ await flush();
422
+ }
423
+
424
+ // Messages: walk forward from (watermark - overlap) via the time index.
425
+ // Sound because rows are stamped at append time (CM addMessage takes no
426
+ // timestamp). An importer that writes historical stamps into a live
427
+ // store, or a backward clock step past the overlap, lands behind the
428
+ // watermark and is never indexed.
429
+ let fromMs = msgWm === null ? undefined : Math.max(0, msgWm - (this.cfg.overlapMs ?? 600_000));
430
+ // Messages at exactly `fromMs` already seen this pass. The query bounds
431
+ // are inclusive, so the next page starts at the last page's final
432
+ // millisecond and skips the ones already walked with `offset` — a run
433
+ // of more than a page within one millisecond is walked, not jumped.
434
+ let offset = 0;
435
+ const pageSize = 256;
436
+ for (;;) {
437
+ if (budget <= 0) { report.more = true; break; }
438
+ const page = this.cm.queryMessagesByTime({ fromMs, offset, limit: pageSize });
439
+ const msgs = page.messages;
440
+ report.messagesScanned += msgs.length;
441
+ for (const m of msgs) {
442
+ const item = messageToItem(m, this.cfg);
443
+ if (!item) continue;
444
+ pending.push(item);
445
+ // Only NEW items spend the budget. The overlap re-walk behind the
446
+ // watermark is a dedup no-op for the service, and it must be one
447
+ // for the budget too: when the overlap window held more messages
448
+ // than a tick's budget, every tick spent it all re-sending the same
449
+ // indexed items and never reached the first new one — the index
450
+ // froze at the watermark while reporting merely `more: true`.
451
+ if (msgWm === null || item.cursor === undefined || item.cursor > msgWm) budget--;
452
+ if (pending.length >= batchSize) await flush();
453
+ }
454
+ if (msgs.length < pageSize) break;
455
+ const lastMs = msgs[msgs.length - 1]!.timestamp.getTime();
456
+ const atLast = msgs.filter((m) => m.timestamp.getTime() === lastMs).length;
457
+ offset = lastMs === fromMs ? offset + atLast : atLast;
458
+ fromMs = lastMs;
459
+ }
460
+ await flush();
461
+ this.consecutiveFailures = 0; this.lastError = phaseError; this.lastSyncAt = Date.now();
462
+ return report;
463
+ } catch (e) {
464
+ this.consecutiveFailures++;
465
+ const delay = Math.min(15 * 60_000, 5_000 * 2 ** Math.min(8, this.consecutiveFailures - 1));
466
+ this.backoffUntil = Date.now() + delay;
467
+ this.lastError = e instanceof Error ? e.message : String(e);
468
+ this.log(`[history/semantic] sync failed (${this.consecutiveFailures}×, retry in ${Math.round(delay / 1000)}s): ${this.lastError}`);
469
+ report.more = true;
470
+ return report;
471
+ }
472
+ }
473
+ }
@@ -8,7 +8,8 @@ export type { ApiEvent } from './api/index.js';
8
8
  export { HealthModule } from './health/index.js';
9
9
  export type { HealthModuleConfig } from './health/index.js';
10
10
 
11
- export { HistoryModule } from './history/index.js';
11
+ export { HistoryModule, type HistoryModuleOptions } from './history/index.js';
12
+ export type { SemanticIndexConfig } from './history/semantic.js';
12
13
 
13
14
  export { WorkspaceModule, WorkspaceReadError } from './workspace/index.js';
14
15
  export type { WorkspaceReadErrorCode, WorkspaceReadStage, WorkspaceDiskReadResult, ReadFileFromDiskOptions } from './workspace/index.js';