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,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory Store Abstraction Layer — Core Types & Interfaces.
|
|
3
|
+
*
|
|
4
|
+
* This module defines the storage contracts that all backend implementations
|
|
5
|
+
* (SQLite local, Tencent Cloud VectorDB, etc.) must satisfy.
|
|
6
|
+
*
|
|
7
|
+
* Design principles:
|
|
8
|
+
* 1. **Backend-agnostic**: Upper-layer modules (hooks, tools, pipeline, record)
|
|
9
|
+
* depend only on these interfaces — never on concrete implementations.
|
|
10
|
+
* 2. **Capability-based**: Features like vector search, FTS, and hybrid search
|
|
11
|
+
* are expressed as capability flags so callers can gracefully degrade.
|
|
12
|
+
* 3. **Fault-tolerant**: All methods return empty results or `false` on
|
|
13
|
+
* failure rather than throwing, unless explicitly documented otherwise.
|
|
14
|
+
* 4. **Sync-first**: Matches current SQLite DatabaseSync usage. TCVDB backend
|
|
15
|
+
* adapts internally without changing these signatures.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TdaiCore — Host-neutral facade for TDAI memory capabilities.
|
|
3
|
+
*
|
|
4
|
+
* This is the single entry point that both OpenClaw and Hermes/Gateway call
|
|
5
|
+
* to perform recall, capture, search, and pipeline management. It depends
|
|
6
|
+
* only on abstract interfaces (HostAdapter, LLMRunner), never on a specific host.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* // OpenClaw path (in-process)
|
|
10
|
+
* const adapter = new OpenClawHostAdapter({ api, pluginDataDir, config });
|
|
11
|
+
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedCfg });
|
|
12
|
+
* await core.initialize();
|
|
13
|
+
* const recall = await core.handleBeforeRecall("user query", "session-1");
|
|
14
|
+
*
|
|
15
|
+
* // Gateway path (HTTP)
|
|
16
|
+
* const adapter = new StandaloneHostAdapter({ ... });
|
|
17
|
+
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedCfg });
|
|
18
|
+
* await core.initialize();
|
|
19
|
+
* // HTTP handler calls core.handleBeforeRecall / core.handleTurnCommitted / etc.
|
|
20
|
+
*/
|
|
21
|
+
import { performAutoRecall } from "./hooks/auto-recall.js";
|
|
22
|
+
import { performAutoCapture } from "./hooks/auto-capture.js";
|
|
23
|
+
import { executeMemorySearch, formatSearchResponse } from "./tools/memory-search.js";
|
|
24
|
+
import { executeConversationSearch, formatConversationSearchResponse } from "./tools/conversation-search.js";
|
|
25
|
+
import { initDataDirectories, initStores, resetStores, createPipelineManager, createL1Runner, createPersister, createL2Runner, createL3Runner, } from "../utils/pipeline-factory.js";
|
|
26
|
+
import { CheckpointManager } from "../utils/checkpoint.js";
|
|
27
|
+
import { SessionFilter } from "../utils/session-filter.js";
|
|
28
|
+
import { StandaloneLLMRunnerFactory } from "../adapters/standalone/llm-runner.js";
|
|
29
|
+
const TAG = "[memory-tdai] [core]";
|
|
30
|
+
// ============================
|
|
31
|
+
// TdaiCore
|
|
32
|
+
// ============================
|
|
33
|
+
export class TdaiCore {
|
|
34
|
+
hostAdapter;
|
|
35
|
+
cfg;
|
|
36
|
+
logger;
|
|
37
|
+
dataDir;
|
|
38
|
+
runnerFactory;
|
|
39
|
+
sessionFilter;
|
|
40
|
+
instanceId;
|
|
41
|
+
// Lazy-initialized resources
|
|
42
|
+
vectorStore;
|
|
43
|
+
embeddingService;
|
|
44
|
+
scheduler;
|
|
45
|
+
/**
|
|
46
|
+
* Promise gate for the one-shot scheduler-start sequence.
|
|
47
|
+
*
|
|
48
|
+
* ``ensureSchedulerStarted`` reads a checkpoint file (async) and then
|
|
49
|
+
* calls ``scheduler.start(restoredStates)``. Under the Gateway, several
|
|
50
|
+
* HTTP requests can reach ``handleTurnCommitted`` concurrently and all
|
|
51
|
+
* race into that function. Using a plain boolean flag is unsafe: the
|
|
52
|
+
* first caller flips the flag to ``true`` *before* the await completes,
|
|
53
|
+
* so subsequent callers slip past the check and touch the scheduler
|
|
54
|
+
* before ``start()`` has actually run — which makes ``start()``'s
|
|
55
|
+
* ``sessionStates.set(key, restored)`` later clobber the state that
|
|
56
|
+
* those concurrent captures already incremented.
|
|
57
|
+
*
|
|
58
|
+
* Storing the in-flight promise lets every concurrent caller ``await``
|
|
59
|
+
* the same start sequence. Once it resolves the promise is kept as a
|
|
60
|
+
* sentinel so subsequent calls are a single already-resolved await
|
|
61
|
+
* (effectively a no-op).
|
|
62
|
+
*/
|
|
63
|
+
schedulerStartPromise;
|
|
64
|
+
storeReady;
|
|
65
|
+
/**
|
|
66
|
+
* In-flight fire-and-forget background tasks started by
|
|
67
|
+
* ``handleTurnCommitted`` (currently: deferred L0 embedding for
|
|
68
|
+
* SQLite-style stores — see auto-capture.ts path A).
|
|
69
|
+
*
|
|
70
|
+
* ``destroy()`` awaits all pending entries (with a hard timeout)
|
|
71
|
+
* before closing ``vectorStore`` / ``embeddingService`` so that a
|
|
72
|
+
* late ``updateL0Embedding`` cannot land on an already-closed
|
|
73
|
+
* database connection.
|
|
74
|
+
*
|
|
75
|
+
* Each task registers itself on creation and removes itself in its
|
|
76
|
+
* own ``finally`` handler, so the set stays bounded by the number
|
|
77
|
+
* of currently-running background tasks.
|
|
78
|
+
*/
|
|
79
|
+
bgTasks = new Set();
|
|
80
|
+
constructor(opts) {
|
|
81
|
+
this.hostAdapter = opts.hostAdapter;
|
|
82
|
+
this.cfg = opts.config;
|
|
83
|
+
this.logger = opts.hostAdapter.getLogger();
|
|
84
|
+
this.dataDir = opts.hostAdapter.getRuntimeContext().dataDir;
|
|
85
|
+
this.runnerFactory = opts.hostAdapter.getLLMRunnerFactory();
|
|
86
|
+
this.sessionFilter = opts.sessionFilter ?? new SessionFilter([]);
|
|
87
|
+
this.instanceId = opts.instanceId;
|
|
88
|
+
}
|
|
89
|
+
// ============================
|
|
90
|
+
// Lifecycle
|
|
91
|
+
// ============================
|
|
92
|
+
/**
|
|
93
|
+
* Initialize data directories, storage, and pipeline scheduler.
|
|
94
|
+
* Must be called once before any other methods.
|
|
95
|
+
*/
|
|
96
|
+
async initialize() {
|
|
97
|
+
this.logger.debug?.(`${TAG} Initializing TDAI Core: dataDir=${this.dataDir}`);
|
|
98
|
+
initDataDirectories(this.dataDir);
|
|
99
|
+
// Initialize stores (async)
|
|
100
|
+
this.storeReady = this.initStores();
|
|
101
|
+
// Create pipeline manager (sync — does not need store)
|
|
102
|
+
if (this.cfg.extraction.enabled) {
|
|
103
|
+
this.scheduler = createPipelineManager(this.cfg, this.logger, this.sessionFilter);
|
|
104
|
+
// Wire runners after store is ready (or after store init fails — runners
|
|
105
|
+
// still work in degraded mode with JSONL fallback and no embedding)
|
|
106
|
+
this.storeReady
|
|
107
|
+
.then(() => this.wirePipelineRunners())
|
|
108
|
+
.catch((err) => {
|
|
109
|
+
this.logger.error(`${TAG} Store init failed; wiring pipeline runners in degraded mode: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
this.wirePipelineRunners();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
this.logger.debug?.(`${TAG} TDAI Core initialized`);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Destroy all resources. Call on shutdown.
|
|
117
|
+
*/
|
|
118
|
+
async destroy() {
|
|
119
|
+
this.logger.debug?.(`${TAG} Destroying TDAI Core...`);
|
|
120
|
+
// Wait for store init to complete before tearing down
|
|
121
|
+
await this.storeReady?.catch(() => { });
|
|
122
|
+
if (this.scheduler && this.schedulerStartPromise) {
|
|
123
|
+
await this.scheduler.destroy();
|
|
124
|
+
this.schedulerStartPromise = undefined;
|
|
125
|
+
this.logger.debug?.(`${TAG} Scheduler destroyed`);
|
|
126
|
+
}
|
|
127
|
+
// Drain fire-and-forget background tasks started by auto-capture
|
|
128
|
+
// (currently: deferred L0 embedding writes). We must wait for
|
|
129
|
+
// them here — BEFORE closing vectorStore / embeddingService —
|
|
130
|
+
// otherwise a late updateL0Embedding lands on an already-closed
|
|
131
|
+
// DB connection and either throws "database is not open" or
|
|
132
|
+
// (worse) corrupts state. A hard timeout keeps destroy bounded
|
|
133
|
+
// when a background task is stuck on a hung embed HTTP call.
|
|
134
|
+
if (this.bgTasks.size > 0) {
|
|
135
|
+
const pending = [...this.bgTasks];
|
|
136
|
+
this.logger.debug?.(`${TAG} Draining ${pending.length} background task(s) before closing stores...`);
|
|
137
|
+
const BG_DRAIN_TIMEOUT_MS = 5_000;
|
|
138
|
+
let drainTimeoutId;
|
|
139
|
+
try {
|
|
140
|
+
await Promise.race([
|
|
141
|
+
Promise.allSettled(pending).then(() => undefined),
|
|
142
|
+
new Promise((_, reject) => {
|
|
143
|
+
drainTimeoutId = setTimeout(() => reject(new Error("bgTasks drain timeout")), BG_DRAIN_TIMEOUT_MS);
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
this.logger.debug?.(`${TAG} Background tasks drained`);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
this.logger.warn(`${TAG} Background-task drain timed out (${BG_DRAIN_TIMEOUT_MS}ms): ` +
|
|
150
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
151
|
+
`Closing stores anyway — residual writes may surface as warnings.`);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (drainTimeoutId !== undefined)
|
|
155
|
+
clearTimeout(drainTimeoutId);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (this.vectorStore) {
|
|
159
|
+
this.vectorStore.close();
|
|
160
|
+
this.vectorStore = undefined;
|
|
161
|
+
this.logger.debug?.(`${TAG} VectorStore closed`);
|
|
162
|
+
}
|
|
163
|
+
if (this.embeddingService?.close) {
|
|
164
|
+
try {
|
|
165
|
+
await this.embeddingService.close();
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
this.logger.warn(`${TAG} EmbeddingService close error: ${err instanceof Error ? err.message : String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
this.embeddingService = undefined;
|
|
171
|
+
}
|
|
172
|
+
resetStores(this.dataDir);
|
|
173
|
+
this.logger.debug?.(`${TAG} TDAI Core destroyed`);
|
|
174
|
+
}
|
|
175
|
+
// ============================
|
|
176
|
+
// Core capabilities
|
|
177
|
+
// ============================
|
|
178
|
+
/**
|
|
179
|
+
* Handle recall (memory retrieval) before an LLM turn.
|
|
180
|
+
* Maps to: OpenClaw `before_prompt_build` / Hermes `prefetch()`.
|
|
181
|
+
*/
|
|
182
|
+
async handleBeforeRecall(userText, sessionKey) {
|
|
183
|
+
await this.storeReady?.catch(() => { });
|
|
184
|
+
const result = await performAutoRecall({
|
|
185
|
+
userText,
|
|
186
|
+
actorId: "default_user",
|
|
187
|
+
sessionKey,
|
|
188
|
+
cfg: this.cfg,
|
|
189
|
+
pluginDataDir: this.dataDir,
|
|
190
|
+
logger: this.logger,
|
|
191
|
+
vectorStore: this.vectorStore,
|
|
192
|
+
embeddingService: this.embeddingService,
|
|
193
|
+
});
|
|
194
|
+
return result ?? {};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Handle turn commitment (conversation capture + pipeline trigger).
|
|
198
|
+
* Maps to: OpenClaw `agent_end` / Hermes `sync_turn()`.
|
|
199
|
+
*/
|
|
200
|
+
async handleTurnCommitted(turn) {
|
|
201
|
+
await this.storeReady?.catch(() => { });
|
|
202
|
+
await this.ensureSchedulerStarted();
|
|
203
|
+
return performAutoCapture({
|
|
204
|
+
messages: turn.messages,
|
|
205
|
+
sessionKey: turn.sessionKey,
|
|
206
|
+
sessionId: turn.sessionId,
|
|
207
|
+
cfg: this.cfg,
|
|
208
|
+
pluginDataDir: this.dataDir,
|
|
209
|
+
logger: this.logger,
|
|
210
|
+
scheduler: this.scheduler,
|
|
211
|
+
originalUserText: turn.userText,
|
|
212
|
+
originalUserMessageCount: turn.originalUserMessageCount,
|
|
213
|
+
pluginStartTimestamp: turn.startedAt ?? Date.now(),
|
|
214
|
+
vectorStore: this.vectorStore,
|
|
215
|
+
embeddingService: this.embeddingService,
|
|
216
|
+
bgTaskRegistry: this.bgTasks,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Search L1 structured memories.
|
|
221
|
+
* Maps to: `tdai_memory_search` tool.
|
|
222
|
+
*/
|
|
223
|
+
async searchMemories(params) {
|
|
224
|
+
const result = await executeMemorySearch({
|
|
225
|
+
query: params.query,
|
|
226
|
+
limit: params.limit ?? 5,
|
|
227
|
+
type: params.type,
|
|
228
|
+
scene: params.scene,
|
|
229
|
+
vectorStore: this.vectorStore,
|
|
230
|
+
embeddingService: this.embeddingService,
|
|
231
|
+
logger: this.logger,
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
text: formatSearchResponse(result),
|
|
235
|
+
total: result.total,
|
|
236
|
+
strategy: result.strategy,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Search L0 raw conversations.
|
|
241
|
+
* Maps to: `tdai_conversation_search` tool.
|
|
242
|
+
*/
|
|
243
|
+
async searchConversations(params) {
|
|
244
|
+
const result = await executeConversationSearch({
|
|
245
|
+
query: params.query,
|
|
246
|
+
limit: params.limit ?? 5,
|
|
247
|
+
sessionKey: params.sessionKey,
|
|
248
|
+
vectorStore: this.vectorStore,
|
|
249
|
+
embeddingService: this.embeddingService,
|
|
250
|
+
logger: this.logger,
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
text: formatConversationSearchResponse(result),
|
|
254
|
+
total: result.total,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Handle end-of-conversation for a single session.
|
|
259
|
+
*
|
|
260
|
+
* ⚠️ Read this if you are editing the method:
|
|
261
|
+
*
|
|
262
|
+
* There are two distinct shutdown-ish events, and they must **NOT**
|
|
263
|
+
* share an implementation:
|
|
264
|
+
*
|
|
265
|
+
* - **`gateway_stop` (OpenClaw / process exit)**
|
|
266
|
+
* The host is going away. Tear everything down — scheduler,
|
|
267
|
+
* VectorStore, EmbeddingService, caches. That is
|
|
268
|
+
* {@link destroy}, not this method.
|
|
269
|
+
*
|
|
270
|
+
* - **`on_session_end` (Hermes) / `POST /session/end` (Gateway)**
|
|
271
|
+
* One conversation ended while the process keeps serving other
|
|
272
|
+
* concurrent sessions. **Only** this session's buffered work
|
|
273
|
+
* should be flushed; every other session's timers, buffers,
|
|
274
|
+
* pipeline state, and the shared scheduler itself MUST remain
|
|
275
|
+
* untouched. That is this method.
|
|
276
|
+
*
|
|
277
|
+
* Historically this method did ``scheduler.destroy() +
|
|
278
|
+
* createPipelineManager()``, which conflated the two semantics and
|
|
279
|
+
* wiped concurrent sessions' in-memory state on every ``/session/end``
|
|
280
|
+
* call. That bug is covered by the concurrency test
|
|
281
|
+
* ``P0-1: handleSessionEnd must be scoped to its session``.
|
|
282
|
+
*
|
|
283
|
+
* @param sessionKey Session whose buffered work should be flushed.
|
|
284
|
+
* Unknown keys are tolerated as a no-op so callers
|
|
285
|
+
* don't have to pre-check whether the session was
|
|
286
|
+
* already evicted or never produced a capture.
|
|
287
|
+
*/
|
|
288
|
+
async handleSessionEnd(sessionKey) {
|
|
289
|
+
if (!sessionKey)
|
|
290
|
+
return;
|
|
291
|
+
await this.storeReady?.catch(() => { });
|
|
292
|
+
if (!this.scheduler)
|
|
293
|
+
return;
|
|
294
|
+
await this.scheduler.flushSession(sessionKey);
|
|
295
|
+
}
|
|
296
|
+
// ============================
|
|
297
|
+
// Accessors (for migration bridge)
|
|
298
|
+
// ============================
|
|
299
|
+
/** Get the LLM runner factory (for creating host-neutral LLM runners). */
|
|
300
|
+
getLLMRunnerFactory() {
|
|
301
|
+
return this.runnerFactory;
|
|
302
|
+
}
|
|
303
|
+
/** Get the shared VectorStore (may be undefined if init failed). */
|
|
304
|
+
getVectorStore() {
|
|
305
|
+
return this.vectorStore;
|
|
306
|
+
}
|
|
307
|
+
/** Get the shared EmbeddingService (may be undefined if not configured). */
|
|
308
|
+
getEmbeddingService() {
|
|
309
|
+
return this.embeddingService;
|
|
310
|
+
}
|
|
311
|
+
/** Get the pipeline scheduler (may be undefined if extraction disabled). */
|
|
312
|
+
getScheduler() {
|
|
313
|
+
return this.scheduler;
|
|
314
|
+
}
|
|
315
|
+
/** Whether the scheduler has been started (or is currently starting). */
|
|
316
|
+
isSchedulerStarted() {
|
|
317
|
+
return this.schedulerStartPromise !== undefined;
|
|
318
|
+
}
|
|
319
|
+
/** Set the instance ID for metrics (may be resolved asynchronously). */
|
|
320
|
+
setInstanceId(id) {
|
|
321
|
+
this.instanceId = id;
|
|
322
|
+
if (this.scheduler) {
|
|
323
|
+
this.scheduler.instanceId = id;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// ============================
|
|
327
|
+
// Internal helpers
|
|
328
|
+
// ============================
|
|
329
|
+
async initStores() {
|
|
330
|
+
try {
|
|
331
|
+
const stores = await initStores(this.cfg, this.dataDir, this.logger);
|
|
332
|
+
this.vectorStore = stores.vectorStore;
|
|
333
|
+
this.embeddingService = stores.embeddingService;
|
|
334
|
+
this.logger.debug?.(`${TAG} Stores initialized: backend=${this.cfg.storeBackend}, embedding=${this.cfg.embedding.provider}`);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
this.logger.warn(`${TAG} Store init failed; recall/dedup degraded: ${err instanceof Error ? err.message : String(err)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
wirePipelineRunners() {
|
|
341
|
+
if (!this.scheduler)
|
|
342
|
+
return;
|
|
343
|
+
// Determine whether to use standalone LLM runner for extraction.
|
|
344
|
+
// Priority: cfg.llm.enabled (explicit override) > hostType detection.
|
|
345
|
+
const useStandaloneRunner = this.cfg.llm.enabled || this.hostAdapter.hostType !== "openclaw";
|
|
346
|
+
const openclawConfig = (!useStandaloneRunner && this.hostAdapter.hostType === "openclaw")
|
|
347
|
+
? this.hostAdapter.getOpenClawConfig?.()
|
|
348
|
+
: undefined;
|
|
349
|
+
// When standalone runner is active, create LLM runners from the factory.
|
|
350
|
+
// If cfg.llm is configured AND we're in OpenClaw mode, build a dedicated
|
|
351
|
+
// StandaloneLLMRunnerFactory from cfg.llm to override the host runner.
|
|
352
|
+
let runnerFactory = this.runnerFactory;
|
|
353
|
+
if (useStandaloneRunner && this.cfg.llm.enabled && this.hostAdapter.hostType === "openclaw") {
|
|
354
|
+
runnerFactory = new StandaloneLLMRunnerFactory({
|
|
355
|
+
config: {
|
|
356
|
+
baseUrl: this.cfg.llm.baseUrl,
|
|
357
|
+
apiKey: this.cfg.llm.apiKey,
|
|
358
|
+
model: this.cfg.llm.model,
|
|
359
|
+
maxTokens: this.cfg.llm.maxTokens,
|
|
360
|
+
timeoutMs: this.cfg.llm.timeoutMs,
|
|
361
|
+
disableThinking: this.cfg.llm.disableThinking,
|
|
362
|
+
},
|
|
363
|
+
logger: this.logger,
|
|
364
|
+
});
|
|
365
|
+
this.logger.debug?.(`${TAG} Using standalone LLM override: model=${this.cfg.llm.model}, baseUrl=${this.cfg.llm.baseUrl}`);
|
|
366
|
+
}
|
|
367
|
+
const l1LlmRunner = useStandaloneRunner
|
|
368
|
+
? runnerFactory.createRunner({ enableTools: false })
|
|
369
|
+
: undefined;
|
|
370
|
+
const l2l3LlmRunner = useStandaloneRunner
|
|
371
|
+
? runnerFactory.createRunner({ enableTools: true })
|
|
372
|
+
: undefined;
|
|
373
|
+
// L1 runner
|
|
374
|
+
this.scheduler.setL1Runner(createL1Runner({
|
|
375
|
+
pluginDataDir: this.dataDir,
|
|
376
|
+
cfg: this.cfg,
|
|
377
|
+
openclawConfig,
|
|
378
|
+
vectorStore: this.vectorStore,
|
|
379
|
+
embeddingService: this.embeddingService,
|
|
380
|
+
logger: this.logger,
|
|
381
|
+
getInstanceId: () => this.instanceId,
|
|
382
|
+
llmRunner: l1LlmRunner,
|
|
383
|
+
}));
|
|
384
|
+
// Persister
|
|
385
|
+
this.scheduler.setPersister(createPersister(this.dataDir, this.logger));
|
|
386
|
+
// L2 runner
|
|
387
|
+
this.scheduler.setL2Runner(async (sessionKey, cursor) => {
|
|
388
|
+
const l2Runner = createL2Runner({
|
|
389
|
+
pluginDataDir: this.dataDir,
|
|
390
|
+
cfg: this.cfg,
|
|
391
|
+
openclawConfig,
|
|
392
|
+
vectorStore: this.vectorStore,
|
|
393
|
+
logger: this.logger,
|
|
394
|
+
instanceId: this.instanceId,
|
|
395
|
+
llmRunner: l2l3LlmRunner,
|
|
396
|
+
});
|
|
397
|
+
return l2Runner(sessionKey, cursor);
|
|
398
|
+
});
|
|
399
|
+
// L3 runner
|
|
400
|
+
this.scheduler.setL3Runner(async () => {
|
|
401
|
+
const l3Runner = createL3Runner({
|
|
402
|
+
pluginDataDir: this.dataDir,
|
|
403
|
+
cfg: this.cfg,
|
|
404
|
+
openclawConfig,
|
|
405
|
+
vectorStore: this.vectorStore,
|
|
406
|
+
logger: this.logger,
|
|
407
|
+
instanceId: this.instanceId,
|
|
408
|
+
llmRunner: l2l3LlmRunner,
|
|
409
|
+
});
|
|
410
|
+
await l3Runner();
|
|
411
|
+
});
|
|
412
|
+
this.logger.debug?.(`${TAG} Pipeline runners wired`);
|
|
413
|
+
}
|
|
414
|
+
ensureSchedulerStarted() {
|
|
415
|
+
// Fast path: already started (or starting) — every concurrent caller
|
|
416
|
+
// awaits the same in-flight promise. The promise is kept around as a
|
|
417
|
+
// permanently-resolved sentinel after success so subsequent calls
|
|
418
|
+
// collapse into a cheap already-resolved await.
|
|
419
|
+
if (this.schedulerStartPromise)
|
|
420
|
+
return this.schedulerStartPromise;
|
|
421
|
+
if (!this.scheduler)
|
|
422
|
+
return Promise.resolve();
|
|
423
|
+
// Capture scheduler locally so TypeScript narrows inside the closure
|
|
424
|
+
// even after ``this.scheduler`` is re-assigned by handleSessionEnd.
|
|
425
|
+
const scheduler = this.scheduler;
|
|
426
|
+
this.schedulerStartPromise = (async () => {
|
|
427
|
+
try {
|
|
428
|
+
const checkpoint = new CheckpointManager(this.dataDir, this.logger);
|
|
429
|
+
const cp = await checkpoint.read();
|
|
430
|
+
scheduler.start(checkpoint.getAllPipelineStates(cp));
|
|
431
|
+
this.logger.debug?.(`${TAG} Scheduler started`);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
this.logger.error(`${TAG} Failed to restore checkpoint: ${err instanceof Error ? err.message : String(err)}`);
|
|
435
|
+
scheduler.start({});
|
|
436
|
+
}
|
|
437
|
+
})();
|
|
438
|
+
// If the start sequence itself rejects we clear the gate so the next
|
|
439
|
+
// caller can retry; on success we keep the resolved promise so it
|
|
440
|
+
// short-circuits permanently.
|
|
441
|
+
this.schedulerStartPromise.catch(() => {
|
|
442
|
+
this.schedulerStartPromise = undefined;
|
|
443
|
+
});
|
|
444
|
+
return this.schedulerStartPromise;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation_search tool: Agent-callable tool for searching L0 conversation records.
|
|
3
|
+
*
|
|
4
|
+
* Supports three search strategies with automatic degradation:
|
|
5
|
+
* 1. **hybrid** (default) — FTS5 keyword + vector embedding in parallel,
|
|
6
|
+
* merged via Reciprocal Rank Fusion (RRF).
|
|
7
|
+
* 2. **embedding** — pure vector similarity (when FTS5 is unavailable).
|
|
8
|
+
* 3. **fts** — pure FTS5 keyword search (when embedding is unavailable).
|
|
9
|
+
*
|
|
10
|
+
* The tool is registered via `api.registerTool()` in index.ts.
|
|
11
|
+
*/
|
|
12
|
+
import { buildFtsQuery } from "../store/sqlite.js";
|
|
13
|
+
const TAG = "[memory-tdai][tdai_conversation_search]";
|
|
14
|
+
// ============================
|
|
15
|
+
// RRF (Reciprocal Rank Fusion)
|
|
16
|
+
// ============================
|
|
17
|
+
/** Standard RRF constant from the original RRF paper. */
|
|
18
|
+
const RRF_K = 60;
|
|
19
|
+
/**
|
|
20
|
+
* Merge multiple ranked lists of `ConversationSearchResultItem` via Reciprocal
|
|
21
|
+
* Rank Fusion. Items appearing in multiple lists get their RRF scores summed.
|
|
22
|
+
*
|
|
23
|
+
* Returns items sorted by descending RRF score. The `score` field of each
|
|
24
|
+
* returned item is replaced by the RRF score for consistent ranking semantics.
|
|
25
|
+
*/
|
|
26
|
+
function rrfMergeL0(...lists) {
|
|
27
|
+
const map = new Map();
|
|
28
|
+
for (const list of lists) {
|
|
29
|
+
for (let rank = 0; rank < list.length; rank++) {
|
|
30
|
+
const item = list[rank];
|
|
31
|
+
const score = 1 / (RRF_K + rank + 1);
|
|
32
|
+
const existing = map.get(item.id);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.rrfScore += score;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
map.set(item.id, { item, rrfScore: score });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...map.values()]
|
|
42
|
+
.sort((a, b) => b.rrfScore - a.rrfScore)
|
|
43
|
+
.map(({ item, rrfScore }) => ({ ...item, score: rrfScore }));
|
|
44
|
+
}
|
|
45
|
+
// ============================
|
|
46
|
+
// Search implementation
|
|
47
|
+
// ============================
|
|
48
|
+
export async function executeConversationSearch(params) {
|
|
49
|
+
const { query, limit, sessionKey: sessionFilter, vectorStore, embeddingService, logger, } = params;
|
|
50
|
+
logger?.debug?.(`${TAG} CALLED: query="${query.slice(0, 100)}", limit=${limit}, ` +
|
|
51
|
+
`sessionFilter=${sessionFilter ?? "(none)"}, ` +
|
|
52
|
+
`vectorStore=${vectorStore ? "available" : "UNAVAILABLE"}, ` +
|
|
53
|
+
`embeddingService=${embeddingService ? "available" : "UNAVAILABLE"}`);
|
|
54
|
+
if (!query || query.trim().length === 0) {
|
|
55
|
+
logger?.debug?.(`${TAG} Empty query, returning empty`);
|
|
56
|
+
return { results: [], total: 0, strategy: "none" };
|
|
57
|
+
}
|
|
58
|
+
if (!vectorStore) {
|
|
59
|
+
logger?.warn?.(`${TAG} VectorStore not available`);
|
|
60
|
+
return { results: [], total: 0, strategy: "none" };
|
|
61
|
+
}
|
|
62
|
+
// ── Determine available capabilities ──
|
|
63
|
+
const hasEmbedding = !!embeddingService;
|
|
64
|
+
const hasFts = vectorStore.isFtsAvailable();
|
|
65
|
+
if (!hasEmbedding && !hasFts) {
|
|
66
|
+
logger?.warn?.(`${TAG} Neither EmbeddingService nor FTS5 available — cannot search`);
|
|
67
|
+
return {
|
|
68
|
+
results: [],
|
|
69
|
+
total: 0,
|
|
70
|
+
strategy: "none",
|
|
71
|
+
message: "Embedding service is not configured and FTS is not available. " +
|
|
72
|
+
"Conversation search requires an embedding provider or FTS5 support. " +
|
|
73
|
+
"Please configure an embedding provider in the embedding.provider setting (e.g. openai_compatible).",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// ── Over-retrieve for later filtering and RRF merging ──
|
|
77
|
+
const candidateK = sessionFilter ? limit * 4 : limit * 3;
|
|
78
|
+
// ── Run available search strategies in parallel ──
|
|
79
|
+
const [ftsItems, vecItems] = await Promise.all([
|
|
80
|
+
// FTS5 keyword search on L0
|
|
81
|
+
(async () => {
|
|
82
|
+
if (!hasFts)
|
|
83
|
+
return [];
|
|
84
|
+
try {
|
|
85
|
+
const ftsQuery = buildFtsQuery(query);
|
|
86
|
+
if (!ftsQuery) {
|
|
87
|
+
logger?.debug?.(`${TAG} [hybrid-fts] No usable FTS tokens from query`);
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 query: "${ftsQuery}"`);
|
|
91
|
+
const ftsResults = await vectorStore.searchL0Fts(ftsQuery, candidateK);
|
|
92
|
+
logger?.debug?.(`${TAG} [hybrid-fts] FTS5 returned ${ftsResults.length} candidates`);
|
|
93
|
+
return ftsResults.map((r) => ({
|
|
94
|
+
id: r.record_id,
|
|
95
|
+
session_key: r.session_key,
|
|
96
|
+
role: r.role,
|
|
97
|
+
content: r.message_text,
|
|
98
|
+
score: r.score,
|
|
99
|
+
recorded_at: r.recorded_at,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
logger?.warn?.(`${TAG} [hybrid-fts] FTS5 search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
})(),
|
|
107
|
+
// Vector embedding search on L0
|
|
108
|
+
(async () => {
|
|
109
|
+
if (!hasEmbedding)
|
|
110
|
+
return [];
|
|
111
|
+
try {
|
|
112
|
+
logger?.debug?.(`${TAG} [hybrid-vec] Generating query embedding...`);
|
|
113
|
+
const queryEmbedding = await embeddingService.embed(query);
|
|
114
|
+
logger?.debug?.(`${TAG} [hybrid-vec] Embedding OK, dims=${queryEmbedding.length}, searching top-${candidateK}...`);
|
|
115
|
+
const vecResults = await vectorStore.searchL0Vector(queryEmbedding, candidateK, query);
|
|
116
|
+
logger?.debug?.(`${TAG} [hybrid-vec] Vector search returned ${vecResults.length} candidates`);
|
|
117
|
+
return vecResults.map((r) => ({
|
|
118
|
+
id: r.record_id,
|
|
119
|
+
session_key: r.session_key,
|
|
120
|
+
role: r.role,
|
|
121
|
+
content: r.message_text,
|
|
122
|
+
score: r.score,
|
|
123
|
+
recorded_at: r.recorded_at,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
logger?.warn?.(`${TAG} [hybrid-vec] Embedding search failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
})(),
|
|
131
|
+
]);
|
|
132
|
+
// ── Determine effective strategy ──
|
|
133
|
+
const ftsOk = ftsItems.length > 0;
|
|
134
|
+
const vecOk = vecItems.length > 0;
|
|
135
|
+
let strategy;
|
|
136
|
+
if (ftsOk && vecOk) {
|
|
137
|
+
strategy = "hybrid";
|
|
138
|
+
}
|
|
139
|
+
else if (vecOk) {
|
|
140
|
+
strategy = "embedding";
|
|
141
|
+
}
|
|
142
|
+
else if (ftsOk) {
|
|
143
|
+
strategy = "fts";
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
logger?.debug?.(`${TAG} Both search paths returned 0 results`);
|
|
147
|
+
return { results: [], total: 0, strategy: hasEmbedding ? "embedding" : "fts" };
|
|
148
|
+
}
|
|
149
|
+
// ── Merge results ──
|
|
150
|
+
let results;
|
|
151
|
+
if (strategy === "hybrid") {
|
|
152
|
+
results = rrfMergeL0(ftsItems, vecItems);
|
|
153
|
+
logger?.debug?.(`${TAG} [hybrid] RRF merged: fts=${ftsItems.length}, vec=${vecItems.length} → ${results.length} unique`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// Single-source: use whichever list has results (already sorted by score)
|
|
157
|
+
results = ftsOk ? ftsItems : vecItems;
|
|
158
|
+
}
|
|
159
|
+
// ── Apply session key filter ──
|
|
160
|
+
if (sessionFilter) {
|
|
161
|
+
const preFilterCount = results.length;
|
|
162
|
+
results = results.filter((r) => r.session_key === sessionFilter);
|
|
163
|
+
logger?.debug?.(`${TAG} After session filter "${sessionFilter}": ${results.length}/${preFilterCount}`);
|
|
164
|
+
}
|
|
165
|
+
// ── Trim to requested limit ──
|
|
166
|
+
const trimmed = results.slice(0, limit);
|
|
167
|
+
logger?.debug?.(`${TAG} RESULT (strategy=${strategy}): returning ${trimmed.length} messages ` +
|
|
168
|
+
`(scores: [${trimmed.map((r) => r.score.toFixed(3)).join(", ")}])`);
|
|
169
|
+
return {
|
|
170
|
+
results: trimmed,
|
|
171
|
+
total: trimmed.length,
|
|
172
|
+
strategy,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// ============================
|
|
176
|
+
// Tool response formatter
|
|
177
|
+
// ============================
|
|
178
|
+
export function formatConversationSearchResponse(result) {
|
|
179
|
+
if (result.message) {
|
|
180
|
+
return result.message;
|
|
181
|
+
}
|
|
182
|
+
if (result.results.length === 0) {
|
|
183
|
+
return "No matching conversation messages found.";
|
|
184
|
+
}
|
|
185
|
+
const lines = [
|
|
186
|
+
`Found ${result.total} matching message(s):`,
|
|
187
|
+
"",
|
|
188
|
+
];
|
|
189
|
+
for (const item of result.results) {
|
|
190
|
+
const scoreStr = typeof item.score === "number" ? ` (score: ${item.score.toFixed(3)})` : "";
|
|
191
|
+
const dateStr = item.recorded_at ? ` [${item.recorded_at}]` : "";
|
|
192
|
+
lines.push(`---`);
|
|
193
|
+
lines.push(`**[${item.role}]** Session: ${item.session_key}${dateStr}${scoreStr}`);
|
|
194
|
+
lines.push("");
|
|
195
|
+
lines.push(item.content);
|
|
196
|
+
lines.push("");
|
|
197
|
+
}
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|