dsh-tdai-memory 0.2.9 → 0.2.11

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 +130 -130
  3. package/README.zh.md +72 -72
  4. package/THIRD-PARTY-NOTICES.md +20 -20
  5. package/client.js +349 -311
  6. package/cordis.patch.yml +8 -8
  7. package/index.js +421 -421
  8. package/package.json +8 -5
  9. package/recall-inject.js +86 -86
  10. package/vendor/dsh-settings-expose.js +75 -75
  11. package/vendor/tdai/adapters/index.js +15 -15
  12. package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -83
  13. package/vendor/tdai/adapters/openclaw/index.js +5 -5
  14. package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -69
  15. package/vendor/tdai/adapters/standalone/host-adapter.js +58 -58
  16. package/vendor/tdai/adapters/standalone/index.js +5 -5
  17. package/vendor/tdai/adapters/standalone/llm-runner.js +246 -246
  18. package/vendor/tdai/config.js +363 -363
  19. package/vendor/tdai/core/conversation/l0-recorder.js +420 -420
  20. package/vendor/tdai/core/hooks/auto-capture.js +236 -236
  21. package/vendor/tdai/core/hooks/auto-recall.js +640 -640
  22. package/vendor/tdai/core/index.js +8 -8
  23. package/vendor/tdai/core/persona/persona-generator.js +184 -184
  24. package/vendor/tdai/core/persona/persona-trigger.js +94 -94
  25. package/vendor/tdai/core/profile/profile-sync.js +200 -200
  26. package/vendor/tdai/core/prompts/l1-dedup.js +72 -72
  27. package/vendor/tdai/core/prompts/l1-extraction.js +34 -34
  28. package/vendor/tdai/core/prompts/persona-generation.js +35 -35
  29. package/vendor/tdai/core/prompts/scene-extraction.js +45 -45
  30. package/vendor/tdai/core/record/l1-dedup.js +318 -318
  31. package/vendor/tdai/core/record/l1-extractor.js +379 -379
  32. package/vendor/tdai/core/record/l1-reader.js +178 -178
  33. package/vendor/tdai/core/record/l1-writer.js +141 -141
  34. package/vendor/tdai/core/report/reporter.js +86 -86
  35. package/vendor/tdai/core/scene/filename-normalizer.js +161 -161
  36. package/vendor/tdai/core/scene/scene-extractor.js +410 -410
  37. package/vendor/tdai/core/scene/scene-format.js +53 -53
  38. package/vendor/tdai/core/scene/scene-index.js +82 -82
  39. package/vendor/tdai/core/scene/scene-navigation.js +57 -57
  40. package/vendor/tdai/core/seed/input.js +404 -404
  41. package/vendor/tdai/core/seed/seed-runtime.js +314 -314
  42. package/vendor/tdai/core/seed/types.js +9 -9
  43. package/vendor/tdai/core/store/bm25-client.js +117 -117
  44. package/vendor/tdai/core/store/bm25-local.js +68 -68
  45. package/vendor/tdai/core/store/embedding.js +632 -632
  46. package/vendor/tdai/core/store/factory.js +95 -95
  47. package/vendor/tdai/core/store/search-utils.js +54 -54
  48. package/vendor/tdai/core/store/sqlite.js +1632 -1632
  49. package/vendor/tdai/core/store/tcvdb-client.js +221 -221
  50. package/vendor/tdai/core/store/tcvdb.js +1047 -1047
  51. package/vendor/tdai/core/store/types.js +17 -17
  52. package/vendor/tdai/core/tdai-core.js +446 -446
  53. package/vendor/tdai/core/tools/conversation-search.js +199 -199
  54. package/vendor/tdai/core/tools/memory-search.js +207 -207
  55. package/vendor/tdai/core/types.js +12 -12
  56. package/vendor/tdai/utils/backup.js +189 -189
  57. package/vendor/tdai/utils/checkpoint.js +360 -360
  58. package/vendor/tdai/utils/clean-context-runner.js +472 -472
  59. package/vendor/tdai/utils/ensure-hook-policy.js +213 -213
  60. package/vendor/tdai/utils/env.js +13 -13
  61. package/vendor/tdai/utils/managed-timer.js +127 -127
  62. package/vendor/tdai/utils/manifest.js +91 -91
  63. package/vendor/tdai/utils/memory-cleaner.js +300 -300
  64. package/vendor/tdai/utils/no-think-fetch.js +109 -109
  65. package/vendor/tdai/utils/openclaw-state-dir.js +26 -26
  66. package/vendor/tdai/utils/pipeline-factory.js +517 -517
  67. package/vendor/tdai/utils/pipeline-manager.js +892 -892
  68. package/vendor/tdai/utils/sanitize.js +360 -360
  69. package/vendor/tdai/utils/serial-queue.js +103 -103
  70. package/vendor/tdai/utils/session-filter.js +88 -88
  71. package/vendor/tdai/utils/text-utils.js +29 -29
  72. package/vendor/tdai/utils/time.js +252 -252
package/index.js CHANGED
@@ -1,421 +1,421 @@
1
- /**
2
- * dsh-tdai-memory — TencentDB Agent Memory ported into DeepSeek Harness.
3
- *
4
- * Reuses the host-neutral core of `tdai-memory-openclaw-plugin`
5
- * (TdaiCore: L0 conversation capture -> L1 structured facts -> L2 scenes ->
6
- * L3 persona; vector recall; memory/conversation search) through a thin
7
- * DshHostAdapter. The existing data directory (~/.memory-tencentdb/memory-tdai)
8
- * is reused as-is, so previously accumulated memories stay available.
9
- *
10
- * Wiring:
11
- * - `session/event` (turn/start, turn/end) -> L0 capture + pipeline trigger
12
- * - `system-prompt/assemble` waterfall -> per-turn recall injection
13
- * (relevant L1 memories + L3 persona + L2 scene navigation)
14
- * - tools `tdai_memory_search` / `tdai_conversation_search`
15
- * - LLM calls (L1/L2/L3 extraction) go through the standalone OpenAI-
16
- * compatible runner (config `llm`, falls back to TDAI_LLM_* env vars)
17
- * - embeddings through the configurable OpenAI-compatible endpoint
18
- * (defaults to the local Qwen3-Embedding-0.6B service on :8088)
19
- */
20
- import os from "node:os";
21
- import { join } from "node:path";
22
- import z from "@deepseek-ai/schemastery";
23
- import { defineTool } from "@deepseek-ai/dsh-tools";
24
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
25
- import { TdaiCore } from "./vendor/tdai/core/tdai-core.js";
26
- import { parseConfig } from "./vendor/tdai/config.js";
27
- import { StandaloneHostAdapter } from "./vendor/tdai/adapters/standalone/host-adapter.js";
28
- import { ensureSettingsNamespaceExposed } from "./vendor/dsh-settings-expose.js";
29
-
30
- /** Cordis plugin name. */
31
- const name = "tdai-memory";
32
- /** Services this row injects. */
33
- const inject = ["sessions", "systemPrompt", "tools"];
34
- /** Settings namespace owned by this plugin (Web UI settings section). */
35
- const NS = settingsNamespace("tdai-memory");
36
-
37
- /** Runtime schema for the tdai-memory row. */
38
- const Config = z.object({
39
- /** TDAI data directory; default: ~/.memory-tencentdb/memory-tdai. */
40
- dataDir: z.string().default(""),
41
- /** LLM used for L1/L2/L3 extraction (OpenAI-compatible). Falls back to TDAI_LLM_* env. */
42
- llm: z.object({
43
- baseUrl: z.string().default(""),
44
- apiKey: z.string().default("").role("secret"),
45
- model: z.string().default(""),
46
- maxTokens: z.number().default(4096),
47
- timeoutMs: z.number().default(120000),
48
- }),
49
- /** Embedding endpoint (OpenAI-compatible /v1/embeddings). */
50
- embedding: z.object({
51
- baseUrl: z.string().default("http://127.0.0.1:8088/v1"),
52
- apiKey: z.string().default("").role("secret"),
53
- model: z.string().default("Qwen3-Embedding-0.6B"),
54
- dimensions: z.number().default(1024),
55
- sendDimensions: z.boolean().default(false),
56
- }),
57
- /** Capture conversations into L0. */
58
- captureEnabled: z.boolean().default(true),
59
- /** L1 extraction pipeline (facts from conversations). */
60
- extraction: z.object({
61
- enabled: z.boolean().default(true),
62
- /** Conflict detection before storing; extra LLM call, disable if flaky.
63
- * Off by default: dedup LLM output parsing is flaky (README matches). */
64
- enableDedup: z.boolean().default(false),
65
- }),
66
- /** Recall injection settings. */
67
- recall: z.object({
68
- enabled: z.boolean().default(true),
69
- maxResults: z.number().default(5),
70
- scoreThreshold: z.number().default(0.3),
71
- timeoutMs: z.number().default(3000),
72
- }),
73
- /** Register the two search tools. */
74
- toolsEnabled: z.boolean().default(true),
75
- });
76
-
77
- /**
78
- * Whether a derived message is real human input.
79
- *
80
- * A `"user"` role alone is not enough: DSH injects synthetic user-role
81
- * context (system reminders, runtime context, skill catalogs, goal rounds,
82
- * tool results, …) that must never be treated as user input. Real input is
83
- * distinguished by `message.source.kind === "user"` (see dsh-llm Message
84
- * types). Messages without a `source` (older harnesses) are treated as real
85
- * input to stay compatible.
86
- */
87
- function isRealUserMessage(m) {
88
- return m?.role === "user" && (m.source == null || m.source.kind === "user");
89
- }
90
-
91
- /** Extract plain text from a dsh message content (block array or string). */
92
- function extractText(content) {
93
- if (typeof content === "string") return content.trim();
94
- if (!Array.isArray(content)) return "";
95
- return content
96
- .filter((b) => b && b.type === "text" && typeof b.text === "string")
97
- .map((b) => b.text)
98
- .join("\n")
99
- .trim();
100
- }
101
-
102
- function apply(ctx, config) {
103
- // ── build-once state (rebuildable) ───────────────────────────────────────
104
- // TdaiCore is built from the RESOLVED settings value (composition entry as
105
- // base, `tdai-memory` settings section / settings.yaml as user layer), so
106
- // keys stored in settings.yaml (llm.apiKey etc.) are honored. The settings
107
- // inject callback that drives `installSettingsSection`'s setSource is
108
- // scheduled by cordis as a plugin start — it ALWAYS runs after any 0ms
109
- // timer — so the no-settings fallback below cannot be allowed to win the
110
- // race. Instead of racing, we let the fallback build from the entry config
111
- // first (guaranteeing a working core even without settings), and REBUILD
112
- // from the resolved settings when the settings mount arrives. The fallback
113
- // is a one-shot; the settings build always wins afterwards.
114
- let core = null;
115
- let tdaiConfig = null;
116
- let dataDir = "";
117
- let builtOnce = false;
118
-
119
- /** turn id of the last turn/end per session (capture trigger). */
120
- const lastTurnEnd = new Map();
121
- /** turn ids already captured per session (dedup across flushes). */
122
- const capturedTurns = new Map();
123
- /** turn/start timestamp per session (L0 capture cursor floor). */
124
- const turnStarts = new Map();
125
- /** session keys seen this process (for final L1 flush on teardown). */
126
- const seenSessions = new Set();
127
- /** recall cache per session: same user text within TTL reuses the result. */
128
- const recallCache = new Map();
129
- const RECALL_CACHE_TTL_MS = 30_000;
130
-
131
- // One-shot mode (dsh --profile headless): the process exits right after
132
- // flush, so L1 must finish inside the flush listener. In web mode the
133
- // long-lived process runs the pipeline in the background instead.
134
- const isOneShot = process.argv.some((arg) => arg === "headless");
135
-
136
- // Verbose tdai-internal logging goes to the console only when explicitly
137
- // enabled; routine diagnostics use the dsh logger.
138
- const verbose = !!process.env.DSH_TDAI_DEBUG;
139
- const log = (level, m) => {
140
- if (verbose) console.error(`[tdai-memory] ${m}`);
141
- else if (level === "warn") ctx.logger.warn(`[tdai-memory] ${m}`);
142
- else if (level === "error") ctx.logger.error(`[tdai-memory] ${m}`);
143
- };
144
- const logger = {
145
- debug: (m) => log("debug", m),
146
- info: (m) => log("info", m),
147
- warn: (m) => log("warn", m),
148
- error: (m) => log("error", m),
149
- };
150
-
151
- // ── core construction (rebuildable) ──────────────────────────────────────
152
- async function build(cfg) {
153
- if (cfg?.llm?.apiKey) {
154
- log("debug", `build: using settings config (apiKey=${cfg.llm.apiKey.slice(0, 6)}…)`);
155
- }
156
- // Tear down the previous core, if any (first build has none).
157
- const previous = core;
158
- if (previous) {
159
- await previous.destroy().catch((error) => {
160
- log("warn", `previous core destroy failed: ${String(error)}`);
161
- });
162
- }
163
- dataDir = cfg.dataDir || join(os.homedir(), ".memory-tencentdb", "memory-tdai");
164
- const nextConfig = parseConfig({
165
- timezone: "system",
166
- storeBackend: "sqlite",
167
- capture: { enabled: cfg.captureEnabled },
168
- extraction: { enabled: cfg.extraction.enabled, enableDedup: cfg.extraction.enableDedup },
169
- recall: { ...cfg.recall },
170
- embedding: {
171
- enabled: true,
172
- provider: "openai",
173
- baseUrl: cfg.embedding.baseUrl,
174
- apiKey: cfg.embedding.apiKey,
175
- model: cfg.embedding.model,
176
- dimensions: cfg.embedding.dimensions,
177
- sendDimensions: cfg.embedding.sendDimensions,
178
- timeoutMs: 10000,
179
- },
180
- llm: {
181
- enabled: true,
182
- baseUrl: cfg.llm.baseUrl || process.env.TDAI_LLM_BASE_URL || "",
183
- apiKey: cfg.llm.apiKey || process.env.TDAI_LLM_API_KEY || "",
184
- model: cfg.llm.model || process.env.TDAI_LLM_MODEL || "deepseek-v4-flash",
185
- maxTokens: cfg.llm.maxTokens,
186
- timeoutMs: cfg.llm.timeoutMs,
187
- },
188
- });
189
- const nextHostAdapter = new StandaloneHostAdapter({
190
- dataDir,
191
- llmConfig: {
192
- baseUrl: nextConfig.llm.baseUrl,
193
- apiKey: nextConfig.llm.apiKey,
194
- model: nextConfig.llm.model,
195
- maxTokens: nextConfig.llm.maxTokens,
196
- timeoutMs: nextConfig.llm.timeoutMs,
197
- },
198
- logger,
199
- defaultUserId: "dsh-user",
200
- platform: "dsh",
201
- });
202
- const nextCore = new TdaiCore({ hostAdapter: nextHostAdapter, config: nextConfig, instanceId: "dsh" });
203
- // Publish before initialize so concurrent flush listeners already use the
204
- // new core; initialize() only sets up stores/pipeline.
205
- tdaiConfig = nextConfig;
206
- core = nextCore;
207
- await nextCore.initialize();
208
- const key = nextConfig.llm.apiKey || "";
209
- log("info", `initialized (dataDir=${dataDir}, llm=${nextConfig.llm.model}, apiKey=${key ? `${key.slice(0, 6)}…` : "<empty>"}, embedding=${nextConfig.embedding.model})`);
210
- }
211
-
212
- // ── capture: triggered on session/flush (awaitable; runs before process
213
- // exit in headless, and at every request boundary in web) ─────────────
214
- ctx.on("session/event", (session, event) => {
215
- if (event.type === "turn/start") {
216
- turnStarts.set(session.id, event.time);
217
- } else if (event.type === "turn/end") {
218
- lastTurnEnd.set(session.id, event.data.turn);
219
- }
220
- });
221
-
222
- ctx.on("session/flush", async (session) => {
223
- try {
224
- const turnId = lastTurnEnd.get(session.id);
225
- if (turnId === undefined || capturedTurns.get(session.id) === turnId) return;
226
- capturedTurns.set(session.id, turnId);
227
- seenSessions.add(session.id);
228
- const startedAt = turnStarts.get(session.id) ?? Date.now();
229
- await captureTurn(session, startedAt);
230
- if (isOneShot) {
231
- // Run pending L1 extraction to completion before the process exits.
232
- await core?.handleSessionEnd(session.id).catch((error) => {
233
- ctx.logger.warn(`[tdai-memory] L1 flush failed: ${String(error)}`);
234
- });
235
- }
236
- } catch (error) {
237
- ctx.logger.warn(`[tdai-memory] capture failed: ${String(error)}`);
238
- }
239
- });
240
-
241
- async function captureTurn(session, startedAt) {
242
- if (!tdaiConfig?.capture.enabled) return;
243
- const derived = session.deriveMessages();
244
- // Start from the last REAL user message. DSH injects synthetic user-role
245
- // context (system reminders, runtime context, skill catalogs, goal
246
- // rounds, …) whose `source.kind` is not "user"; those must not pollute
247
- // L0 memory (issue #1).
248
- const lastUserIdx = derived.findLastIndex(isRealUserMessage);
249
- if (lastUserIdx < 0) return;
250
- const messages = derived
251
- .slice(lastUserIdx)
252
- .map((m) => {
253
- // Keep assistant replies and real user input only; drop synthetic
254
- // user-role messages (plugin context, tool results, …) injected
255
- // after the last real user message.
256
- if (m.role !== "assistant" && !isRealUserMessage(m)) return null;
257
- const text = extractText(m.content);
258
- if (!text) return null;
259
- return {
260
- id: m.id ?? `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
261
- role: m.role === "assistant" ? "assistant" : "user",
262
- content: text,
263
- timestamp: Date.now(),
264
- };
265
- })
266
- .filter(Boolean);
267
- if (messages.length === 0) return;
268
- const lastUser = [...messages].reverse().find((m) => m.role === "user");
269
- const result = await core.handleTurnCommitted({
270
- userText: lastUser?.content ?? "",
271
- assistantText: "",
272
- messages,
273
- sessionKey: session.id,
274
- startedAt,
275
- });
276
- ctx.logger.debug(`[tdai-memory] captured turn: l0=${result.l0RecordedCount} notified=${result.schedulerNotified} vectors=${result.l0VectorsWritten}`);
277
- }
278
-
279
- // ── recall injection ────────────────────────────────────────────────────
280
- // The prompt assembly runs inside the agent's own context (scope = agent),
281
- // so the waterfall listener must be registered on `agent.ctx` — root-level
282
- // listeners never see agent-scoped assemblies. Agents appear right after
283
- // their session is announced, so defer one tick and attach there.
284
- ctx.on("session/created", (session) => {
285
- setTimeout(() => {
286
- try {
287
- const agents = ctx.get("agents");
288
- const agent = agents?.get?.(session.id);
289
- if (!agent?.ctx) return;
290
- agent.ctx.on("system-prompt/assemble", async (assembly, context) => {
291
- try {
292
- if (!tdaiConfig?.recall.enabled) return assembly;
293
- const lastUser = [...session.deriveMessages()].reverse().find(isRealUserMessage);
294
- if (!lastUser) return assembly;
295
- const text = extractText(lastUser.content);
296
- if (!text) return assembly;
297
- const now = Date.now();
298
- const cached = recallCache.get(session.id);
299
- if (cached && cached.text === text && now - cached.ts < RECALL_CACHE_TTL_MS) {
300
- return injectRecall(assembly, cached.result);
301
- }
302
- const result = await Promise.race([
303
- core.handleBeforeRecall(text, session.id),
304
- new Promise((resolve) => setTimeout(() => resolve(null), tdaiConfig.recall.timeoutMs + 1000)),
305
- ]).catch(() => null);
306
- recallCache.set(session.id, { text, result: result ?? {}, ts: now });
307
- return injectRecall(assembly, result ?? {});
308
- } catch (error) {
309
- ctx.logger.warn(`[tdai-memory] recall failed: ${String(error)}`);
310
- return assembly;
311
- }
312
- });
313
- } catch (error) {
314
- ctx.logger.warn(`[tdai-memory] recall attach failed: ${String(error)}`);
315
- }
316
- }, 0);
317
- });
318
-
319
- function injectRecall(assembly, result) {
320
- const extras = [];
321
- if (result.prependContext) extras.push({ name: "tdai:recall", order: 1000, text: result.prependContext });
322
- if (result.appendSystemContext) extras.push({ name: "tdai:profile", order: 1001, text: result.appendSystemContext });
323
- if (extras.length === 0) return assembly;
324
- return { ...assembly, contexts: [...assembly.contexts, ...extras] };
325
- }
326
-
327
- // ── tools ────────────────────────────────────────────────────────────────
328
- if (config.toolsEnabled) {
329
- ctx.tools.register(defineTool({
330
- name: "tdai_memory_search",
331
- description:
332
- "Search structured long-term memories (facts, preferences, and experiences extracted from past conversations). " +
333
- "Use this to recall what the user told you or what happened in earlier sessions.",
334
- parameters: {
335
- query: { type: "string", required: true, description: "The memory to search for." },
336
- limit: { type: "integer", description: "Maximum results (default 5)." },
337
- },
338
- output: {
339
- schema: { type: "string" },
340
- render: (_args, value) => [{ type: "text", text: value }],
341
- },
342
- async execute(args) {
343
- const result = await core.searchMemories({ query: args.query, limit: args.limit ?? 5 });
344
- return result.text;
345
- },
346
- }));
347
- ctx.tools.register(defineTool({
348
- name: "tdai_conversation_search",
349
- description:
350
- "Search raw past conversation logs (L0) by keyword. Use this to look up exact quotes or details " +
351
- "that structured memory search does not cover.",
352
- parameters: {
353
- query: { type: "string", required: true, description: "Text to find in past conversations." },
354
- limit: { type: "integer", description: "Maximum results (default 5)." },
355
- },
356
- output: {
357
- schema: { type: "string" },
358
- render: (_args, value) => [{ type: "text", text: value }],
359
- },
360
- async execute(args) {
361
- const result = await core.searchConversations({ query: args.query, limit: args.limit ?? 5 });
362
- return result.text;
363
- },
364
- }));
365
- }
366
-
367
- // ── settings-backed configuration ─────────────────────────────────────────
368
- // The composition entry stays the base layer; a registered `tdai-memory`
369
- // settings section overlays it (settings.yaml). `installSettingsSection`
370
- // hands `setSource` a GETTER (`() => scope.get()`), not the config object —
371
- // we must call it to obtain the resolved settings. The settings mount
372
- // (cordis inject) always arrives after our fallback timer, so the fallback
373
- // builds the core from the entry config first (one-shot) and the settings
374
- // build REBUILDS the core with the resolved values (apiKey included).
375
- installSettingsSection(ctx, NS, Config, config, {
376
- setSource: (getter) => {
377
- builtOnce = true;
378
- void build(getter());
379
- },
380
- onChange: () => {
381
- ctx.logger.warn("[tdai-memory] settings updated; restart to apply (TdaiCore is built at startup)");
382
- },
383
- });
384
-
385
- // dsh-host-apiproxy hard-codes which settings namespaces the Web client may
386
- // see; without this, the settings section answers `settings-not-exposed`
387
- // on any stock install. Patch the allowlist idempotently (self-heals after
388
- // dsh updates overwrite the file).
389
- ensureSettingsNamespaceExposed(ctx, "tdai-memory", ctx.logger);
390
-
391
- // Fallback when no settings service ever mounts: build from the entry
392
- // config. One-shot — if the settings mount arrives later, its build
393
- // supersedes this one. Delayed past the 0ms window so headless/web boots
394
- // are never blocked on this timer.
395
- setTimeout(() => {
396
- if (!builtOnce) {
397
- builtOnce = true;
398
- void build(config);
399
- }
400
- }, 500);
401
-
402
- // ── lifecycle ────────────────────────────────────────────────────────────
403
- ctx.effect(() => {
404
- return async () => {
405
- // Final flush: run pending L1 extraction for every session seen this
406
- // process and wait for it to finish (headless exits right after the
407
- // tree disposes, so this is what makes extraction complete in one-shot
408
- // runs). Unknown keys are tolerated as no-ops.
409
- for (const sessionKey of seenSessions) {
410
- await core?.handleSessionEnd(sessionKey).catch((error) => {
411
- ctx.logger.warn(`[tdai-memory] final L1 flush failed for ${sessionKey}: ${String(error)}`);
412
- });
413
- }
414
- await core?.destroy().catch((error) => {
415
- ctx.logger.warn(`[tdai-memory] destroy failed: ${String(error)}`);
416
- });
417
- };
418
- }, "tdai-memory.cleanup()");
419
- }
420
-
421
- export { Config, apply, inject, name };
1
+ /**
2
+ * dsh-tdai-memory — TencentDB Agent Memory ported into DeepSeek Harness.
3
+ *
4
+ * Reuses the host-neutral core of `tdai-memory-openclaw-plugin`
5
+ * (TdaiCore: L0 conversation capture -> L1 structured facts -> L2 scenes ->
6
+ * L3 persona; vector recall; memory/conversation search) through a thin
7
+ * DshHostAdapter. The existing data directory (~/.memory-tencentdb/memory-tdai)
8
+ * is reused as-is, so previously accumulated memories stay available.
9
+ *
10
+ * Wiring:
11
+ * - `session/event` (turn/start, turn/end) -> L0 capture + pipeline trigger
12
+ * - `system-prompt/assemble` waterfall -> per-turn recall injection
13
+ * (relevant L1 memories + L3 persona + L2 scene navigation)
14
+ * - tools `tdai_memory_search` / `tdai_conversation_search`
15
+ * - LLM calls (L1/L2/L3 extraction) go through the standalone OpenAI-
16
+ * compatible runner (config `llm`, falls back to TDAI_LLM_* env vars)
17
+ * - embeddings through the configurable OpenAI-compatible endpoint
18
+ * (defaults to the local Qwen3-Embedding-0.6B service on :8088)
19
+ */
20
+ import os from "node:os";
21
+ import { join } from "node:path";
22
+ import z from "@deepseek-ai/schemastery";
23
+ import { defineTool } from "@deepseek-ai/dsh-tools";
24
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
25
+ import { TdaiCore } from "./vendor/tdai/core/tdai-core.js";
26
+ import { parseConfig } from "./vendor/tdai/config.js";
27
+ import { StandaloneHostAdapter } from "./vendor/tdai/adapters/standalone/host-adapter.js";
28
+ import { ensureSettingsNamespaceExposed } from "./vendor/dsh-settings-expose.js";
29
+
30
+ /** Cordis plugin name. */
31
+ const name = "tdai-memory";
32
+ /** Services this row injects. */
33
+ const inject = ["sessions", "systemPrompt", "tools"];
34
+ /** Settings namespace owned by this plugin (Web UI settings section). */
35
+ const NS = settingsNamespace("tdai-memory");
36
+
37
+ /** Runtime schema for the tdai-memory row. */
38
+ const Config = z.object({
39
+ /** TDAI data directory; default: ~/.memory-tencentdb/memory-tdai. */
40
+ dataDir: z.string().default(""),
41
+ /** LLM used for L1/L2/L3 extraction (OpenAI-compatible). Falls back to TDAI_LLM_* env. */
42
+ llm: z.object({
43
+ baseUrl: z.string().default(""),
44
+ apiKey: z.string().default("").role("secret"),
45
+ model: z.string().default(""),
46
+ maxTokens: z.number().default(4096),
47
+ timeoutMs: z.number().default(120000),
48
+ }),
49
+ /** Embedding endpoint (OpenAI-compatible /v1/embeddings). */
50
+ embedding: z.object({
51
+ baseUrl: z.string().default("http://127.0.0.1:8088/v1"),
52
+ apiKey: z.string().default("").role("secret"),
53
+ model: z.string().default("Qwen3-Embedding-0.6B"),
54
+ dimensions: z.number().default(1024),
55
+ sendDimensions: z.boolean().default(false),
56
+ }),
57
+ /** Capture conversations into L0. */
58
+ captureEnabled: z.boolean().default(true),
59
+ /** L1 extraction pipeline (facts from conversations). */
60
+ extraction: z.object({
61
+ enabled: z.boolean().default(true),
62
+ /** Conflict detection before storing; extra LLM call, disable if flaky.
63
+ * Off by default: dedup LLM output parsing is flaky (README matches). */
64
+ enableDedup: z.boolean().default(false),
65
+ }),
66
+ /** Recall injection settings. */
67
+ recall: z.object({
68
+ enabled: z.boolean().default(true),
69
+ maxResults: z.number().default(5),
70
+ scoreThreshold: z.number().default(0.3),
71
+ timeoutMs: z.number().default(3000),
72
+ }),
73
+ /** Register the two search tools. */
74
+ toolsEnabled: z.boolean().default(true),
75
+ });
76
+
77
+ /**
78
+ * Whether a derived message is real human input.
79
+ *
80
+ * A `"user"` role alone is not enough: DSH injects synthetic user-role
81
+ * context (system reminders, runtime context, skill catalogs, goal rounds,
82
+ * tool results, …) that must never be treated as user input. Real input is
83
+ * distinguished by `message.source.kind === "user"` (see dsh-llm Message
84
+ * types). Messages without a `source` (older harnesses) are treated as real
85
+ * input to stay compatible.
86
+ */
87
+ function isRealUserMessage(m) {
88
+ return m?.role === "user" && (m.source == null || m.source.kind === "user");
89
+ }
90
+
91
+ /** Extract plain text from a dsh message content (block array or string). */
92
+ function extractText(content) {
93
+ if (typeof content === "string") return content.trim();
94
+ if (!Array.isArray(content)) return "";
95
+ return content
96
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
97
+ .map((b) => b.text)
98
+ .join("\n")
99
+ .trim();
100
+ }
101
+
102
+ function apply(ctx, config) {
103
+ // ── build-once state (rebuildable) ───────────────────────────────────────
104
+ // TdaiCore is built from the RESOLVED settings value (composition entry as
105
+ // base, `tdai-memory` settings section / settings.yaml as user layer), so
106
+ // keys stored in settings.yaml (llm.apiKey etc.) are honored. The settings
107
+ // inject callback that drives `installSettingsSection`'s setSource is
108
+ // scheduled by cordis as a plugin start — it ALWAYS runs after any 0ms
109
+ // timer — so the no-settings fallback below cannot be allowed to win the
110
+ // race. Instead of racing, we let the fallback build from the entry config
111
+ // first (guaranteeing a working core even without settings), and REBUILD
112
+ // from the resolved settings when the settings mount arrives. The fallback
113
+ // is a one-shot; the settings build always wins afterwards.
114
+ let core = null;
115
+ let tdaiConfig = null;
116
+ let dataDir = "";
117
+ let builtOnce = false;
118
+
119
+ /** turn id of the last turn/end per session (capture trigger). */
120
+ const lastTurnEnd = new Map();
121
+ /** turn ids already captured per session (dedup across flushes). */
122
+ const capturedTurns = new Map();
123
+ /** turn/start timestamp per session (L0 capture cursor floor). */
124
+ const turnStarts = new Map();
125
+ /** session keys seen this process (for final L1 flush on teardown). */
126
+ const seenSessions = new Set();
127
+ /** recall cache per session: same user text within TTL reuses the result. */
128
+ const recallCache = new Map();
129
+ const RECALL_CACHE_TTL_MS = 30_000;
130
+
131
+ // One-shot mode (dsh --profile headless): the process exits right after
132
+ // flush, so L1 must finish inside the flush listener. In web mode the
133
+ // long-lived process runs the pipeline in the background instead.
134
+ const isOneShot = process.argv.some((arg) => arg === "headless");
135
+
136
+ // Verbose tdai-internal logging goes to the console only when explicitly
137
+ // enabled; routine diagnostics use the dsh logger.
138
+ const verbose = !!process.env.DSH_TDAI_DEBUG;
139
+ const log = (level, m) => {
140
+ if (verbose) console.error(`[tdai-memory] ${m}`);
141
+ else if (level === "warn") ctx.logger.warn(`[tdai-memory] ${m}`);
142
+ else if (level === "error") ctx.logger.error(`[tdai-memory] ${m}`);
143
+ };
144
+ const logger = {
145
+ debug: (m) => log("debug", m),
146
+ info: (m) => log("info", m),
147
+ warn: (m) => log("warn", m),
148
+ error: (m) => log("error", m),
149
+ };
150
+
151
+ // ── core construction (rebuildable) ──────────────────────────────────────
152
+ async function build(cfg) {
153
+ if (cfg?.llm?.apiKey) {
154
+ log("debug", `build: using settings config (apiKey=${cfg.llm.apiKey.slice(0, 6)}…)`);
155
+ }
156
+ // Tear down the previous core, if any (first build has none).
157
+ const previous = core;
158
+ if (previous) {
159
+ await previous.destroy().catch((error) => {
160
+ log("warn", `previous core destroy failed: ${String(error)}`);
161
+ });
162
+ }
163
+ dataDir = cfg.dataDir || join(os.homedir(), ".memory-tencentdb", "memory-tdai");
164
+ const nextConfig = parseConfig({
165
+ timezone: "system",
166
+ storeBackend: "sqlite",
167
+ capture: { enabled: cfg.captureEnabled },
168
+ extraction: { enabled: cfg.extraction.enabled, enableDedup: cfg.extraction.enableDedup },
169
+ recall: { ...cfg.recall },
170
+ embedding: {
171
+ enabled: true,
172
+ provider: "openai",
173
+ baseUrl: cfg.embedding.baseUrl,
174
+ apiKey: cfg.embedding.apiKey,
175
+ model: cfg.embedding.model,
176
+ dimensions: cfg.embedding.dimensions,
177
+ sendDimensions: cfg.embedding.sendDimensions,
178
+ timeoutMs: 10000,
179
+ },
180
+ llm: {
181
+ enabled: true,
182
+ baseUrl: cfg.llm.baseUrl || process.env.TDAI_LLM_BASE_URL || "",
183
+ apiKey: cfg.llm.apiKey || process.env.TDAI_LLM_API_KEY || "",
184
+ model: cfg.llm.model || process.env.TDAI_LLM_MODEL || "deepseek-v4-flash",
185
+ maxTokens: cfg.llm.maxTokens,
186
+ timeoutMs: cfg.llm.timeoutMs,
187
+ },
188
+ });
189
+ const nextHostAdapter = new StandaloneHostAdapter({
190
+ dataDir,
191
+ llmConfig: {
192
+ baseUrl: nextConfig.llm.baseUrl,
193
+ apiKey: nextConfig.llm.apiKey,
194
+ model: nextConfig.llm.model,
195
+ maxTokens: nextConfig.llm.maxTokens,
196
+ timeoutMs: nextConfig.llm.timeoutMs,
197
+ },
198
+ logger,
199
+ defaultUserId: "dsh-user",
200
+ platform: "dsh",
201
+ });
202
+ const nextCore = new TdaiCore({ hostAdapter: nextHostAdapter, config: nextConfig, instanceId: "dsh" });
203
+ // Publish before initialize so concurrent flush listeners already use the
204
+ // new core; initialize() only sets up stores/pipeline.
205
+ tdaiConfig = nextConfig;
206
+ core = nextCore;
207
+ await nextCore.initialize();
208
+ const key = nextConfig.llm.apiKey || "";
209
+ log("info", `initialized (dataDir=${dataDir}, llm=${nextConfig.llm.model}, apiKey=${key ? `${key.slice(0, 6)}…` : "<empty>"}, embedding=${nextConfig.embedding.model})`);
210
+ }
211
+
212
+ // ── capture: triggered on session/flush (awaitable; runs before process
213
+ // exit in headless, and at every request boundary in web) ─────────────
214
+ ctx.on("session/event", (session, event) => {
215
+ if (event.type === "turn/start") {
216
+ turnStarts.set(session.id, event.time);
217
+ } else if (event.type === "turn/end") {
218
+ lastTurnEnd.set(session.id, event.data.turn);
219
+ }
220
+ });
221
+
222
+ ctx.on("session/flush", async (session) => {
223
+ try {
224
+ const turnId = lastTurnEnd.get(session.id);
225
+ if (turnId === undefined || capturedTurns.get(session.id) === turnId) return;
226
+ capturedTurns.set(session.id, turnId);
227
+ seenSessions.add(session.id);
228
+ const startedAt = turnStarts.get(session.id) ?? Date.now();
229
+ await captureTurn(session, startedAt);
230
+ if (isOneShot) {
231
+ // Run pending L1 extraction to completion before the process exits.
232
+ await core?.handleSessionEnd(session.id).catch((error) => {
233
+ ctx.logger.warn(`[tdai-memory] L1 flush failed: ${String(error)}`);
234
+ });
235
+ }
236
+ } catch (error) {
237
+ ctx.logger.warn(`[tdai-memory] capture failed: ${String(error)}`);
238
+ }
239
+ });
240
+
241
+ async function captureTurn(session, startedAt) {
242
+ if (!tdaiConfig?.capture.enabled) return;
243
+ const derived = session.deriveMessages();
244
+ // Start from the last REAL user message. DSH injects synthetic user-role
245
+ // context (system reminders, runtime context, skill catalogs, goal
246
+ // rounds, …) whose `source.kind` is not "user"; those must not pollute
247
+ // L0 memory (issue #1).
248
+ const lastUserIdx = derived.findLastIndex(isRealUserMessage);
249
+ if (lastUserIdx < 0) return;
250
+ const messages = derived
251
+ .slice(lastUserIdx)
252
+ .map((m) => {
253
+ // Keep assistant replies and real user input only; drop synthetic
254
+ // user-role messages (plugin context, tool results, …) injected
255
+ // after the last real user message.
256
+ if (m.role !== "assistant" && !isRealUserMessage(m)) return null;
257
+ const text = extractText(m.content);
258
+ if (!text) return null;
259
+ return {
260
+ id: m.id ?? `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
261
+ role: m.role === "assistant" ? "assistant" : "user",
262
+ content: text,
263
+ timestamp: Date.now(),
264
+ };
265
+ })
266
+ .filter(Boolean);
267
+ if (messages.length === 0) return;
268
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
269
+ const result = await core.handleTurnCommitted({
270
+ userText: lastUser?.content ?? "",
271
+ assistantText: "",
272
+ messages,
273
+ sessionKey: session.id,
274
+ startedAt,
275
+ });
276
+ ctx.logger.debug(`[tdai-memory] captured turn: l0=${result.l0RecordedCount} notified=${result.schedulerNotified} vectors=${result.l0VectorsWritten}`);
277
+ }
278
+
279
+ // ── recall injection ────────────────────────────────────────────────────
280
+ // The prompt assembly runs inside the agent's own context (scope = agent),
281
+ // so the waterfall listener must be registered on `agent.ctx` — root-level
282
+ // listeners never see agent-scoped assemblies. Agents appear right after
283
+ // their session is announced, so defer one tick and attach there.
284
+ ctx.on("session/created", (session) => {
285
+ setTimeout(() => {
286
+ try {
287
+ const agents = ctx.get("agents");
288
+ const agent = agents?.get?.(session.id);
289
+ if (!agent?.ctx) return;
290
+ agent.ctx.on("system-prompt/assemble", async (assembly, context) => {
291
+ try {
292
+ if (!tdaiConfig?.recall.enabled) return assembly;
293
+ const lastUser = [...session.deriveMessages()].reverse().find(isRealUserMessage);
294
+ if (!lastUser) return assembly;
295
+ const text = extractText(lastUser.content);
296
+ if (!text) return assembly;
297
+ const now = Date.now();
298
+ const cached = recallCache.get(session.id);
299
+ if (cached && cached.text === text && now - cached.ts < RECALL_CACHE_TTL_MS) {
300
+ return injectRecall(assembly, cached.result);
301
+ }
302
+ const result = await Promise.race([
303
+ core.handleBeforeRecall(text, session.id),
304
+ new Promise((resolve) => setTimeout(() => resolve(null), tdaiConfig.recall.timeoutMs + 1000)),
305
+ ]).catch(() => null);
306
+ recallCache.set(session.id, { text, result: result ?? {}, ts: now });
307
+ return injectRecall(assembly, result ?? {});
308
+ } catch (error) {
309
+ ctx.logger.warn(`[tdai-memory] recall failed: ${String(error)}`);
310
+ return assembly;
311
+ }
312
+ });
313
+ } catch (error) {
314
+ ctx.logger.warn(`[tdai-memory] recall attach failed: ${String(error)}`);
315
+ }
316
+ }, 0);
317
+ });
318
+
319
+ function injectRecall(assembly, result) {
320
+ const extras = [];
321
+ if (result.prependContext) extras.push({ name: "tdai:recall", order: 1000, text: result.prependContext });
322
+ if (result.appendSystemContext) extras.push({ name: "tdai:profile", order: 1001, text: result.appendSystemContext });
323
+ if (extras.length === 0) return assembly;
324
+ return { ...assembly, contexts: [...assembly.contexts, ...extras] };
325
+ }
326
+
327
+ // ── tools ────────────────────────────────────────────────────────────────
328
+ if (config.toolsEnabled) {
329
+ ctx.tools.register(defineTool({
330
+ name: "tdai_memory_search",
331
+ description:
332
+ "Search structured long-term memories (facts, preferences, and experiences extracted from past conversations). " +
333
+ "Use this to recall what the user told you or what happened in earlier sessions.",
334
+ parameters: {
335
+ query: { type: "string", required: true, description: "The memory to search for." },
336
+ limit: { type: "integer", description: "Maximum results (default 5)." },
337
+ },
338
+ output: {
339
+ schema: { type: "string" },
340
+ render: (_args, value) => [{ type: "text", text: value }],
341
+ },
342
+ async execute(args) {
343
+ const result = await core.searchMemories({ query: args.query, limit: args.limit ?? 5 });
344
+ return result.text;
345
+ },
346
+ }));
347
+ ctx.tools.register(defineTool({
348
+ name: "tdai_conversation_search",
349
+ description:
350
+ "Search raw past conversation logs (L0) by keyword. Use this to look up exact quotes or details " +
351
+ "that structured memory search does not cover.",
352
+ parameters: {
353
+ query: { type: "string", required: true, description: "Text to find in past conversations." },
354
+ limit: { type: "integer", description: "Maximum results (default 5)." },
355
+ },
356
+ output: {
357
+ schema: { type: "string" },
358
+ render: (_args, value) => [{ type: "text", text: value }],
359
+ },
360
+ async execute(args) {
361
+ const result = await core.searchConversations({ query: args.query, limit: args.limit ?? 5 });
362
+ return result.text;
363
+ },
364
+ }));
365
+ }
366
+
367
+ // ── settings-backed configuration ─────────────────────────────────────────
368
+ // The composition entry stays the base layer; a registered `tdai-memory`
369
+ // settings section overlays it (settings.yaml). `installSettingsSection`
370
+ // hands `setSource` a GETTER (`() => scope.get()`), not the config object —
371
+ // we must call it to obtain the resolved settings. The settings mount
372
+ // (cordis inject) always arrives after our fallback timer, so the fallback
373
+ // builds the core from the entry config first (one-shot) and the settings
374
+ // build REBUILDS the core with the resolved values (apiKey included).
375
+ installSettingsSection(ctx, NS, Config, config, {
376
+ setSource: (getter) => {
377
+ builtOnce = true;
378
+ void build(getter());
379
+ },
380
+ onChange: () => {
381
+ ctx.logger.warn("[tdai-memory] settings updated; restart to apply (TdaiCore is built at startup)");
382
+ },
383
+ });
384
+
385
+ // dsh-host-apiproxy hard-codes which settings namespaces the Web client may
386
+ // see; without this, the settings section answers `settings-not-exposed`
387
+ // on any stock install. Patch the allowlist idempotently (self-heals after
388
+ // dsh updates overwrite the file).
389
+ ensureSettingsNamespaceExposed(ctx, "tdai-memory", ctx.logger);
390
+
391
+ // Fallback when no settings service ever mounts: build from the entry
392
+ // config. One-shot — if the settings mount arrives later, its build
393
+ // supersedes this one. Delayed past the 0ms window so headless/web boots
394
+ // are never blocked on this timer.
395
+ setTimeout(() => {
396
+ if (!builtOnce) {
397
+ builtOnce = true;
398
+ void build(config);
399
+ }
400
+ }, 500);
401
+
402
+ // ── lifecycle ────────────────────────────────────────────────────────────
403
+ ctx.effect(() => {
404
+ return async () => {
405
+ // Final flush: run pending L1 extraction for every session seen this
406
+ // process and wait for it to finish (headless exits right after the
407
+ // tree disposes, so this is what makes extraction complete in one-shot
408
+ // runs). Unknown keys are tolerated as no-ops.
409
+ for (const sessionKey of seenSessions) {
410
+ await core?.handleSessionEnd(sessionKey).catch((error) => {
411
+ ctx.logger.warn(`[tdai-memory] final L1 flush failed for ${sessionKey}: ${String(error)}`);
412
+ });
413
+ }
414
+ await core?.destroy().catch((error) => {
415
+ ctx.logger.warn(`[tdai-memory] destroy failed: ${String(error)}`);
416
+ });
417
+ };
418
+ }, "tdai-memory.cleanup()");
419
+ }
420
+
421
+ export { Config, apply, inject, name };