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.
Files changed (69) hide show
  1. package/LICENSE +21 -21
  2. package/THIRD-PARTY-NOTICES.md +20 -20
  3. package/client.js +8 -4
  4. package/cordis.patch.yml +8 -8
  5. package/index.js +414 -414
  6. package/package.json +1 -1
  7. package/recall-inject.js +86 -86
  8. package/vendor/tdai/adapters/index.js +15 -15
  9. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  10. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  11. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  12. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  13. package/vendor/tdai/adapters/standalone/index.js +5 -5
  14. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -246
  15. package/vendor/tdai/config.js +363 -363
  16. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  17. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  18. package/vendor/tdai/core/hooks/auto-recall.js +653 -653
  19. package/vendor/tdai/core/index.js +8 -8
  20. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  21. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  22. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  23. package/vendor/tdai/core/prompts/l1-dedup.js +136 -136
  24. package/vendor/tdai/core/prompts/l1-extraction.js +132 -132
  25. package/vendor/tdai/core/prompts/persona-generation.js +160 -160
  26. package/vendor/tdai/core/prompts/scene-extraction.js +269 -269
  27. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  28. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  29. package/vendor/tdai/core/record/l1-reader.js +178 -178
  30. package/vendor/tdai/core/record/l1-writer.js +141 -141
  31. package/vendor/tdai/core/report/reporter.js +86 -86
  32. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  33. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  34. package/vendor/tdai/core/scene/scene-format.js +53 -53
  35. package/vendor/tdai/core/scene/scene-index.js +82 -82
  36. package/vendor/tdai/core/scene/scene-navigation.js +60 -60
  37. package/vendor/tdai/core/seed/input.js +404 -404
  38. package/vendor/tdai/core/seed/seed-runtime.js +314 -314
  39. package/vendor/tdai/core/seed/types.js +9 -9
  40. package/vendor/tdai/core/store/bm25-client.js +117 -117
  41. package/vendor/tdai/core/store/bm25-local.js +68 -68
  42. package/vendor/tdai/core/store/embedding.js +632 -632
  43. package/vendor/tdai/core/store/factory.js +95 -95
  44. package/vendor/tdai/core/store/search-utils.js +54 -54
  45. package/vendor/tdai/core/store/sqlite.js +1822 -1822
  46. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  47. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  48. package/vendor/tdai/core/store/types.js +17 -17
  49. package/vendor/tdai/core/tdai-core.js +446 -446
  50. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  51. package/vendor/tdai/core/tools/memory-search.js +207 -207
  52. package/vendor/tdai/core/types.js +12 -12
  53. package/vendor/tdai/utils/backup.js +189 -189
  54. package/vendor/tdai/utils/checkpoint.js +360 -360
  55. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  56. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  57. package/vendor/tdai/utils/env.js +13 -13
  58. package/vendor/tdai/utils/managed-timer.js +127 -127
  59. package/vendor/tdai/utils/manifest.js +91 -91
  60. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  61. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  62. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  63. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  64. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  65. package/vendor/tdai/utils/sanitize.js +360 -360
  66. package/vendor/tdai/utils/serial-queue.js +103 -103
  67. package/vendor/tdai/utils/session-filter.js +88 -88
  68. package/vendor/tdai/utils/text-utils.js +29 -29
  69. 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
+ }