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