dsh-tdai-memory 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed runtime: L0→L1→L2→L3 orchestration for the `seed` command.
|
|
3
|
+
*
|
|
4
|
+
* Uses the shared pipeline-factory for VectorStore/EmbeddingService init,
|
|
5
|
+
* L1 runner, L2 runner, L3 runner, and persister wiring — keeping this
|
|
6
|
+
* module focused on seed-specific concerns:
|
|
7
|
+
* - Synchronous per-round L0 capture with progress reporting
|
|
8
|
+
* - waitForL1Idle polling (L1 only — see FIXME below)
|
|
9
|
+
* - Ctrl+C graceful shutdown
|
|
10
|
+
*
|
|
11
|
+
* FIXME: Currently we only wait for L1 to become idle before destroying the
|
|
12
|
+
* pipeline. L2 (scene extraction) and L3 (persona generation) may still be
|
|
13
|
+
* in-flight when `pipeline.destroy()` is called. This is intentional for now
|
|
14
|
+
* to avoid excessively long seed runs, but means seed output may not include
|
|
15
|
+
* the latest L2/L3 artifacts. Re-evaluate adding a full L1+L2+L3 idle wait
|
|
16
|
+
* once pipeline-manager exposes reliable L2/L3 idle signals.
|
|
17
|
+
*/
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { parseConfig } from "../../config.js";
|
|
20
|
+
import { performAutoCapture } from "../hooks/auto-capture.js";
|
|
21
|
+
import { createPipeline, createL2Runner, createL3Runner } from "../../utils/pipeline-factory.js";
|
|
22
|
+
import { readManifest, writeManifest } from "../../utils/manifest.js";
|
|
23
|
+
import { StandaloneLLMRunnerFactory } from "../../adapters/standalone/llm-runner.js";
|
|
24
|
+
const TAG = "[memory-tdai] [seed]";
|
|
25
|
+
// ============================
|
|
26
|
+
// Seed pipeline creation
|
|
27
|
+
// ============================
|
|
28
|
+
/**
|
|
29
|
+
* Create a seed pipeline using the shared factory, with L2/L3 runners
|
|
30
|
+
* wired via shared factory functions (same logic as index.ts live runtime).
|
|
31
|
+
*/
|
|
32
|
+
async function createSeedPipeline(opts) {
|
|
33
|
+
const { outputDir, openclawConfig, pluginConfig, logger } = opts;
|
|
34
|
+
// Parse config — all values come from pluginConfig (or parseConfig defaults)
|
|
35
|
+
const cfg = parseConfig(pluginConfig);
|
|
36
|
+
logger.info(`${TAG} Creating seed pipeline: outputDir=${outputDir}, ` +
|
|
37
|
+
`everyN=${cfg.pipeline.everyNConversations}, l1Idle=${cfg.pipeline.l1IdleTimeoutSeconds}s, ` +
|
|
38
|
+
`l2Delay=${cfg.pipeline.l2DelayAfterL1Seconds}s, l2Min=${cfg.pipeline.l2MinIntervalSeconds}s, l2Max=${cfg.pipeline.l2MaxIntervalSeconds}s`);
|
|
39
|
+
// Create standalone LLM runners if cfg.llm is configured.
|
|
40
|
+
// Seed always runs outside OpenClaw, so it needs standalone runners
|
|
41
|
+
// unless an explicit openclawConfig is provided (rare).
|
|
42
|
+
let l1LlmRunner;
|
|
43
|
+
let l2l3LlmRunner;
|
|
44
|
+
if (cfg.llm.enabled && cfg.llm.apiKey) {
|
|
45
|
+
const runnerFactory = new StandaloneLLMRunnerFactory({
|
|
46
|
+
config: {
|
|
47
|
+
baseUrl: cfg.llm.baseUrl,
|
|
48
|
+
apiKey: cfg.llm.apiKey,
|
|
49
|
+
model: cfg.llm.model,
|
|
50
|
+
maxTokens: cfg.llm.maxTokens,
|
|
51
|
+
timeoutMs: cfg.llm.timeoutMs,
|
|
52
|
+
disableThinking: cfg.llm.disableThinking,
|
|
53
|
+
},
|
|
54
|
+
logger,
|
|
55
|
+
});
|
|
56
|
+
l1LlmRunner = runnerFactory.createRunner({ enableTools: false });
|
|
57
|
+
l2l3LlmRunner = runnerFactory.createRunner({ enableTools: true });
|
|
58
|
+
logger.info(`${TAG} Seed using standalone LLM: model=${cfg.llm.model}`);
|
|
59
|
+
}
|
|
60
|
+
// Use shared factory for everything: store init, L1 runner, persister, destroy
|
|
61
|
+
const pipeline = await createPipeline({
|
|
62
|
+
pluginDataDir: outputDir,
|
|
63
|
+
cfg,
|
|
64
|
+
openclawConfig,
|
|
65
|
+
logger,
|
|
66
|
+
l1LlmRunner,
|
|
67
|
+
});
|
|
68
|
+
// Wire L2 runner via shared factory (same logic as index.ts live runtime)
|
|
69
|
+
pipeline.scheduler.setL2Runner(createL2Runner({
|
|
70
|
+
pluginDataDir: outputDir,
|
|
71
|
+
cfg,
|
|
72
|
+
openclawConfig,
|
|
73
|
+
vectorStore: pipeline.vectorStore,
|
|
74
|
+
logger,
|
|
75
|
+
llmRunner: l2l3LlmRunner,
|
|
76
|
+
}));
|
|
77
|
+
// Wire L3 runner via shared factory (same logic as index.ts live runtime)
|
|
78
|
+
pipeline.scheduler.setL3Runner(createL3Runner({
|
|
79
|
+
pluginDataDir: outputDir,
|
|
80
|
+
cfg,
|
|
81
|
+
openclawConfig,
|
|
82
|
+
vectorStore: pipeline.vectorStore,
|
|
83
|
+
logger,
|
|
84
|
+
llmRunner: l2l3LlmRunner,
|
|
85
|
+
}));
|
|
86
|
+
return { pipeline, cfg };
|
|
87
|
+
}
|
|
88
|
+
// ============================
|
|
89
|
+
// waitForL1Idle
|
|
90
|
+
// ============================
|
|
91
|
+
/**
|
|
92
|
+
* Poll pipeline queue status until L1 is idle for a given session.
|
|
93
|
+
* Modeled after benchmark-ingest.ts waitForPipelineIdle() but focused on L1 only.
|
|
94
|
+
*/
|
|
95
|
+
async function waitForL1Idle(scheduler, sessionKeys, logger, opts = {}) {
|
|
96
|
+
const pollInterval = opts.pollIntervalMs ?? 1_000;
|
|
97
|
+
const stableRounds = opts.stableRounds ?? 3;
|
|
98
|
+
const maxWait = opts.maxWaitMs ?? 300_000; // 5 min default
|
|
99
|
+
const startTime = Date.now();
|
|
100
|
+
let consecutiveIdle = 0;
|
|
101
|
+
while (true) {
|
|
102
|
+
const elapsed = Date.now() - startTime;
|
|
103
|
+
if (elapsed > maxWait) {
|
|
104
|
+
logger.warn(`${TAG} [waitL1] Max wait time reached (${(maxWait / 1000).toFixed(0)}s), proceeding`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const queues = scheduler.getQueueSizes();
|
|
108
|
+
// Check per-session: buffered messages + conversation count
|
|
109
|
+
let totalBuffered = 0;
|
|
110
|
+
let totalConversationCount = 0;
|
|
111
|
+
for (const key of sessionKeys) {
|
|
112
|
+
totalBuffered += scheduler.getBufferedMessageCount(key);
|
|
113
|
+
const state = scheduler.getSessionState(key);
|
|
114
|
+
if (state) {
|
|
115
|
+
totalConversationCount += state.conversation_count;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const isIdle = queues.l1Idle &&
|
|
119
|
+
totalBuffered === 0 &&
|
|
120
|
+
totalConversationCount === 0;
|
|
121
|
+
if (isIdle) {
|
|
122
|
+
consecutiveIdle++;
|
|
123
|
+
if (consecutiveIdle >= stableRounds) {
|
|
124
|
+
logger.debug?.(`${TAG} [waitL1] L1 stable for ${stableRounds} consecutive polls`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
consecutiveIdle = 0;
|
|
130
|
+
logger.debug?.(`${TAG} [waitL1] Waiting: l1Queue=${queues.l1}, l1Pending=${queues.l1Pending}, l1Idle=${queues.l1Idle}, ` +
|
|
131
|
+
`buffered=${totalBuffered}, convCount=${totalConversationCount}`);
|
|
132
|
+
}
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ============================
|
|
137
|
+
// Main execution function
|
|
138
|
+
// ============================
|
|
139
|
+
/**
|
|
140
|
+
* Execute the seed pipeline: feed normalized input through L0 → L1.
|
|
141
|
+
*
|
|
142
|
+
* L2/L3 runners are wired but their completion is **not** awaited — see the
|
|
143
|
+
* module-level FIXME. The pipeline is destroyed after L1 idle, so L2/L3 may
|
|
144
|
+
* be interrupted mid-run.
|
|
145
|
+
*
|
|
146
|
+
* This is the core runtime called by `src/cli/commands/seed.ts` after
|
|
147
|
+
* all input validation and user confirmation are complete.
|
|
148
|
+
*/
|
|
149
|
+
export async function executeSeed(input, opts) {
|
|
150
|
+
const { logger, onProgress } = opts;
|
|
151
|
+
const startTime = Date.now();
|
|
152
|
+
// Track interrupt signal
|
|
153
|
+
let interrupted = false;
|
|
154
|
+
const onSigint = () => {
|
|
155
|
+
if (interrupted) {
|
|
156
|
+
// Second Ctrl+C — force exit
|
|
157
|
+
logger.warn(`${TAG} Force exit (second Ctrl+C)`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
interrupted = true;
|
|
161
|
+
logger.warn(`${TAG} Interrupt received, finishing current round and shutting down...`);
|
|
162
|
+
};
|
|
163
|
+
process.on("SIGINT", onSigint);
|
|
164
|
+
let pipeline;
|
|
165
|
+
let totalL0Recorded = 0;
|
|
166
|
+
let roundsProcessed = 0;
|
|
167
|
+
try {
|
|
168
|
+
// Create and start pipeline (returns both the pipeline instance and the
|
|
169
|
+
// seed-optimized config so we don't need to parse config again)
|
|
170
|
+
const seed = await createSeedPipeline(opts);
|
|
171
|
+
pipeline = seed.pipeline;
|
|
172
|
+
const seedCfg = seed.cfg;
|
|
173
|
+
pipeline.scheduler.start({});
|
|
174
|
+
logger.info(`${TAG} Pipeline started, processing ${input.sessions.length} session(s), ${input.totalRounds} round(s)`);
|
|
175
|
+
// Seed-specific: use 0 so the cold-start guard in captureAtomically()
|
|
176
|
+
// does NOT filter out historical messages. In live mode Date.now()
|
|
177
|
+
// prevents the first agent_end from dumping full session history,
|
|
178
|
+
// but seed intentionally feeds all historical data.
|
|
179
|
+
const captureStartTimestamp = 0;
|
|
180
|
+
// Process each session → each round
|
|
181
|
+
// Key invariant: after every everyNConversations rounds we must wait for L1
|
|
182
|
+
// to finish before feeding more rounds. Without this pause the for-loop
|
|
183
|
+
// would dump all rounds into L0 back-to-back and L1 would only run once
|
|
184
|
+
// with the full batch (defeating the "every N" batching semantics).
|
|
185
|
+
const everyN = seedCfg.pipeline.everyNConversations;
|
|
186
|
+
for (const session of input.sessions) {
|
|
187
|
+
if (interrupted)
|
|
188
|
+
break;
|
|
189
|
+
logger.info(`${TAG} Session: key="${session.sessionKey}" id="${session.sessionId}" rounds=${session.rounds.length}`);
|
|
190
|
+
for (let ri = 0; ri < session.rounds.length; ri++) {
|
|
191
|
+
if (interrupted)
|
|
192
|
+
break;
|
|
193
|
+
const round = session.rounds[ri];
|
|
194
|
+
roundsProcessed++;
|
|
195
|
+
// Build messages in the format expected by performAutoCapture.
|
|
196
|
+
// Field must be named "timestamp" (not "ts") because l0-recorder's
|
|
197
|
+
// extractUserAssistantMessages reads m.timestamp for incremental filtering.
|
|
198
|
+
const messages = round.messages.map((m) => ({
|
|
199
|
+
role: m.role,
|
|
200
|
+
content: m.content,
|
|
201
|
+
timestamp: m.timestamp,
|
|
202
|
+
}));
|
|
203
|
+
try {
|
|
204
|
+
const result = await performAutoCapture({
|
|
205
|
+
messages,
|
|
206
|
+
sessionKey: session.sessionKey,
|
|
207
|
+
sessionId: session.sessionId,
|
|
208
|
+
cfg: seedCfg,
|
|
209
|
+
pluginDataDir: opts.outputDir,
|
|
210
|
+
logger,
|
|
211
|
+
scheduler: pipeline.scheduler,
|
|
212
|
+
pluginStartTimestamp: captureStartTimestamp,
|
|
213
|
+
vectorStore: pipeline.vectorStore,
|
|
214
|
+
embeddingService: pipeline.embeddingService,
|
|
215
|
+
});
|
|
216
|
+
totalL0Recorded += result.l0RecordedCount;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
logger.error(`${TAG} L0 capture failed for session="${session.sessionKey}" round=${ri}: ` +
|
|
220
|
+
`${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
}
|
|
222
|
+
// Report progress
|
|
223
|
+
onProgress?.({
|
|
224
|
+
currentRound: roundsProcessed,
|
|
225
|
+
totalRounds: input.totalRounds,
|
|
226
|
+
sessionKey: session.sessionKey,
|
|
227
|
+
stage: "l0_captured",
|
|
228
|
+
});
|
|
229
|
+
// After every N rounds, wait for the triggered L1 to finish before
|
|
230
|
+
// feeding the next batch. This keeps L1 batches aligned with the
|
|
231
|
+
// everyNConversations boundary instead of letting all rounds pile up.
|
|
232
|
+
const roundInSession = ri + 1; // 1-based
|
|
233
|
+
if (roundInSession % everyN === 0 && !interrupted) {
|
|
234
|
+
onProgress?.({
|
|
235
|
+
currentRound: roundsProcessed,
|
|
236
|
+
totalRounds: input.totalRounds,
|
|
237
|
+
sessionKey: session.sessionKey,
|
|
238
|
+
stage: "l1_waiting",
|
|
239
|
+
});
|
|
240
|
+
logger.info(`${TAG} Pausing after round ${roundInSession}/${session.rounds.length} ` +
|
|
241
|
+
`for session="${session.sessionKey}" — waiting for L1 to drain`);
|
|
242
|
+
await waitForL1Idle(pipeline.scheduler, [session.sessionKey], logger, { pollIntervalMs: 500, stableRounds: 2, maxWaitMs: 120_000 });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// After all rounds for this session, wait for any residual L1 work
|
|
246
|
+
// (handles the tail when total rounds is not a multiple of everyN)
|
|
247
|
+
if (!interrupted) {
|
|
248
|
+
onProgress?.({
|
|
249
|
+
currentRound: roundsProcessed,
|
|
250
|
+
totalRounds: input.totalRounds,
|
|
251
|
+
sessionKey: session.sessionKey,
|
|
252
|
+
stage: "l1_waiting",
|
|
253
|
+
});
|
|
254
|
+
await waitForL1Idle(pipeline.scheduler, [session.sessionKey], logger, { pollIntervalMs: 1_000, stableRounds: 3, maxWaitMs: 300_000 });
|
|
255
|
+
logger.info(`${TAG} L1 idle for session="${session.sessionKey}"`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Final wait for all sessions
|
|
259
|
+
if (!interrupted) {
|
|
260
|
+
const allKeys = input.sessions.map((s) => s.sessionKey);
|
|
261
|
+
logger.info(`${TAG} Final L1 idle wait for all sessions...`);
|
|
262
|
+
await waitForL1Idle(pipeline.scheduler, allKeys, logger, { pollIntervalMs: 1_000, stableRounds: 3, maxWaitMs: 300_000 });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
process.removeListener("SIGINT", onSigint);
|
|
267
|
+
// Graceful shutdown
|
|
268
|
+
if (pipeline) {
|
|
269
|
+
try {
|
|
270
|
+
await pipeline.destroy();
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
logger.error(`${TAG} Pipeline destroy error: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const durationMs = Date.now() - startTime;
|
|
278
|
+
const summary = {
|
|
279
|
+
sessionsProcessed: input.sessions.length,
|
|
280
|
+
roundsProcessed,
|
|
281
|
+
messagesProcessed: input.totalMessages,
|
|
282
|
+
l0RecordedCount: totalL0Recorded,
|
|
283
|
+
durationMs,
|
|
284
|
+
outputDir: opts.outputDir,
|
|
285
|
+
};
|
|
286
|
+
if (interrupted) {
|
|
287
|
+
logger.warn(`${TAG} Seed interrupted after ${roundsProcessed}/${input.totalRounds} rounds`);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
logger.info(`${TAG} Seed complete: sessions=${summary.sessionsProcessed}, ` +
|
|
291
|
+
`rounds=${summary.roundsProcessed}, messages=${summary.messagesProcessed}, ` +
|
|
292
|
+
`l0Recorded=${summary.l0RecordedCount}, duration=${(durationMs / 1000).toFixed(1)}s`);
|
|
293
|
+
}
|
|
294
|
+
// Append seed info to manifest (non-fatal if it fails)
|
|
295
|
+
try {
|
|
296
|
+
const manifest = readManifest(opts.outputDir);
|
|
297
|
+
if (manifest) {
|
|
298
|
+
manifest.seed = {
|
|
299
|
+
inputFile: opts.inputFile ? path.basename(opts.inputFile) : undefined,
|
|
300
|
+
sessions: summary.sessionsProcessed,
|
|
301
|
+
rounds: summary.roundsProcessed,
|
|
302
|
+
messages: summary.messagesProcessed,
|
|
303
|
+
startedAt: new Date(startTime).toISOString(),
|
|
304
|
+
completedAt: new Date().toISOString(),
|
|
305
|
+
};
|
|
306
|
+
writeManifest(opts.outputDir, manifest);
|
|
307
|
+
logger.info(`${TAG} Manifest updated with seed info`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
logger.warn(`${TAG} Failed to update manifest with seed info (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
312
|
+
}
|
|
313
|
+
return summary;
|
|
314
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BM25 Sparse Vector Encoding Client.
|
|
3
|
+
*
|
|
4
|
+
* HTTP client for the BM25 Python sidecar service (bm25_server.py).
|
|
5
|
+
* Used by TCVDB backend to generate sparse vectors for hybridSearch.
|
|
6
|
+
*
|
|
7
|
+
* Two operations:
|
|
8
|
+
* - `encodeTexts(texts)` — encode documents for upsert (TF-based)
|
|
9
|
+
* - `encodeQueries(texts)` — encode queries for search (IDF-based)
|
|
10
|
+
*
|
|
11
|
+
* Graceful degradation: if the sidecar is unreachable, all methods
|
|
12
|
+
* return empty arrays and `isHealthy()` returns false. Callers can
|
|
13
|
+
* check health to dynamically downgrade to pure semantic search.
|
|
14
|
+
*/
|
|
15
|
+
// ============================
|
|
16
|
+
// Implementation
|
|
17
|
+
// ============================
|
|
18
|
+
const TAG = "[memory-tdai][bm25-client]";
|
|
19
|
+
export class BM25Client {
|
|
20
|
+
baseUrl;
|
|
21
|
+
timeout;
|
|
22
|
+
logger;
|
|
23
|
+
/** Cached health status to avoid repeated checks on every call. */
|
|
24
|
+
_healthy;
|
|
25
|
+
_lastHealthCheck = 0;
|
|
26
|
+
static HEALTH_CHECK_INTERVAL_MS = 30_000; // re-check every 30s
|
|
27
|
+
constructor(config, logger) {
|
|
28
|
+
this.baseUrl = config.serviceUrl.replace(/\/+$/, "");
|
|
29
|
+
this.timeout = config.timeout;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encode document texts for upsert (TF-based BM25 scoring).
|
|
34
|
+
* Returns one SparseVector per input text.
|
|
35
|
+
* Returns empty array on error (non-throwing).
|
|
36
|
+
*/
|
|
37
|
+
async encodeTexts(texts) {
|
|
38
|
+
if (texts.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
return this._encode("/encode_texts", texts);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Encode query texts for search (IDF-based BM25 scoring).
|
|
44
|
+
* Returns one SparseVector per input text.
|
|
45
|
+
* Returns empty array on error (non-throwing).
|
|
46
|
+
*/
|
|
47
|
+
async encodeQueries(texts) {
|
|
48
|
+
if (texts.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
return this._encode("/encode_queries", texts);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check if the BM25 sidecar is reachable.
|
|
54
|
+
* Result is cached for 30 seconds to avoid spamming health checks.
|
|
55
|
+
*/
|
|
56
|
+
async isHealthy() {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
if (this._healthy !== undefined &&
|
|
59
|
+
now - this._lastHealthCheck < BM25Client.HEALTH_CHECK_INTERVAL_MS) {
|
|
60
|
+
return this._healthy;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const resp = await fetch(`${this.baseUrl}/health`, {
|
|
64
|
+
signal: AbortSignal.timeout(3000),
|
|
65
|
+
});
|
|
66
|
+
this._healthy = resp.ok;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
this._healthy = false;
|
|
70
|
+
}
|
|
71
|
+
this._lastHealthCheck = now;
|
|
72
|
+
if (!this._healthy) {
|
|
73
|
+
this.logger?.warn(`${TAG} BM25 sidecar health check failed (${this.baseUrl})`);
|
|
74
|
+
}
|
|
75
|
+
return this._healthy;
|
|
76
|
+
}
|
|
77
|
+
// ── Internal ──────────────────────────────────────────────────
|
|
78
|
+
async _encode(path, texts) {
|
|
79
|
+
try {
|
|
80
|
+
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ texts }),
|
|
84
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
85
|
+
});
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
const errBody = await resp.text().catch(() => "(unreadable)");
|
|
88
|
+
this.logger?.warn(`${TAG} ${path} HTTP ${resp.status}: ${errBody.slice(0, 200)}`);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const json = (await resp.json());
|
|
92
|
+
return json.vectors ?? [];
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// Mark unhealthy on connection errors
|
|
96
|
+
this._healthy = false;
|
|
97
|
+
this._lastHealthCheck = Date.now();
|
|
98
|
+
this.logger?.warn(`${TAG} ${path} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ============================
|
|
104
|
+
// Factory
|
|
105
|
+
// ============================
|
|
106
|
+
/**
|
|
107
|
+
* Create a BM25Client if BM25 is enabled in config.
|
|
108
|
+
* Returns undefined if disabled — callers should check before using.
|
|
109
|
+
*/
|
|
110
|
+
export function createBM25Client(config, logger) {
|
|
111
|
+
if (!config.enabled) {
|
|
112
|
+
logger?.info(`${TAG} BM25 sparse encoding disabled`);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
logger?.info(`${TAG} BM25 client → ${config.serviceUrl}`);
|
|
116
|
+
return new BM25Client({ serviceUrl: config.serviceUrl, timeout: config.timeout }, logger);
|
|
117
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local BM25 Sparse Vector Encoder.
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript replacement for the Python sidecar BM25 client.
|
|
5
|
+
* Uses @tencentdb-agent-memory/tcvdb-text package for tokenization (jieba-wasm) and BM25 encoding.
|
|
6
|
+
*
|
|
7
|
+
* Two operations (same contract as the old BM25Client):
|
|
8
|
+
* - `encodeTexts(texts)` — encode documents for upsert (TF-based)
|
|
9
|
+
* - `encodeQueries(texts)` — encode queries for search (IDF-based)
|
|
10
|
+
*/
|
|
11
|
+
import { BM25Encoder } from "@tencentdb-agent-memory/tcvdb-text";
|
|
12
|
+
const TAG = "[memory-tdai][bm25-local]";
|
|
13
|
+
// ============================
|
|
14
|
+
// Implementation
|
|
15
|
+
// ============================
|
|
16
|
+
export class BM25LocalEncoder {
|
|
17
|
+
encoder;
|
|
18
|
+
logger;
|
|
19
|
+
constructor(language = "zh", logger) {
|
|
20
|
+
this.logger = logger;
|
|
21
|
+
this.encoder = BM25Encoder.default(language);
|
|
22
|
+
logger?.debug?.(`${TAG} Initialized BM25 local encoder (language=${language})`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Encode document texts for upsert (TF-based BM25 scoring).
|
|
26
|
+
* Returns one SparseVector per input text.
|
|
27
|
+
*/
|
|
28
|
+
encodeTexts(texts) {
|
|
29
|
+
if (texts.length === 0)
|
|
30
|
+
return [];
|
|
31
|
+
try {
|
|
32
|
+
return this.encoder.encodeTexts(texts);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
this.logger?.warn(`${TAG} encodeTexts failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Encode query texts for search (IDF-based BM25 scoring).
|
|
41
|
+
* Returns one SparseVector per input text.
|
|
42
|
+
*/
|
|
43
|
+
encodeQueries(texts) {
|
|
44
|
+
if (texts.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
try {
|
|
47
|
+
return this.encoder.encodeQueries(texts);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
this.logger?.warn(`${TAG} encodeQueries failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ============================
|
|
56
|
+
// Factory
|
|
57
|
+
// ============================
|
|
58
|
+
/**
|
|
59
|
+
* Create a BM25LocalEncoder if BM25 is enabled in config.
|
|
60
|
+
* Returns undefined if disabled — callers should check before using.
|
|
61
|
+
*/
|
|
62
|
+
export function createBM25Encoder(config, logger) {
|
|
63
|
+
if (!config.enabled) {
|
|
64
|
+
logger?.debug?.(`${TAG} BM25 sparse encoding disabled`);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return new BM25LocalEncoder(config.language ?? "zh", logger);
|
|
68
|
+
}
|