@animalabs/agent-framework 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +153 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1169 -76
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +59 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +106 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +69 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1201 -80
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +115 -7
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
|
@@ -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
|
+
}
|
package/src/modules/index.ts
CHANGED
|
@@ -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';
|