dsh-tdai-memory 0.2.13 → 0.2.14
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 -21
- package/THIRD-PARTY-NOTICES.md +20 -20
- package/client.js +8 -4
- package/cordis.patch.yml +8 -8
- package/index.js +414 -414
- package/package.json +1 -1
- package/recall-inject.js +86 -86
- package/vendor/tdai/adapters/index.js +15 -15
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
- package/vendor/tdai/adapters/openclaw/index.js +5 -5
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
- package/vendor/tdai/adapters/standalone/index.js +5 -5
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -246
- package/vendor/tdai/config.js +363 -363
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
- package/vendor/tdai/core/hooks/auto-capture.js +236 -236
- package/vendor/tdai/core/hooks/auto-recall.js +653 -653
- package/vendor/tdai/core/index.js +8 -8
- package/vendor/tdai/core/persona/persona-generator.js +184 -184
- package/vendor/tdai/core/persona/persona-trigger.js +94 -94
- package/vendor/tdai/core/profile/profile-sync.js +200 -200
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -136
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -132
- package/vendor/tdai/core/prompts/persona-generation.js +160 -160
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -269
- package/vendor/tdai/core/record/l1-dedup.js +318 -318
- package/vendor/tdai/core/record/l1-extractor.js +379 -379
- package/vendor/tdai/core/record/l1-reader.js +178 -178
- package/vendor/tdai/core/record/l1-writer.js +141 -141
- package/vendor/tdai/core/report/reporter.js +86 -86
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
- package/vendor/tdai/core/scene/scene-extractor.js +410 -410
- package/vendor/tdai/core/scene/scene-format.js +53 -53
- package/vendor/tdai/core/scene/scene-index.js +82 -82
- package/vendor/tdai/core/scene/scene-navigation.js +60 -60
- package/vendor/tdai/core/seed/input.js +404 -404
- package/vendor/tdai/core/seed/seed-runtime.js +314 -314
- package/vendor/tdai/core/seed/types.js +9 -9
- package/vendor/tdai/core/store/bm25-client.js +117 -117
- package/vendor/tdai/core/store/bm25-local.js +68 -68
- package/vendor/tdai/core/store/embedding.js +632 -632
- package/vendor/tdai/core/store/factory.js +95 -95
- package/vendor/tdai/core/store/search-utils.js +54 -54
- package/vendor/tdai/core/store/sqlite.js +1822 -1822
- package/vendor/tdai/core/store/tcvdb-client.js +221 -221
- package/vendor/tdai/core/store/tcvdb.js +1047 -1047
- package/vendor/tdai/core/store/types.js +17 -17
- package/vendor/tdai/core/tdai-core.js +446 -446
- package/vendor/tdai/core/tools/conversation-search.js +199 -199
- package/vendor/tdai/core/tools/memory-search.js +207 -207
- package/vendor/tdai/core/types.js +12 -12
- package/vendor/tdai/utils/backup.js +189 -189
- package/vendor/tdai/utils/checkpoint.js +360 -360
- package/vendor/tdai/utils/clean-context-runner.js +472 -472
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
- package/vendor/tdai/utils/env.js +13 -13
- package/vendor/tdai/utils/managed-timer.js +127 -127
- package/vendor/tdai/utils/manifest.js +91 -91
- package/vendor/tdai/utils/memory-cleaner.js +300 -300
- package/vendor/tdai/utils/no-think-fetch.js +109 -109
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
- package/vendor/tdai/utils/pipeline-factory.js +517 -517
- package/vendor/tdai/utils/pipeline-manager.js +892 -892
- package/vendor/tdai/utils/sanitize.js +360 -360
- package/vendor/tdai/utils/serial-queue.js +103 -103
- package/vendor/tdai/utils/session-filter.js +88 -88
- package/vendor/tdai/utils/text-utils.js +29 -29
- package/vendor/tdai/utils/time.js +252 -252
|
@@ -1,446 +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
|
-
}
|
|
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
|
+
}
|