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