dsh-tdai-memory 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +9 -1
  3. package/README.zh.md +8 -1
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +4 -3
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +436 -436
  8. package/package.json +16 -4
  9. package/recall-inject.js +86 -86
  10. package/vendor/tdai/adapters/index.js +15 -15
  11. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  12. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  13. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  14. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  15. package/vendor/tdai/adapters/standalone/index.js +5 -5
  16. package/vendor/tdai/adapters/standalone/llm-runner.js +274 -274
  17. package/vendor/tdai/config.js +366 -366
  18. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  19. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  20. package/vendor/tdai/core/hooks/auto-recall.js +653 -653
  21. package/vendor/tdai/core/index.js +8 -8
  22. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  23. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  24. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  25. package/vendor/tdai/core/prompts/l1-dedup.js +136 -136
  26. package/vendor/tdai/core/prompts/l1-extraction.js +132 -132
  27. package/vendor/tdai/core/prompts/persona-generation.js +160 -160
  28. package/vendor/tdai/core/prompts/scene-extraction.js +269 -269
  29. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  30. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  31. package/vendor/tdai/core/record/l1-reader.js +178 -178
  32. package/vendor/tdai/core/record/l1-writer.js +141 -141
  33. package/vendor/tdai/core/report/reporter.js +86 -86
  34. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  35. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  36. package/vendor/tdai/core/scene/scene-format.js +53 -53
  37. package/vendor/tdai/core/scene/scene-index.js +82 -82
  38. package/vendor/tdai/core/scene/scene-navigation.js +60 -60
  39. package/vendor/tdai/core/seed/input.js +404 -404
  40. package/vendor/tdai/core/seed/seed-runtime.js +318 -318
  41. package/vendor/tdai/core/seed/types.js +9 -9
  42. package/vendor/tdai/core/store/bm25-client.js +117 -117
  43. package/vendor/tdai/core/store/bm25-local.js +68 -68
  44. package/vendor/tdai/core/store/embedding.js +632 -632
  45. package/vendor/tdai/core/store/factory.js +95 -95
  46. package/vendor/tdai/core/store/search-utils.js +54 -54
  47. package/vendor/tdai/core/store/sqlite.js +1822 -1822
  48. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  49. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  50. package/vendor/tdai/core/store/types.js +17 -17
  51. package/vendor/tdai/core/tdai-core.js +450 -450
  52. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  53. package/vendor/tdai/core/tools/memory-search.js +207 -207
  54. package/vendor/tdai/core/types.js +12 -12
  55. package/vendor/tdai/utils/backup.js +189 -189
  56. package/vendor/tdai/utils/checkpoint.js +360 -360
  57. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  58. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  59. package/vendor/tdai/utils/env.js +13 -13
  60. package/vendor/tdai/utils/managed-timer.js +127 -127
  61. package/vendor/tdai/utils/manifest.js +91 -91
  62. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  63. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  64. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  65. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  66. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  67. package/vendor/tdai/utils/sanitize.js +360 -360
  68. package/vendor/tdai/utils/serial-queue.js +103 -103
  69. package/vendor/tdai/utils/session-filter.js +88 -88
  70. package/vendor/tdai/utils/session-header-fetch.js +88 -88
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. package/vendor/tdai/utils/time.js +252 -252
@@ -1,450 +1,450 @@
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
- sendSessionHeader: this.cfg.llm.sendSessionHeader,
363
- sessionHeaderName: this.cfg.llm.sessionHeaderName,
364
- sessionId: this.cfg.llm.sessionId,
365
- dataDir: this.dataDir,
366
- },
367
- logger: this.logger,
368
- });
369
- this.logger.debug?.(`${TAG} Using standalone LLM override: model=${this.cfg.llm.model}, baseUrl=${this.cfg.llm.baseUrl}`);
370
- }
371
- const l1LlmRunner = useStandaloneRunner
372
- ? runnerFactory.createRunner({ enableTools: false })
373
- : undefined;
374
- const l2l3LlmRunner = useStandaloneRunner
375
- ? runnerFactory.createRunner({ enableTools: true })
376
- : undefined;
377
- // L1 runner
378
- this.scheduler.setL1Runner(createL1Runner({
379
- pluginDataDir: this.dataDir,
380
- cfg: this.cfg,
381
- openclawConfig,
382
- vectorStore: this.vectorStore,
383
- embeddingService: this.embeddingService,
384
- logger: this.logger,
385
- getInstanceId: () => this.instanceId,
386
- llmRunner: l1LlmRunner,
387
- }));
388
- // Persister
389
- this.scheduler.setPersister(createPersister(this.dataDir, this.logger));
390
- // L2 runner
391
- this.scheduler.setL2Runner(async (sessionKey, cursor) => {
392
- const l2Runner = createL2Runner({
393
- pluginDataDir: this.dataDir,
394
- cfg: this.cfg,
395
- openclawConfig,
396
- vectorStore: this.vectorStore,
397
- logger: this.logger,
398
- instanceId: this.instanceId,
399
- llmRunner: l2l3LlmRunner,
400
- });
401
- return l2Runner(sessionKey, cursor);
402
- });
403
- // L3 runner
404
- this.scheduler.setL3Runner(async () => {
405
- const l3Runner = createL3Runner({
406
- pluginDataDir: this.dataDir,
407
- cfg: this.cfg,
408
- openclawConfig,
409
- vectorStore: this.vectorStore,
410
- logger: this.logger,
411
- instanceId: this.instanceId,
412
- llmRunner: l2l3LlmRunner,
413
- });
414
- await l3Runner();
415
- });
416
- this.logger.debug?.(`${TAG} Pipeline runners wired`);
417
- }
418
- ensureSchedulerStarted() {
419
- // Fast path: already started (or starting) — every concurrent caller
420
- // awaits the same in-flight promise. The promise is kept around as a
421
- // permanently-resolved sentinel after success so subsequent calls
422
- // collapse into a cheap already-resolved await.
423
- if (this.schedulerStartPromise)
424
- return this.schedulerStartPromise;
425
- if (!this.scheduler)
426
- return Promise.resolve();
427
- // Capture scheduler locally so TypeScript narrows inside the closure
428
- // even after ``this.scheduler`` is re-assigned by handleSessionEnd.
429
- const scheduler = this.scheduler;
430
- this.schedulerStartPromise = (async () => {
431
- try {
432
- const checkpoint = new CheckpointManager(this.dataDir, this.logger);
433
- const cp = await checkpoint.read();
434
- scheduler.start(checkpoint.getAllPipelineStates(cp));
435
- this.logger.debug?.(`${TAG} Scheduler started`);
436
- }
437
- catch (err) {
438
- this.logger.error(`${TAG} Failed to restore checkpoint: ${err instanceof Error ? err.message : String(err)}`);
439
- scheduler.start({});
440
- }
441
- })();
442
- // If the start sequence itself rejects we clear the gate so the next
443
- // caller can retry; on success we keep the resolved promise so it
444
- // short-circuits permanently.
445
- this.schedulerStartPromise.catch(() => {
446
- this.schedulerStartPromise = undefined;
447
- });
448
- return this.schedulerStartPromise;
449
- }
450
- }
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
+ sendSessionHeader: this.cfg.llm.sendSessionHeader,
363
+ sessionHeaderName: this.cfg.llm.sessionHeaderName,
364
+ sessionId: this.cfg.llm.sessionId,
365
+ dataDir: this.dataDir,
366
+ },
367
+ logger: this.logger,
368
+ });
369
+ this.logger.debug?.(`${TAG} Using standalone LLM override: model=${this.cfg.llm.model}, baseUrl=${this.cfg.llm.baseUrl}`);
370
+ }
371
+ const l1LlmRunner = useStandaloneRunner
372
+ ? runnerFactory.createRunner({ enableTools: false })
373
+ : undefined;
374
+ const l2l3LlmRunner = useStandaloneRunner
375
+ ? runnerFactory.createRunner({ enableTools: true })
376
+ : undefined;
377
+ // L1 runner
378
+ this.scheduler.setL1Runner(createL1Runner({
379
+ pluginDataDir: this.dataDir,
380
+ cfg: this.cfg,
381
+ openclawConfig,
382
+ vectorStore: this.vectorStore,
383
+ embeddingService: this.embeddingService,
384
+ logger: this.logger,
385
+ getInstanceId: () => this.instanceId,
386
+ llmRunner: l1LlmRunner,
387
+ }));
388
+ // Persister
389
+ this.scheduler.setPersister(createPersister(this.dataDir, this.logger));
390
+ // L2 runner
391
+ this.scheduler.setL2Runner(async (sessionKey, cursor) => {
392
+ const l2Runner = createL2Runner({
393
+ pluginDataDir: this.dataDir,
394
+ cfg: this.cfg,
395
+ openclawConfig,
396
+ vectorStore: this.vectorStore,
397
+ logger: this.logger,
398
+ instanceId: this.instanceId,
399
+ llmRunner: l2l3LlmRunner,
400
+ });
401
+ return l2Runner(sessionKey, cursor);
402
+ });
403
+ // L3 runner
404
+ this.scheduler.setL3Runner(async () => {
405
+ const l3Runner = createL3Runner({
406
+ pluginDataDir: this.dataDir,
407
+ cfg: this.cfg,
408
+ openclawConfig,
409
+ vectorStore: this.vectorStore,
410
+ logger: this.logger,
411
+ instanceId: this.instanceId,
412
+ llmRunner: l2l3LlmRunner,
413
+ });
414
+ await l3Runner();
415
+ });
416
+ this.logger.debug?.(`${TAG} Pipeline runners wired`);
417
+ }
418
+ ensureSchedulerStarted() {
419
+ // Fast path: already started (or starting) — every concurrent caller
420
+ // awaits the same in-flight promise. The promise is kept around as a
421
+ // permanently-resolved sentinel after success so subsequent calls
422
+ // collapse into a cheap already-resolved await.
423
+ if (this.schedulerStartPromise)
424
+ return this.schedulerStartPromise;
425
+ if (!this.scheduler)
426
+ return Promise.resolve();
427
+ // Capture scheduler locally so TypeScript narrows inside the closure
428
+ // even after ``this.scheduler`` is re-assigned by handleSessionEnd.
429
+ const scheduler = this.scheduler;
430
+ this.schedulerStartPromise = (async () => {
431
+ try {
432
+ const checkpoint = new CheckpointManager(this.dataDir, this.logger);
433
+ const cp = await checkpoint.read();
434
+ scheduler.start(checkpoint.getAllPipelineStates(cp));
435
+ this.logger.debug?.(`${TAG} Scheduler started`);
436
+ }
437
+ catch (err) {
438
+ this.logger.error(`${TAG} Failed to restore checkpoint: ${err instanceof Error ? err.message : String(err)}`);
439
+ scheduler.start({});
440
+ }
441
+ })();
442
+ // If the start sequence itself rejects we clear the gate so the next
443
+ // caller can retry; on success we keep the resolved promise so it
444
+ // short-circuits permanently.
445
+ this.schedulerStartPromise.catch(() => {
446
+ this.schedulerStartPromise = undefined;
447
+ });
448
+ return this.schedulerStartPromise;
449
+ }
450
+ }