dsh-tdai-memory 0.1.0
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 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scorp1o117
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# dsh-tdai-memory
|
|
2
|
+
|
|
3
|
+
把 **TencentDB Agent Memory**(腾讯云开源的四层记忆系统,原为 OpenClaw 插件)移植进 DeepSeek Harness。
|
|
4
|
+
|
|
5
|
+
## 能力
|
|
6
|
+
|
|
7
|
+
- **L0 对话捕获**:每轮对话(turn 结束、请求边界)自动写入原始对话(JSONL + SQLite + FTS + 向量)
|
|
8
|
+
- **L1 结构化记忆**:后台管线用 LLM 从对话中提取事实/偏好/事件(persona / episodic / instruction),写入 `records/` + SQLite + FTS + 向量
|
|
9
|
+
- **L2 场景 / L3 画像**:场景块与用户画像生成(管线自动调度)
|
|
10
|
+
- **自动召回注入**:每次组装提示词时,按当前用户消息检索相关记忆 + 画像,作为动态上下文注入(模型"凭空想起")
|
|
11
|
+
- **工具**:`tdai_memory_search`(L1 结构化检索)、`tdai_conversation_search`(L0 原文检索)
|
|
12
|
+
|
|
13
|
+
数据目录复用现有 `~/.memory-tencentdb/memory-tdai`,**旧记忆无缝继承**。
|
|
14
|
+
|
|
15
|
+
## 架构(移植方式)
|
|
16
|
+
|
|
17
|
+
| 层 | 内容 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| 核心 | `tdai-memory-openclaw-plugin` 的 host-neutral 核心(`src/core`、`src/utils`),tsc 编译为 ESM(`dist-dsh/`),零改动 |
|
|
20
|
+
| 宿主适配 | `StandaloneHostAdapter`(官方 standalone 模式,OpenAI 兼容直调) |
|
|
21
|
+
| dsh 壳 | `index.js`:配置映射、`session/event`+`session/flush` 捕获、`agent.ctx` 上的 `system-prompt/assemble` waterfall 召回注入、工具注册、生命周期 |
|
|
22
|
+
| 备用 | `recall-inject.js`:preset 行形态的召回注入(挂在 agent preset 内时用) |
|
|
23
|
+
|
|
24
|
+
关键接线(都是踩坑换来的):
|
|
25
|
+
|
|
26
|
+
- **捕获**:`session/flush` 监听器(await 语义,headless 退出前必完成);`turn/start` 时间戳做 L0 cursor 下限;turn id 去重
|
|
27
|
+
- **headless 一次性任务**:flush 内再等 `core.handleSessionEnd()`(L1 提取跑完才退出;否则 5s 关闭超时会杀掉提取)
|
|
28
|
+
- **召回注入**:必须注册在 **`agent.ctx`** 上(组装在 agent 作用域进行,root 监听器收不到);`session/created` 后延迟一 tick 从 `agents` 服务拿 agent
|
|
29
|
+
|
|
30
|
+
## 配置(profile patch)
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
- id: tdai-memory
|
|
34
|
+
name: './plugins/dsh-tdai-memory/index.js'
|
|
35
|
+
config:
|
|
36
|
+
extraction:
|
|
37
|
+
enabled: true
|
|
38
|
+
enableDedup: false # dedup 的 LLM 输出解析不稳,先关
|
|
39
|
+
llm: # L1/L2/L3 提取模型(OpenAI 兼容)
|
|
40
|
+
baseUrl: 'https://opencode.ai/zen/go/v1'
|
|
41
|
+
apiKey: 'sk-...'
|
|
42
|
+
model: 'mimo-v2.5' # deepseek-v4-flash 对提取 JSON 输出不合格
|
|
43
|
+
embedding: # 向量(OpenAI 兼容 /v1/embeddings)
|
|
44
|
+
baseUrl: 'http://127.0.0.1:8088/v1'
|
|
45
|
+
apiKey: 'local-no-key' # parseConfig 强制 apiKey 非空,本地端点可占位
|
|
46
|
+
model: 'Qwen3-Embedding-0.6B'
|
|
47
|
+
dimensions: 1024
|
|
48
|
+
sendDimensions: false
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 已知取舍
|
|
52
|
+
|
|
53
|
+
- **提取模型**:`mimo-v2.5` 提取正确但单次 20-30s(后台执行,不阻塞对话);`deepseek-v4-flash` 快但 JSON 输出不合规(提取 0 条)
|
|
54
|
+
- **dedup**:LLM 冲突检测输出解析不稳(曾导致 stored=0),默认关闭;开启需换更稳的模型
|
|
55
|
+
- **L1 记忆向量**:随存储写入(8088 embedding 快);L0 向量走后台任务,headless 退出时由 `destroy()` drain
|
|
56
|
+
- **升级**:上游拉新代码后,在 tdai 项目目录重跑 `npx tsc -p dsh-tsconfig.json`(产物在 `dist-dsh/`)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## TencentDB Agent Memory core (vendored in `vendor/tdai`)
|
|
4
|
+
|
|
5
|
+
`vendor/tdai` is a TypeScript-compiled copy of the host-neutral core of
|
|
6
|
+
[TencentCloud/TencentDB-Agent-Memory](https://github.com/TencentCloud/TencentDB-Agent-Memory)
|
|
7
|
+
(`tdai-memory-openclaw-plugin`, npm: `@tencentdb-agent-memory/memory-tencentdb`),
|
|
8
|
+
compiled with `tsc -p dsh-tsconfig.json` (see the upstream repo for source).
|
|
9
|
+
Its runtime dependencies are declared in this package's `dependencies`.
|
|
10
|
+
|
|
11
|
+
MIT License — Copyright (c) 2026 TencentCloud
|
|
12
|
+
|
|
13
|
+
## Runtime dependencies
|
|
14
|
+
|
|
15
|
+
- `ai` / `@ai-sdk/openai` — Vercel AI SDK (Apache-2.0)
|
|
16
|
+
- `@node-rs/jieba` — Rust jieba bindings (MIT)
|
|
17
|
+
- `sqlite-vec` — vector search extension for SQLite (Apache-2.0 / MIT)
|
|
18
|
+
- `json5` — JSON5 parser (MIT)
|
|
19
|
+
- `undici` — HTTP client (MIT)
|
|
20
|
+
- `@tencentdb-agent-memory/tcvdb-text` — TencentDB text utilities (MIT)
|
package/index.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
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
|
+
|
|
33
|
+
/** Runtime schema for the tdai-memory row. */
|
|
34
|
+
const Config = z.object({
|
|
35
|
+
/** TDAI data directory; default: ~/.memory-tencentdb/memory-tdai. */
|
|
36
|
+
dataDir: z.string().default(""),
|
|
37
|
+
/** LLM used for L1/L2/L3 extraction (OpenAI-compatible). Falls back to TDAI_LLM_* env. */
|
|
38
|
+
llm: z.object({
|
|
39
|
+
baseUrl: z.string().default(""),
|
|
40
|
+
apiKey: z.string().default(""),
|
|
41
|
+
model: z.string().default(""),
|
|
42
|
+
maxTokens: z.number().default(4096),
|
|
43
|
+
timeoutMs: z.number().default(120000),
|
|
44
|
+
}),
|
|
45
|
+
/** Embedding endpoint (OpenAI-compatible /v1/embeddings). */
|
|
46
|
+
embedding: z.object({
|
|
47
|
+
baseUrl: z.string().default("http://127.0.0.1:8088/v1"),
|
|
48
|
+
apiKey: z.string().default(""),
|
|
49
|
+
model: z.string().default("Qwen3-Embedding-0.6B"),
|
|
50
|
+
dimensions: z.number().default(1024),
|
|
51
|
+
sendDimensions: z.boolean().default(false),
|
|
52
|
+
}),
|
|
53
|
+
/** Capture conversations into L0. */
|
|
54
|
+
captureEnabled: z.boolean().default(true),
|
|
55
|
+
/** L1 extraction pipeline (facts from conversations). */
|
|
56
|
+
extraction: z.object({
|
|
57
|
+
enabled: z.boolean().default(true),
|
|
58
|
+
/** Conflict detection before storing; extra LLM call, disable if flaky. */
|
|
59
|
+
enableDedup: z.boolean().default(true),
|
|
60
|
+
}),
|
|
61
|
+
/** Recall injection settings. */
|
|
62
|
+
recall: z.object({
|
|
63
|
+
enabled: z.boolean().default(true),
|
|
64
|
+
maxResults: z.number().default(5),
|
|
65
|
+
scoreThreshold: z.number().default(0.3),
|
|
66
|
+
timeoutMs: z.number().default(3000),
|
|
67
|
+
}),
|
|
68
|
+
/** Register the two search tools. */
|
|
69
|
+
toolsEnabled: z.boolean().default(true),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Extract plain text from a dsh message content (block array or string). */
|
|
73
|
+
function extractText(content) {
|
|
74
|
+
if (typeof content === "string") return content.trim();
|
|
75
|
+
if (!Array.isArray(content)) return "";
|
|
76
|
+
return content
|
|
77
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
78
|
+
.map((b) => b.text)
|
|
79
|
+
.join("\n")
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function apply(ctx, config) {
|
|
84
|
+
const dataDir = config.dataDir || join(os.homedir(), ".memory-tencentdb", "memory-tdai");
|
|
85
|
+
|
|
86
|
+
const tdaiConfig = parseConfig({
|
|
87
|
+
timezone: "system",
|
|
88
|
+
storeBackend: "sqlite",
|
|
89
|
+
capture: { enabled: config.captureEnabled },
|
|
90
|
+
extraction: { enabled: config.extraction.enabled, enableDedup: config.extraction.enableDedup },
|
|
91
|
+
recall: { ...config.recall },
|
|
92
|
+
embedding: {
|
|
93
|
+
enabled: true,
|
|
94
|
+
provider: "openai",
|
|
95
|
+
baseUrl: config.embedding.baseUrl,
|
|
96
|
+
apiKey: config.embedding.apiKey,
|
|
97
|
+
model: config.embedding.model,
|
|
98
|
+
dimensions: config.embedding.dimensions,
|
|
99
|
+
sendDimensions: config.embedding.sendDimensions,
|
|
100
|
+
timeoutMs: 10000,
|
|
101
|
+
},
|
|
102
|
+
llm: {
|
|
103
|
+
enabled: true,
|
|
104
|
+
baseUrl: config.llm.baseUrl || process.env.TDAI_LLM_BASE_URL || "",
|
|
105
|
+
apiKey: config.llm.apiKey || process.env.TDAI_LLM_API_KEY || "",
|
|
106
|
+
model: config.llm.model || process.env.TDAI_LLM_MODEL || "deepseek-v4-flash",
|
|
107
|
+
maxTokens: config.llm.maxTokens,
|
|
108
|
+
timeoutMs: config.llm.timeoutMs,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Verbose tdai-internal logging goes to the console only when explicitly
|
|
113
|
+
// enabled; routine diagnostics use the dsh logger.
|
|
114
|
+
const verbose = !!process.env.DSH_TDAI_DEBUG;
|
|
115
|
+
const log = (level, m) => {
|
|
116
|
+
if (verbose) console.error(`[tdai-memory] ${m}`);
|
|
117
|
+
else if (level === "warn") ctx.logger.warn(`[tdai-memory] ${m}`);
|
|
118
|
+
else if (level === "error") ctx.logger.error(`[tdai-memory] ${m}`);
|
|
119
|
+
};
|
|
120
|
+
const logger = {
|
|
121
|
+
debug: (m) => log("debug", m),
|
|
122
|
+
info: (m) => log("info", m),
|
|
123
|
+
warn: (m) => log("warn", m),
|
|
124
|
+
error: (m) => log("error", m),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const hostAdapter = new StandaloneHostAdapter({
|
|
128
|
+
dataDir,
|
|
129
|
+
llmConfig: {
|
|
130
|
+
baseUrl: tdaiConfig.llm.baseUrl,
|
|
131
|
+
apiKey: tdaiConfig.llm.apiKey,
|
|
132
|
+
model: tdaiConfig.llm.model,
|
|
133
|
+
maxTokens: tdaiConfig.llm.maxTokens,
|
|
134
|
+
timeoutMs: tdaiConfig.llm.timeoutMs,
|
|
135
|
+
},
|
|
136
|
+
logger,
|
|
137
|
+
defaultUserId: "dsh-user",
|
|
138
|
+
platform: "dsh",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const core = new TdaiCore({ hostAdapter, config: tdaiConfig, instanceId: "dsh" });
|
|
142
|
+
|
|
143
|
+
/** turn id of the last turn/end per session (capture trigger). */
|
|
144
|
+
const lastTurnEnd = new Map();
|
|
145
|
+
/** turn ids already captured per session (dedup across flushes). */
|
|
146
|
+
const capturedTurns = new Map();
|
|
147
|
+
/** turn/start timestamp per session (L0 capture cursor floor). */
|
|
148
|
+
const turnStarts = new Map();
|
|
149
|
+
/** session keys seen this process (for final L1 flush on teardown). */
|
|
150
|
+
const seenSessions = new Set();
|
|
151
|
+
/** recall cache per session: same user text within TTL reuses the result. */
|
|
152
|
+
const recallCache = new Map();
|
|
153
|
+
const RECALL_CACHE_TTL_MS = 30_000;
|
|
154
|
+
|
|
155
|
+
// One-shot mode (dsh --profile headless): the process exits right after
|
|
156
|
+
// flush, so L1 must finish inside the flush listener. In web mode the
|
|
157
|
+
// long-lived process runs the pipeline in the background instead.
|
|
158
|
+
const isOneShot = process.argv.some((arg) => arg === "headless");
|
|
159
|
+
|
|
160
|
+
// ── capture: triggered on session/flush (awaitable; runs before process
|
|
161
|
+
// exit in headless, and at every request boundary in web) ─────────────
|
|
162
|
+
ctx.on("session/event", (session, event) => {
|
|
163
|
+
if (event.type === "turn/start") {
|
|
164
|
+
turnStarts.set(session.id, event.time);
|
|
165
|
+
} else if (event.type === "turn/end") {
|
|
166
|
+
lastTurnEnd.set(session.id, event.data.turn);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
ctx.on("session/flush", async (session) => {
|
|
171
|
+
try {
|
|
172
|
+
const turnId = lastTurnEnd.get(session.id);
|
|
173
|
+
if (turnId === undefined || capturedTurns.get(session.id) === turnId) return;
|
|
174
|
+
capturedTurns.set(session.id, turnId);
|
|
175
|
+
seenSessions.add(session.id);
|
|
176
|
+
const startedAt = turnStarts.get(session.id) ?? Date.now();
|
|
177
|
+
await captureTurn(session, startedAt);
|
|
178
|
+
if (isOneShot) {
|
|
179
|
+
// Run pending L1 extraction to completion before the process exits.
|
|
180
|
+
await core.handleSessionEnd(session.id).catch((error) => {
|
|
181
|
+
ctx.logger.warn(`[tdai-memory] L1 flush failed: ${String(error)}`);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ctx.logger.warn(`[tdai-memory] capture failed: ${String(error)}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
async function captureTurn(session, startedAt) {
|
|
190
|
+
if (!tdaiConfig.capture.enabled) return;
|
|
191
|
+
const derived = session.deriveMessages();
|
|
192
|
+
const lastUserIdx = derived.map((m) => m.role).lastIndexOf("user");
|
|
193
|
+
if (lastUserIdx < 0) return;
|
|
194
|
+
const messages = derived
|
|
195
|
+
.slice(lastUserIdx)
|
|
196
|
+
.map((m) => {
|
|
197
|
+
const text = extractText(m.content);
|
|
198
|
+
if (!text) return null;
|
|
199
|
+
return {
|
|
200
|
+
id: m.id ?? `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
201
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
202
|
+
content: text,
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
};
|
|
205
|
+
})
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
if (messages.length === 0) return;
|
|
208
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
209
|
+
const result = await core.handleTurnCommitted({
|
|
210
|
+
userText: lastUser?.content ?? "",
|
|
211
|
+
assistantText: "",
|
|
212
|
+
messages,
|
|
213
|
+
sessionKey: session.id,
|
|
214
|
+
startedAt,
|
|
215
|
+
});
|
|
216
|
+
ctx.logger.debug(`[tdai-memory] captured turn: l0=${result.l0RecordedCount} notified=${result.schedulerNotified} vectors=${result.l0VectorsWritten}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── recall injection ────────────────────────────────────────────────────
|
|
220
|
+
// The prompt assembly runs inside the agent's own context (scope = agent),
|
|
221
|
+
// so the waterfall listener must be registered on `agent.ctx` — root-level
|
|
222
|
+
// listeners never see agent-scoped assemblies. Agents appear right after
|
|
223
|
+
// their session is announced, so defer one tick and attach there.
|
|
224
|
+
ctx.on("session/created", (session) => {
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
try {
|
|
227
|
+
const agents = ctx.get("agents");
|
|
228
|
+
const agent = agents?.get?.(session.id);
|
|
229
|
+
if (!agent?.ctx) return;
|
|
230
|
+
agent.ctx.on("system-prompt/assemble", async (assembly, context) => {
|
|
231
|
+
try {
|
|
232
|
+
if (!tdaiConfig.recall.enabled) return assembly;
|
|
233
|
+
const lastUser = [...session.deriveMessages()].reverse().find((m) => m.role === "user");
|
|
234
|
+
if (!lastUser) return assembly;
|
|
235
|
+
const text = extractText(lastUser.content);
|
|
236
|
+
if (!text) return assembly;
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const cached = recallCache.get(session.id);
|
|
239
|
+
if (cached && cached.text === text && now - cached.ts < RECALL_CACHE_TTL_MS) {
|
|
240
|
+
return injectRecall(assembly, cached.result);
|
|
241
|
+
}
|
|
242
|
+
const result = await Promise.race([
|
|
243
|
+
core.handleBeforeRecall(text, session.id),
|
|
244
|
+
new Promise((resolve) => setTimeout(() => resolve(null), tdaiConfig.recall.timeoutMs + 1000)),
|
|
245
|
+
]).catch(() => null);
|
|
246
|
+
recallCache.set(session.id, { text, result: result ?? {}, ts: now });
|
|
247
|
+
return injectRecall(assembly, result ?? {});
|
|
248
|
+
} catch (error) {
|
|
249
|
+
ctx.logger.warn(`[tdai-memory] recall failed: ${String(error)}`);
|
|
250
|
+
return assembly;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
ctx.logger.warn(`[tdai-memory] recall attach failed: ${String(error)}`);
|
|
255
|
+
}
|
|
256
|
+
}, 0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
function injectRecall(assembly, result) {
|
|
260
|
+
const extras = [];
|
|
261
|
+
if (result.prependContext) extras.push({ name: "tdai:recall", order: 1000, text: result.prependContext });
|
|
262
|
+
if (result.appendSystemContext) extras.push({ name: "tdai:profile", order: 1001, text: result.appendSystemContext });
|
|
263
|
+
if (extras.length === 0) return assembly;
|
|
264
|
+
return { ...assembly, contexts: [...assembly.contexts, ...extras] };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── tools ────────────────────────────────────────────────────────────────
|
|
268
|
+
if (config.toolsEnabled) {
|
|
269
|
+
ctx.tools.register(defineTool({
|
|
270
|
+
name: "tdai_memory_search",
|
|
271
|
+
description:
|
|
272
|
+
"Search structured long-term memories (facts, preferences, and experiences extracted from past conversations). " +
|
|
273
|
+
"Use this to recall what the user told you or what happened in earlier sessions.",
|
|
274
|
+
parameters: {
|
|
275
|
+
query: { type: "string", required: true, description: "The memory to search for." },
|
|
276
|
+
limit: { type: "integer", description: "Maximum results (default 5)." },
|
|
277
|
+
},
|
|
278
|
+
output: {
|
|
279
|
+
schema: { type: "string" },
|
|
280
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
281
|
+
},
|
|
282
|
+
async execute(args) {
|
|
283
|
+
const result = await core.searchMemories({ query: args.query, limit: args.limit ?? 5 });
|
|
284
|
+
return result.text;
|
|
285
|
+
},
|
|
286
|
+
}));
|
|
287
|
+
ctx.tools.register(defineTool({
|
|
288
|
+
name: "tdai_conversation_search",
|
|
289
|
+
description:
|
|
290
|
+
"Search raw past conversation logs (L0) by keyword. Use this to look up exact quotes or details " +
|
|
291
|
+
"that structured memory search does not cover.",
|
|
292
|
+
parameters: {
|
|
293
|
+
query: { type: "string", required: true, description: "Text to find in past conversations." },
|
|
294
|
+
limit: { type: "integer", description: "Maximum results (default 5)." },
|
|
295
|
+
},
|
|
296
|
+
output: {
|
|
297
|
+
schema: { type: "string" },
|
|
298
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
299
|
+
},
|
|
300
|
+
async execute(args) {
|
|
301
|
+
const result = await core.searchConversations({ query: args.query, limit: args.limit ?? 5 });
|
|
302
|
+
return result.text;
|
|
303
|
+
},
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── lifecycle ────────────────────────────────────────────────────────────
|
|
308
|
+
ctx.effect(async () => {
|
|
309
|
+
await core.initialize();
|
|
310
|
+
ctx.logger.info(`[tdai-memory] initialized (dataDir=${dataDir}, llm=${tdaiConfig.llm.model}, embedding=${tdaiConfig.embedding.model})`);
|
|
311
|
+
return async () => {
|
|
312
|
+
// Final flush: run pending L1 extraction for every session seen this
|
|
313
|
+
// process and wait for it to finish (headless exits right after the
|
|
314
|
+
// tree disposes, so this is what makes extraction complete in one-shot
|
|
315
|
+
// runs). Unknown keys are tolerated as no-ops.
|
|
316
|
+
for (const sessionKey of seenSessions) {
|
|
317
|
+
await core.handleSessionEnd(sessionKey).catch((error) => {
|
|
318
|
+
ctx.logger.warn(`[tdai-memory] final L1 flush failed for ${sessionKey}: ${String(error)}`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
await core.destroy().catch((error) => {
|
|
322
|
+
ctx.logger.warn(`[tdai-memory] destroy failed: ${String(error)}`);
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
}, "tdai-memory.initialize()");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export { Config, apply, inject, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-tdai-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TencentDB Agent Memory for DeepSeek Harness: L0 conversation capture, L1 structured memory extraction, L2 scenes / L3 persona, automatic recall injection, and memory search tools. Uses the existing ~/.memory-tencentdb/memory-tdai data directory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"recall-inject.js",
|
|
13
|
+
"vendor",
|
|
14
|
+
"README.md",
|
|
15
|
+
"README.zh.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"THIRD-PARTY-NOTICES.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"memory",
|
|
24
|
+
"tencentdb",
|
|
25
|
+
"agent-memory",
|
|
26
|
+
"rag",
|
|
27
|
+
"vector-search",
|
|
28
|
+
"persona"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.18"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Scorp1o117/dsh-tdai-memory.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/Scorp1o117/dsh-tdai-memory/issues"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@ai-sdk/openai": "^3.0.53",
|
|
43
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
45
|
+
"@node-rs/jieba": "^2.0.1",
|
|
46
|
+
"@tencentdb-agent-memory/tcvdb-text": "^0.1.1",
|
|
47
|
+
"ai": "^6.0.164",
|
|
48
|
+
"json5": "^2.2.3",
|
|
49
|
+
"sqlite-vec": "0.1.7-alpha.2",
|
|
50
|
+
"undici": "^8.1.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
package/recall-inject.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tdai-recall-inject — agent-plane recall injection row for dsh-tdai-memory.
|
|
3
|
+
*
|
|
4
|
+
* Mounted INSIDE an agent preset (agent.cordis.yml), this row listens on the
|
|
5
|
+
* `system-prompt/assemble` waterfall in the preset's scope, so it runs for
|
|
6
|
+
* every assembly of every agent joined to that preset (agent -> preset scope
|
|
7
|
+
* chain). It asks the `tdaiMemory` service (provided by the root-level
|
|
8
|
+
* dsh-tdai-memory plugin) for relevant long-term memories and appends them
|
|
9
|
+
* as dynamic context sections (user-role snapshots):
|
|
10
|
+
*
|
|
11
|
+
* - tdai:recall — relevant L1 memories for the current user message
|
|
12
|
+
* - tdai:profile — stable persona / scene navigation context
|
|
13
|
+
*
|
|
14
|
+
* The recall call is bounded by `recall.timeoutMs`; failures degrade to no
|
|
15
|
+
* injection and never break the assembly.
|
|
16
|
+
*/
|
|
17
|
+
import z from "@deepseek-ai/schemastery";
|
|
18
|
+
|
|
19
|
+
/** Cordis plugin name. */
|
|
20
|
+
const name = "tdai-recall-inject";
|
|
21
|
+
/** Services consumed: the prompt registry and the memory core service. */
|
|
22
|
+
const inject = ["systemPrompt", "tdaiMemory"];
|
|
23
|
+
|
|
24
|
+
/** Runtime schema for the recall-inject row. */
|
|
25
|
+
const Config = z.object({
|
|
26
|
+
/** Skip recall when the user message is shorter than this. */
|
|
27
|
+
minUserTextChars: z.number().default(1),
|
|
28
|
+
/** Per-session cache TTL for identical user text (ms). */
|
|
29
|
+
cacheTtlMs: z.number().default(30_000),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function apply(ctx, config) {
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
|
|
35
|
+
ctx.on("system-prompt/assemble", async (assembly, context) => {
|
|
36
|
+
try {
|
|
37
|
+
const agent = context?.agent;
|
|
38
|
+
if (!agent) return assembly;
|
|
39
|
+
const session = agent.session;
|
|
40
|
+
if (!session) return assembly;
|
|
41
|
+
const lastUser = [...session.deriveMessages()].reverse().find((m) => m.role === "user");
|
|
42
|
+
if (!lastUser) return assembly;
|
|
43
|
+
const text = extractText(lastUser.content);
|
|
44
|
+
if (!text || text.length < config.minUserTextChars) return assembly;
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const cached = cache.get(session.id);
|
|
48
|
+
if (cached && cached.text === text && now - cached.ts < config.cacheTtlMs) {
|
|
49
|
+
return injectRecall(assembly, cached.result);
|
|
50
|
+
}
|
|
51
|
+
const result = await ctx.tdaiMemory
|
|
52
|
+
.handleBeforeRecall(text, session.id)
|
|
53
|
+
.catch(() => null);
|
|
54
|
+
cache.set(session.id, { text, result: result ?? {}, ts: now });
|
|
55
|
+
return injectRecall(assembly, result ?? {});
|
|
56
|
+
} catch {
|
|
57
|
+
return assembly;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractText(content) {
|
|
63
|
+
if (typeof content === "string") return content.trim();
|
|
64
|
+
if (!Array.isArray(content)) return "";
|
|
65
|
+
return content
|
|
66
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
67
|
+
.map((b) => b.text)
|
|
68
|
+
.join("\n")
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function injectRecall(assembly, result) {
|
|
73
|
+
const extras = [];
|
|
74
|
+
if (result.prependContext) extras.push({ name: "tdai:recall", order: 1000, text: result.prependContext });
|
|
75
|
+
if (result.appendSystemContext) extras.push({ name: "tdai:profile", order: 1001, text: result.appendSystemContext });
|
|
76
|
+
if (extras.length === 0) return assembly;
|
|
77
|
+
return { ...assembly, contexts: [...assembly.contexts, ...extras] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDAI Adapters — barrel re-export for all host adapter implementations.
|
|
3
|
+
*
|
|
4
|
+
* Each adapter translates a specific host environment's API into
|
|
5
|
+
* the host-neutral HostAdapter interface consumed by TdaiCore.
|
|
6
|
+
*
|
|
7
|
+
* Directory structure:
|
|
8
|
+
* adapters/
|
|
9
|
+
* ├── openclaw/ — OpenClaw plugin host (in-process, runEmbeddedPiAgent)
|
|
10
|
+
* └── standalone/ — Gateway / Hermes sidecar (HTTP, OpenAI-compatible API)
|
|
11
|
+
*/
|
|
12
|
+
// OpenClaw adapter
|
|
13
|
+
export { OpenClawHostAdapter, OpenClawLLMRunner, OpenClawLLMRunnerFactory } from "./openclaw/index.js";
|
|
14
|
+
// Standalone adapter
|
|
15
|
+
export { StandaloneHostAdapter, StandaloneLLMRunner, StandaloneLLMRunnerFactory } from "./standalone/index.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClawHostAdapter — translates OpenClaw's plugin API into TDAI Core's
|
|
3
|
+
* unified HostAdapter interface.
|
|
4
|
+
*
|
|
5
|
+
* This is the "thin shell" that keeps OpenClaw-specific dependencies
|
|
6
|
+
* (OpenClawPluginApi, pluginConfig, resolveStateDir, event system)
|
|
7
|
+
* confined to the adapter layer while TDAI Core remains host-neutral.
|
|
8
|
+
*
|
|
9
|
+
* Usage (in index.ts):
|
|
10
|
+
* const adapter = new OpenClawHostAdapter({ api, pluginDataDir, config });
|
|
11
|
+
* const core = new TdaiCore({ hostAdapter: adapter, config: parsedConfig });
|
|
12
|
+
*/
|
|
13
|
+
import { OpenClawLLMRunnerFactory } from "./llm-runner.js";
|
|
14
|
+
// ============================
|
|
15
|
+
// OpenClawHostAdapter
|
|
16
|
+
// ============================
|
|
17
|
+
export class OpenClawHostAdapter {
|
|
18
|
+
hostType = "openclaw";
|
|
19
|
+
api;
|
|
20
|
+
pluginDataDir;
|
|
21
|
+
openclawConfig;
|
|
22
|
+
runnerFactory;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.api = opts.api;
|
|
25
|
+
this.pluginDataDir = opts.pluginDataDir;
|
|
26
|
+
this.openclawConfig = opts.openclawConfig;
|
|
27
|
+
this.runnerFactory = new OpenClawLLMRunnerFactory({
|
|
28
|
+
config: opts.openclawConfig,
|
|
29
|
+
agentRuntime: opts.api.runtime.agent,
|
|
30
|
+
logger: opts.api.logger,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a RuntimeContext from the current OpenClaw session.
|
|
35
|
+
*
|
|
36
|
+
* In OpenClaw, sessionKey and sessionId come from the event/ctx objects
|
|
37
|
+
* passed to hooks. This method returns a context with sensible defaults;
|
|
38
|
+
* callers can override sessionKey/sessionId per-hook invocation using
|
|
39
|
+
* `buildRuntimeContextForSession()`.
|
|
40
|
+
*/
|
|
41
|
+
getRuntimeContext() {
|
|
42
|
+
return {
|
|
43
|
+
userId: "default_user",
|
|
44
|
+
sessionId: "",
|
|
45
|
+
sessionKey: "",
|
|
46
|
+
platform: "openclaw",
|
|
47
|
+
workspaceDir: process.cwd(),
|
|
48
|
+
dataDir: this.pluginDataDir,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build a RuntimeContext for a specific session (used per-hook).
|
|
53
|
+
*
|
|
54
|
+
* This is an OpenClaw-specific convenience that merges session-level
|
|
55
|
+
* identifiers from hook ctx into the base context.
|
|
56
|
+
*/
|
|
57
|
+
buildRuntimeContextForSession(sessionKey, sessionId) {
|
|
58
|
+
return {
|
|
59
|
+
...this.getRuntimeContext(),
|
|
60
|
+
sessionKey,
|
|
61
|
+
sessionId: sessionId ?? "",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
getLogger() {
|
|
65
|
+
return this.api.logger;
|
|
66
|
+
}
|
|
67
|
+
getLLMRunnerFactory() {
|
|
68
|
+
return this.runnerFactory;
|
|
69
|
+
}
|
|
70
|
+
// -- OpenClaw-specific accessors (for index.ts bridge) --------------------
|
|
71
|
+
/** Get the raw OpenClaw plugin API (for legacy callers during migration). */
|
|
72
|
+
getPluginApi() {
|
|
73
|
+
return this.api;
|
|
74
|
+
}
|
|
75
|
+
/** Get the OpenClaw config object (for legacy callers during migration). */
|
|
76
|
+
getOpenClawConfig() {
|
|
77
|
+
return this.openclawConfig;
|
|
78
|
+
}
|
|
79
|
+
/** Get the resolved plugin data directory. */
|
|
80
|
+
getPluginDataDir() {
|
|
81
|
+
return this.pluginDataDir;
|
|
82
|
+
}
|
|
83
|
+
}
|