dsh-tdai-memory 0.4.0 → 0.4.2
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/README.md +44 -26
- package/README.zh.md +28 -18
- package/client.js +119 -33
- package/index.js +168 -87
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +100 -97
- package/recall-inject.js +12 -10
- package/recall-runtime.js +42 -0
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# dsh-tdai-memory
|
|
2
2
|
|
|
3
|
+
The plugin follows the DSH language setting (Chinese and English in DSH 0.2.0-rc.2), including configuration, status messages and plugin-list metadata. Language-pack locales use the host fallback chain. Switching languages preserves unsaved settings; there is no separate plugin language selector.
|
|
4
|
+
|
|
3
5
|
## Configuration page (DSH 0.2.0-rc.2 and later)
|
|
4
6
|
|
|
5
7
|
Open **Plugins → Installed → dsh-tdai-memory** from the homepage sidebar to configure and save this plugin. The page uses the official `plugins.bundle.config` interface, without a duplicate entry in global Settings. Web and Desktop share the page. This version requires DSH 0.2.0-rc.2 or a later 0.2.x host; existing configuration is retained.
|
|
6
8
|
|
|
9
|
+
Choose **Automatic memory** for capture, extraction, recall and search together; **Search existing memory only** disables capture, extraction and automatic recall while keeping search tools; **Pause memory features** disables all four. The main page contains the two model connections. Individual switches, storage paths and tuning remain under **Advanced**. Existing mixed switch values appear as **Custom combination** and are preserved. Select a mode, Save, then restart DSH for pipeline changes (the search-tool switch applies immediately). Only edited fields are written; blank keys and untouched advanced options remain unchanged.
|
|
10
|
+
|
|
7
11
|
[](README.zh.md)
|
|
8
12
|
|
|
9
13
|
**GitHub**: [Scorp1o117/dsh-tdai-memory](https://github.com/Scorp1o117/dsh-tdai-memory) · **npm**: [dsh-tdai-memory](https://www.npmjs.com/package/dsh-tdai-memory)
|
|
@@ -15,22 +19,22 @@ Part of the [DeepSeek Harness Enhancement Suite](https://github.com/Scorp1o117/d
|
|
|
15
19
|
A port of **TencentDB Agent Memory** (Tencent Cloud's open-source four-layer
|
|
16
20
|
memory system, originally an OpenClaw plugin) into DeepSeek Harness.
|
|
17
21
|
|
|
18
|
-
## Compatibility (v0.
|
|
19
|
-
|
|
20
|
-
Verified with DSH `0.1.7-rc.2` (Web) and `0.2.0-rc.2` (Desktop runtime) in isolated profiles. The Desktop app uses its own `desktop` profile. Other DSH prereleases remain unverified.
|
|
21
|
-
|
|
22
|
-
## Desktop install
|
|
23
|
-
|
|
24
|
-
Use the Desktop-installed `dsh` command (Application → Manage dsh Command), or the app’s Plugins page. Then install into the Desktop profile:
|
|
25
|
-
|
|
26
|
-
```powershell
|
|
27
|
-
dsh plugin --profile desktop add dsh-tdai-memory@0.3.6
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
Restart the Desktop app to load the client bundle. Desktop keeps its profile under `$DSH_HOME/profiles/desktop`.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
## Features
|
|
22
|
+
## Compatibility (v0.4.1)
|
|
23
|
+
|
|
24
|
+
Verified with DSH `0.1.7-rc.2` (Web) and `0.2.0-rc.2` (Desktop runtime) in isolated profiles. The Desktop app uses its own `desktop` profile. Other DSH prereleases remain unverified.
|
|
25
|
+
|
|
26
|
+
## Desktop install
|
|
27
|
+
|
|
28
|
+
Use the Desktop-installed `dsh` command (Application → Manage dsh Command), or the app’s Plugins page. Then install into the Desktop profile:
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
dsh plugin --profile desktop add dsh-tdai-memory@0.3.6
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Restart the Desktop app to load the client bundle. Desktop keeps its profile under `$DSH_HOME/profiles/desktop`.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
## Features
|
|
34
38
|
|
|
35
39
|
- **L0 conversation capture**: every turn (turn end, request boundary) is
|
|
36
40
|
written to raw conversation storage (JSONL + SQLite + FTS + vectors)
|
|
@@ -57,6 +61,12 @@ The data directory reuses the existing `~/.memory-tencentdb/memory-tdai`, so
|
|
|
57
61
|
| dsh shell | `index.js`: config mapping, `session/event` + `session/flush` capture, `system-prompt/assemble` recall injection on `agent.ctx`, tool registration, lifecycle |
|
|
58
62
|
| Fallback | `recall-inject.js`: preset-row recall injection (used when mounted inside an agent preset) |
|
|
59
63
|
|
|
64
|
+
Recall caches expire after 30 seconds and retain at most 128 sessions per
|
|
65
|
+
injection instance. Failed or timed-out recalls are retried on the next assembly.
|
|
66
|
+
The preset row also accepts `timeoutMs` (default 4000); reaching this deadline
|
|
67
|
+
continues prompt assembly without memory context. Deadline timers are cleared
|
|
68
|
+
when recall settles early. Underlying recall work may continue after the deadline.
|
|
69
|
+
|
|
60
70
|
Hard-won wiring details:
|
|
61
71
|
|
|
62
72
|
- **Capture**: `session/flush` listener (await semantics; must complete before
|
|
@@ -69,14 +79,14 @@ Hard-won wiring details:
|
|
|
69
79
|
|
|
70
80
|
## Configuration (profile patch + settings)
|
|
71
81
|
|
|
72
|
-
Configuration is stored in the `tdai-memory` entry of the active Profile patch.
|
|
73
|
-
On first launch, DSH imports the old `$DSH_HOME/settings.yaml` values into that
|
|
74
|
-
entry. The **Web UI Settings → 记忆**
|
|
82
|
+
Configuration is stored in the `tdai-memory` entry of the active Profile patch.
|
|
83
|
+
On first launch, DSH imports the old `$DSH_HOME/settings.yaml` values into that
|
|
84
|
+
entry. The **Web UI Settings → 记忆**
|
|
75
85
|
section edits every field (v0.2.0, write-only keys); TdaiCore is built at
|
|
76
86
|
startup, so changes apply **after a restart**.
|
|
77
87
|
|
|
78
|
-
```yaml
|
|
79
|
-
# $DSH_HOME/profiles/web/cordis.patch.yml
|
|
88
|
+
```yaml
|
|
89
|
+
# $DSH_HOME/profiles/web/cordis.patch.yml
|
|
80
90
|
- id: tdai-memory
|
|
81
91
|
name: 'dsh-tdai-memory'
|
|
82
92
|
config:
|
|
@@ -107,11 +117,11 @@ then mount it in `$DSH_HOME/profiles/web/cordis.patch.yml`:
|
|
|
107
117
|
- insert:
|
|
108
118
|
- id: tdai-memory
|
|
109
119
|
name: 'dsh-tdai-memory'
|
|
110
|
-
config: {} # fill through Settings → 记忆
|
|
120
|
+
config: {} # fill through Settings → 记忆
|
|
111
121
|
```
|
|
112
122
|
|
|
113
|
-
and restart `dsh web`. LLM/embedding API keys can be set in the Web UI
|
|
114
|
-
settings page (记忆 / Memory) or in the `tdai-memory` Profile patch entry.
|
|
123
|
+
and restart `dsh web`. LLM/embedding API keys can be set in the Web UI
|
|
124
|
+
settings page (记忆 / Memory) or in the `tdai-memory` Profile patch entry.
|
|
115
125
|
|
|
116
126
|
> **Note for users**
|
|
117
127
|
> - This plugin is a standard **profile bundle** (`dsh.bundle.patch`):
|
|
@@ -140,6 +150,14 @@ not need it. Users who select the local backend should install and approve
|
|
|
140
150
|
stored=0); off by default; enable only with a more reliable model
|
|
141
151
|
- **L1 memory vectors**: written with storage (8088 embedding is fast); L0
|
|
142
152
|
vectors run as a background task, drained by `destroy()` on headless exit
|
|
153
|
+
- **Search tools** (`toolsEnabled`, on by default): the switch lives in the
|
|
154
|
+
Plugins page and applies in place — the row config is `volatile`, so the
|
|
155
|
+
plugin re-syncs its registration without a restart. If both tools are
|
|
156
|
+
missing, look for the plugin's
|
|
157
|
+
`[tdai-memory] tools registered: tdai_memory_search, tdai_conversation_search`
|
|
158
|
+
log line (v0.4.1+). Before v0.4.1 it read `config.toolsEnabled` off the
|
|
159
|
+
Volatile wrapper cordis hands to `apply` — a property that never exists — so
|
|
160
|
+
the failure was completely silent (issue #3)
|
|
143
161
|
- **Upgrades**: after pulling new upstream code, rerun
|
|
144
162
|
`npx tsc -p dsh-tsconfig.json` in the tdai project dir (output in `dist-dsh/`)
|
|
145
163
|
|
|
@@ -147,5 +165,5 @@ not need it. Users who select the local backend should install and approve
|
|
|
147
165
|
|
|
148
166
|
MIT
|
|
149
167
|
|
|
150
|
-
|
|
151
|
-
Saves use the configuration form API. Rejected writes retain the draft and show an error. Changing a model preserves credentials, and disabling a default-on option explicitly stores `false`.
|
|
168
|
+
|
|
169
|
+
Saves use the configuration form API. Rejected writes retain the draft and show an error. Changing a model preserves credentials, and disabling a default-on option explicitly stores `false`.
|
package/README.zh.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# dsh-tdai-memory
|
|
2
2
|
|
|
3
|
+
插件跟随 DSH 的语言设置(DSH 0.2.0-rc.2 内置中文和 English),配置页面、状态提示和插件列表名称/简介同步切换。扩展语言使用宿主的回退链。切换语言保留未保存的设置,无需单独选择插件语言。
|
|
4
|
+
|
|
3
5
|
## 配置入口(DSH 0.2.0-rc.2 起)
|
|
4
6
|
|
|
5
7
|
在首页侧边栏打开 **插件 → 已安装 → dsh-tdai-memory**,直接在插件详情页配置并保存。配置页注册到官方的 `plugins.bundle.config` 接口;全局设置页不再重复显示配置入口。Web 与桌面版使用相同界面,本版要求 DSH 0.2.0-rc.2 或更新的 0.2.x 版本。现有配置无需迁移。
|
|
6
8
|
|
|
9
|
+
选择 **自动记忆**,一次开启捕获、提取、召回和搜索;**仅搜索已有记忆** 关闭捕获、提取和自动召回,保留搜索工具;**暂停记忆功能** 关闭全部四项。主页面只展示两个模型的连接信息,独立开关、存储路径和调优参数保留在 **高级设置** 中。已有开关组合显示为 **自定义组合**,不会自动覆盖。选择模式后保存,并重启 DSH 使记忆流水线设置生效(搜索工具开关即时生效)。只写入编辑过的字段,密钥留空及未编辑的高级设置保持原值。
|
|
10
|
+
|
|
7
11
|
**GitHub**: [Scorp1o117/dsh-tdai-memory](https://github.com/Scorp1o117/dsh-tdai-memory) · **npm**: [dsh-tdai-memory](https://www.npmjs.com/package/dsh-tdai-memory) · [English](README.md)
|
|
8
12
|
|
|
9
13
|
[](https://github.com/Scorp1o117/dsh-enhancement-suite) [](https://www.npmjs.com/package/dsh-enhancement-suite)
|
|
@@ -12,22 +16,22 @@
|
|
|
12
16
|
|
|
13
17
|
把 **TencentDB Agent Memory**(腾讯云开源的四层记忆系统,原为 OpenClaw 插件)移植进 DeepSeek Harness。
|
|
14
18
|
|
|
15
|
-
## 兼容性(v0.
|
|
16
|
-
|
|
17
|
-
已在 DSH `0.1.7-rc.2`(Web)与 `0.2.0-rc.2`(桌面端运行时)的一次性 Profile 验证。桌面端使用独立的 `desktop` Profile;其他预发布版本暂未验证。
|
|
18
|
-
|
|
19
|
-
## 桌面端安装
|
|
20
|
-
|
|
21
|
-
在桌面端的“插件”页面安装,或使用桌面端“应用 → 管理 dsh 命令”注册的命令:
|
|
22
|
-
|
|
23
|
-
```powershell
|
|
24
|
-
dsh plugin --profile desktop add dsh-tdai-memory@0.3.6
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
重启桌面端以加载客户端插件。配置位于 `$DSH_HOME/profiles/desktop`。
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
## 能力
|
|
19
|
+
## 兼容性(v0.4.1)
|
|
20
|
+
|
|
21
|
+
已在 DSH `0.1.7-rc.2`(Web)与 `0.2.0-rc.2`(桌面端运行时)的一次性 Profile 验证。桌面端使用独立的 `desktop` Profile;其他预发布版本暂未验证。
|
|
22
|
+
|
|
23
|
+
## 桌面端安装
|
|
24
|
+
|
|
25
|
+
在桌面端的“插件”页面安装,或使用桌面端“应用 → 管理 dsh 命令”注册的命令:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
dsh plugin --profile desktop add dsh-tdai-memory@0.3.6
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
重启桌面端以加载客户端插件。配置位于 `$DSH_HOME/profiles/desktop`。
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 能力
|
|
31
35
|
|
|
32
36
|
- **L0 对话捕获**:每轮对话(turn 结束、请求边界)自动写入原始对话(JSONL + SQLite + FTS + 向量)
|
|
33
37
|
- **L1 结构化记忆**:后台管线用 LLM 从对话中提取事实/偏好/事件(persona / episodic / instruction),写入 `records/` + SQLite + FTS + 向量
|
|
@@ -52,6 +56,11 @@ dsh plugin --profile desktop add dsh-tdai-memory@0.3.6
|
|
|
52
56
|
| dsh 壳 | `index.js`:配置映射、`session/event`+`session/flush` 捕获、`agent.ctx` 上的 `system-prompt/assemble` waterfall 召回注入、工具注册、生命周期 |
|
|
53
57
|
| 备用 | `recall-inject.js`:preset 行形态的召回注入(挂在 agent preset 内时用) |
|
|
54
58
|
|
|
59
|
+
召回缓存默认 30 秒过期,每个注入实例最多保留 128 个会话。失败或超时的召回
|
|
60
|
+
会在下次组装时重试。preset 行还支持 `timeoutMs`(默认 4000),到达期限后
|
|
61
|
+
不注入记忆并继续组装提示词;提前完成时会清除超时定时器。期限到达后,底层
|
|
62
|
+
召回任务可能仍会继续运行。
|
|
63
|
+
|
|
55
64
|
关键接线(都是踩坑换来的):
|
|
56
65
|
|
|
57
66
|
- **捕获**:`session/flush` 监听器(await 语义,headless 退出前必完成);`turn/start` 时间戳做 L0 cursor 下限;turn id 去重
|
|
@@ -96,7 +105,8 @@ tdai-memory:
|
|
|
96
105
|
- **提取模型**:`mimo-v2.5` 提取正确但单次 20-30s(后台执行,不阻塞对话);`deepseek-v4-flash` 快但 JSON 输出不合规(提取 0 条)
|
|
97
106
|
- **dedup**:LLM 冲突检测输出解析不稳(曾导致 stored=0),默认关闭;开启需换更稳的模型
|
|
98
107
|
- **L1 记忆向量**:随存储写入(8088 embedding 快);L0 向量走后台任务,headless 退出时由 `destroy()` drain
|
|
108
|
+
- **搜索工具**(`toolsEnabled`,默认开启):开关在插件页,切换后**原地生效**(行配置是 `volatile`,无需重启即重新注册)。若两个工具完全缺失,可查插件日志 `[tdai-memory] tools registered: …`(v0.4.1 起);v0.4.1 之前它读的是 cordis 交给 `apply` 的 Volatile 包装对象上的 `config.toolsEnabled` —— 那个属性永远不存在,因此整个失败过程完全静默(issue #3)
|
|
99
109
|
- **升级**:上游拉新代码后,在 tdai 项目目录重跑 `npx tsc -p dsh-tsconfig.json`(产物在 `dist-dsh/`)
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
保存使用配置表单 API;宿主拒绝写入时保留草稿并提示错误。只修改模型不会覆盖密钥,关闭默认开启的选项会显式保存 `false`。
|
|
111
|
+
|
|
112
|
+
保存使用配置表单 API;宿主拒绝写入时保留草稿并提示错误。只修改模型不会覆盖密钥,关闭默认开启的选项会显式保存 `false`。
|
package/client.js
CHANGED
|
@@ -36,6 +36,35 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
".__tm_status{font-size:12px;color:var(--dsw-alias-label-tertiary)}" +
|
|
37
37
|
".__tm_error{font-size:12px;color:var(--dsw-alias-state-error-primary)}" +
|
|
38
38
|
".__tm_unavailable{font-size:13px;color:var(--dsw-alias-label-tertiary)}";
|
|
39
|
+
// Scoped flat controls retain native keyboard and form behavior.
|
|
40
|
+
CSS += `
|
|
41
|
+
.dsh-flat.__tm_root{width:100%;max-width:720px;gap:14px;font-size:13px;line-height:1.65;color:var(--dsw-alias-label-primary);--flat-accent:var(--dsw-alias-state-business-primary,#3964fe);--flat-border:var(--dsw-alias-border-l2,#dce2eb)}
|
|
42
|
+
.dsh-flat.__tm_root *{box-sizing:border-box;min-width:0}
|
|
43
|
+
.dsh-flat.__tm_root p{margin:0}
|
|
44
|
+
.dsh-flat.__tm_root label[class$="_field"]{gap:7px}
|
|
45
|
+
.dsh-flat.__tm_root [class$="_label"]{font-size:13px;font-weight:500}
|
|
46
|
+
.dsh-flat.__tm_root [class$="_hint"]{font-size:12px;line-height:1.65}
|
|
47
|
+
.dsh-flat.__tm_root input:not([type=checkbox]),.dsh-flat.__tm_root select,.dsh-flat.__tm_root textarea{width:100%;border:1px solid var(--flat-border);border-radius:6px;background:var(--dsw-alias-bg-layer-3);color:inherit;font:inherit;padding:9px 12px;min-height:40px;box-shadow:none;transition:border-color .15s}
|
|
48
|
+
.dsh-flat.__tm_root input:hover:not(:disabled),.dsh-flat.__tm_root select:hover:not(:disabled),.dsh-flat.__tm_root textarea:hover:not(:disabled){border-color:var(--dsw-alias-label-tertiary)}
|
|
49
|
+
.dsh-flat.__tm_root select{appearance:none;padding-right:34px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m2 2 4 4 4-4' fill='none' stroke='%23778091' stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
|
|
50
|
+
.dsh-flat.__tm_root input[type=checkbox]{appearance:none;flex:none;width:30px;height:18px;margin:0;border:1px solid var(--flat-border);border-radius:12px;background:var(--dsw-alias-bg-layer-2);position:relative;cursor:pointer;transition:background .15s,border-color .15s}
|
|
51
|
+
.dsh-flat.__tm_root input[type=checkbox]::before{content:"";position:absolute;left:2px;top:2px;width:12px;height:12px;border-radius:50%;background:var(--dsw-alias-label-secondary);transition:transform .15s}
|
|
52
|
+
.dsh-flat.__tm_root input[type=checkbox]:checked{background:var(--flat-accent);border-color:var(--flat-accent)}
|
|
53
|
+
.dsh-flat.__tm_root input[type=checkbox]:checked::before{transform:translateX(12px);background:#fff}
|
|
54
|
+
.dsh-flat.__tm_root :is(input,select,textarea,button,summary,a):focus-visible{outline:2px solid var(--flat-accent);outline-offset:3px}
|
|
55
|
+
.dsh-flat.__tm_root :is(input,select,textarea,button):disabled{opacity:.5;cursor:default}
|
|
56
|
+
.dsh-flat.__tm_root [class$="_actions"]{flex-wrap:wrap;gap:10px;margin-top:4px;padding-top:16px;border-top:1px solid var(--flat-border)}
|
|
57
|
+
.dsh-flat.__tm_root details{border-top:1px solid var(--flat-border);padding:0}
|
|
58
|
+
.dsh-flat.__tm_root summary{list-style:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 0;font-size:13px;font-weight:500;cursor:pointer;color:var(--dsw-alias-label-secondary)}
|
|
59
|
+
.dsh-flat.__tm_root summary::-webkit-details-marker{display:none}
|
|
60
|
+
.dsh-flat.__tm_root summary::after{content:"+";font-size:18px;font-weight:400;flex:none}
|
|
61
|
+
.dsh-flat.__tm_root details[open]>summary::after{content:"−"}
|
|
62
|
+
.dsh-flat.__tm_root details>div{padding-bottom:18px}
|
|
63
|
+
.dsh-flat.__tm_root .__tm_group{border:0;font-weight:600;font-size:14px;margin:8px 0 0;padding:0}
|
|
64
|
+
@media(prefers-reduced-motion:reduce){.dsh-flat.__tm_root *,.dsh-flat.__tm_root input[type=checkbox]::before{transition:none}}
|
|
65
|
+
.dsh-flat.__tm_root button{border-radius:6px;min-height:34px;padding:7px 14px;font:inherit;font-size:12px;box-shadow:none}
|
|
66
|
+
.dsh-flat.__tm_root :is(h2,h3){margin:0;font-size:14px;font-weight:600}
|
|
67
|
+
`;
|
|
39
68
|
var tagId = "dsh-tdai-memory/main.css";
|
|
40
69
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
41
70
|
var tag = document.createElement("style");
|
|
@@ -50,10 +79,18 @@ window.__ModuleLoader__.load({
|
|
|
50
79
|
var inject = ["slots", "locale", "configForms"];
|
|
51
80
|
var zh = {
|
|
52
81
|
nav: "记忆",
|
|
53
|
-
|
|
82
|
+
mode: "记忆模式",
|
|
83
|
+
modeAuto: "自动记忆(推荐)",
|
|
84
|
+
modeSearch: "仅搜索已有记忆",
|
|
85
|
+
modePaused: "暂停记忆功能",
|
|
86
|
+
modeCustom: "自定义组合",
|
|
87
|
+
modeHint: "自动:捕获、提取、召回和搜索全部开启;仅搜索:不记录新对话、不自动注入记忆;暂停:四项全部关闭。选择后点击保存。",
|
|
88
|
+
advanced: "高级设置:独立开关、存储与请求参数",
|
|
89
|
+
setupHint: "填写提取模型和向量模型的连接信息,再保存。已有自定义设置会保留;密钥留空不修改。",
|
|
90
|
+
intro: "记录对话、整理长期记忆,并在相关对话中自动召回。保存后请重启 DSH 生效;密钥只写不读。",
|
|
54
91
|
groupData: "数据",
|
|
55
|
-
groupLlm: "
|
|
56
|
-
groupEmbedding: "
|
|
92
|
+
groupLlm: "记忆整理模型",
|
|
93
|
+
groupEmbedding: "向量检索模型",
|
|
57
94
|
groupCapture: "捕获",
|
|
58
95
|
groupExtraction: "提取",
|
|
59
96
|
groupRecall: "召回",
|
|
@@ -95,10 +132,18 @@ window.__ModuleLoader__.load({
|
|
|
95
132
|
};
|
|
96
133
|
var en = {
|
|
97
134
|
nav: "Memory",
|
|
98
|
-
|
|
135
|
+
mode: "Memory mode",
|
|
136
|
+
modeAuto: "Automatic memory (recommended)",
|
|
137
|
+
modeSearch: "Search existing memory only",
|
|
138
|
+
modePaused: "Pause memory features",
|
|
139
|
+
modeCustom: "Custom combination",
|
|
140
|
+
modeHint: "Automatic enables capture, extraction, recall and search. Search only records no new conversations and injects no memory. Pause disables all four. Click Save to apply your selection.",
|
|
141
|
+
advanced: "Advanced: individual switches, storage and request options",
|
|
142
|
+
setupHint: "Enter the extraction and embedding connections, then save. Existing custom settings are preserved; blank keys stay unchanged.",
|
|
143
|
+
intro: "Record conversations, organize long-term memory and recall it in relevant chats. Restart DSH after saving to apply changes; keys are write-only.",
|
|
99
144
|
groupData: "Data",
|
|
100
|
-
groupLlm: "
|
|
101
|
-
groupEmbedding: "
|
|
145
|
+
groupLlm: "Memory extraction model",
|
|
146
|
+
groupEmbedding: "Memory search embeddings",
|
|
102
147
|
groupCapture: "Capture",
|
|
103
148
|
groupExtraction: "Extraction",
|
|
104
149
|
groupRecall: "Recall",
|
|
@@ -165,6 +210,9 @@ window.__ModuleLoader__.load({
|
|
|
165
210
|
{ path: ["toolsEnabled"], label: "fieldToolsEnabled", type: "checkbox", group: "groupTools" }
|
|
166
211
|
];
|
|
167
212
|
FIELDS.forEach(function (f) { f.key = f.path.join("."); });
|
|
213
|
+
var BASIC_KEYS = ["llm.baseUrl", "llm.apiKey", "llm.model", "embedding.baseUrl", "embedding.apiKey", "embedding.model"];
|
|
214
|
+
var MODE_KEYS = ["captureEnabled", "extraction.enabled", "recall.enabled", "toolsEnabled"];
|
|
215
|
+
var MODES = { auto: [true, true, true, true], search: [false, false, false, true], paused: [false, false, false, false] };
|
|
168
216
|
|
|
169
217
|
function getPath(obj, path) {
|
|
170
218
|
var cur = obj;
|
|
@@ -176,6 +224,7 @@ window.__ModuleLoader__.load({
|
|
|
176
224
|
}
|
|
177
225
|
|
|
178
226
|
function MemorySection(props) {
|
|
227
|
+
useLocale(props.locale);
|
|
179
228
|
var t = props.t;
|
|
180
229
|
var scope = props.scope;
|
|
181
230
|
var [snapshot, setSnapshot] = react.useState(function () { return scope.getSnapshot(); });
|
|
@@ -204,7 +253,7 @@ window.__ModuleLoader__.load({
|
|
|
204
253
|
// call, so depending on snapshot.value would reset user input on every
|
|
205
254
|
// render (typing appears dead).
|
|
206
255
|
react.useEffect(function () {
|
|
207
|
-
if (ready) setDraft(
|
|
256
|
+
if (ready) setDraft({});
|
|
208
257
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
209
258
|
}, [ready]);
|
|
210
259
|
|
|
@@ -226,6 +275,23 @@ window.__ModuleLoader__.load({
|
|
|
226
275
|
setNotice(null);
|
|
227
276
|
setError(null);
|
|
228
277
|
}
|
|
278
|
+
function memoryMode() {
|
|
279
|
+
var flags = MODE_KEYS.map(function (key) {
|
|
280
|
+
return Object.prototype.hasOwnProperty.call(draft, key) ? draft[key] : getPath(value, key.split(".")) !== false;
|
|
281
|
+
});
|
|
282
|
+
return Object.keys(MODES).find(function (mode) {
|
|
283
|
+
return MODES[mode].every(function (flag, i) { return flag === flags[i]; });
|
|
284
|
+
}) || "custom";
|
|
285
|
+
}
|
|
286
|
+
function setMode(mode) {
|
|
287
|
+
if (!MODES[mode] || busy || !snapshot.writable) return;
|
|
288
|
+
setDraft(function (prev) {
|
|
289
|
+
var next = Object.assign({}, prev);
|
|
290
|
+
MODE_KEYS.forEach(function (key, i) { next[key] = MODES[mode][i]; });
|
|
291
|
+
return next;
|
|
292
|
+
});
|
|
293
|
+
setNotice(null); setError(null);
|
|
294
|
+
}
|
|
229
295
|
|
|
230
296
|
function onSave() {
|
|
231
297
|
if (busy || !snapshot.writable) return;
|
|
@@ -233,6 +299,7 @@ window.__ModuleLoader__.load({
|
|
|
233
299
|
var ops = [];
|
|
234
300
|
for (var i = 0; i < FIELDS.length; i += 1) {
|
|
235
301
|
var f = FIELDS[i];
|
|
302
|
+
if (!Object.prototype.hasOwnProperty.call(draft, f.key)) continue;
|
|
236
303
|
var d = fieldDraft(f);
|
|
237
304
|
var current = getPath(value, f.path);
|
|
238
305
|
if (f.type === "password") {
|
|
@@ -250,7 +317,7 @@ window.__ModuleLoader__.load({
|
|
|
250
317
|
if (String(d).trim() === "" && getPath(user, f.path) === void 0) continue;
|
|
251
318
|
ops.push(String(d).trim() === "" ? { op: "unset", path: f.path } : { op: "set", path: f.path, value: f.type === "number" ? Number(d) : d });
|
|
252
319
|
}
|
|
253
|
-
if (ops.length === 0) { setBusy(false); setNotice(
|
|
320
|
+
if (ops.length === 0) { setBusy(false); setNotice({ key: "saved" }); return; }
|
|
254
321
|
commit(ops);
|
|
255
322
|
}
|
|
256
323
|
|
|
@@ -269,36 +336,38 @@ window.__ModuleLoader__.load({
|
|
|
269
336
|
var next = scope.getSnapshot();
|
|
270
337
|
setSnapshot(next);
|
|
271
338
|
setBusy(false);
|
|
272
|
-
if (!ok) { setError(
|
|
273
|
-
setNotice(
|
|
274
|
-
if (next.value) setDraft(
|
|
339
|
+
if (!ok) { setError({ key: "error", detailKey: "notApplied" }); return; }
|
|
340
|
+
setNotice({ key: "saved" });
|
|
341
|
+
if (next.value) setDraft({});
|
|
275
342
|
}).catch(function (e) {
|
|
276
|
-
setBusy(false); setError(
|
|
343
|
+
setBusy(false); setError({ key: "error", detail: String(e && e.message || e) });
|
|
277
344
|
});
|
|
278
345
|
}
|
|
279
346
|
|
|
280
|
-
var nodes = [];
|
|
281
|
-
var
|
|
347
|
+
var nodes = [], advancedNodes = [];
|
|
348
|
+
var lastGroups = { basic: null, advanced: null };
|
|
282
349
|
// forEach callback gives each handler its own `f` — a `for (var i)`
|
|
283
350
|
// loop would share one `f` across every onChange closure, so typing
|
|
284
351
|
// updated the LAST field's draft and the input appeared dead.
|
|
285
352
|
FIELDS.forEach(function (f) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
353
|
+
var section = BASIC_KEYS.indexOf(f.key) !== -1 ? "basic" : "advanced";
|
|
354
|
+
var target = section === "basic" ? nodes : advancedNodes;
|
|
355
|
+
if (f.group !== lastGroups[section]) {
|
|
356
|
+
lastGroups[section] = f.group;
|
|
357
|
+
target.push(h("div", { key: "g" + f.group, className: "__tm_group" }, t(f.group)));
|
|
289
358
|
}
|
|
290
359
|
var overridden = getPath(user, f.path) !== void 0;
|
|
291
360
|
if (f.type === "checkbox") {
|
|
292
|
-
|
|
361
|
+
target.push(h("label", { key: f.path.join("."), className: "__tm_field" },
|
|
293
362
|
h("span", { className: "__tm_row" },
|
|
294
|
-
h("input", { className: "__tm_check", type: "checkbox", checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
|
|
363
|
+
h("input", { className: "__tm_check", type: "checkbox", disabled: busy || !snapshot.writable, checked: Boolean(fieldDraft(f)), onChange: function (e) { setField(f, e.target.checked); } }),
|
|
295
364
|
h("span", { className: "__tm_label" }, t(f.label)),
|
|
296
365
|
overridden ? h("span", { className: "__tm_override" }, t("overridden")) : null
|
|
297
366
|
)
|
|
298
367
|
));
|
|
299
368
|
return;
|
|
300
369
|
}
|
|
301
|
-
|
|
370
|
+
target.push(h("label", { key: f.path.join("."), className: "__tm_field" },
|
|
302
371
|
h("span", { className: "__tm_label" },
|
|
303
372
|
t(f.label),
|
|
304
373
|
overridden ? h("span", { className: "__tm_override" }, t("overridden")) : null
|
|
@@ -307,6 +376,7 @@ window.__ModuleLoader__.load({
|
|
|
307
376
|
className: "__tm_input",
|
|
308
377
|
type: f.type === "password" ? "password" : f.type === "number" ? "number" : "text",
|
|
309
378
|
value: fieldDraft(f),
|
|
379
|
+
disabled: busy || !snapshot.writable,
|
|
310
380
|
placeholder: f.type === "password" ? (overridden ? "••••••••" : t("secretHint")) : "",
|
|
311
381
|
onChange: function (e) { setField(f, e.target.value); }
|
|
312
382
|
}),
|
|
@@ -314,29 +384,45 @@ window.__ModuleLoader__.load({
|
|
|
314
384
|
));
|
|
315
385
|
});
|
|
316
386
|
|
|
317
|
-
return h("div", { className: "__tm_root" },
|
|
387
|
+
return h("div", { className: "__tm_root dsh-flat" },
|
|
318
388
|
h("p", { className: "__tm_hint", style: { margin: "0 0 4px" } }, t("intro")),
|
|
389
|
+
h("label", { className: "__tm_field" },
|
|
390
|
+
h("span", { className: "__tm_label" }, t("mode")),
|
|
391
|
+
h("select", { className: "__tm_input", "aria-label": t("mode"), value: memoryMode(), disabled: busy || !snapshot.writable,
|
|
392
|
+
onChange: function (e) { setMode(e.target.value); } },
|
|
393
|
+
h("option", { value: "auto" }, t("modeAuto")), h("option", { value: "search" }, t("modeSearch")),
|
|
394
|
+
h("option", { value: "paused" }, t("modePaused")), h("option", { value: "custom", disabled: true }, t("modeCustom"))),
|
|
395
|
+
h("span", { className: "__tm_hint" }, t("modeHint"))),
|
|
396
|
+
h("p", { className: "__tm_hint" }, t("setupHint")),
|
|
319
397
|
nodes,
|
|
398
|
+
h("details", null, h("summary", { style: { cursor: "pointer" } }, t("advanced")),
|
|
399
|
+
h("div", { style: { display: "flex", flexDirection: "column", gap: 10, paddingTop: 12 } }, advancedNodes)),
|
|
320
400
|
h("div", { className: "__tm_actions" },
|
|
321
401
|
h("button", { type: "button", className: "__tm_btn __tm_btnPrimary", onClick: onSave, disabled: busy || !snapshot.writable }, t("save")),
|
|
322
402
|
h("button", { type: "button", className: "__tm_btn", onClick: onReset, disabled: busy || !snapshot.writable }, t("reset")),
|
|
323
|
-
notice ? h("span", { className: "__tm_status" }, notice) : null,
|
|
403
|
+
notice ? h("span", { className: "__tm_status" }, messageText(t, notice)) : null,
|
|
324
404
|
busy ? h("span", { className: "__tm_status" }, t("saving")) : null,
|
|
325
|
-
error ? h("span", { className: "__tm_error" }, error) : null
|
|
405
|
+
error ? h("span", { className: "__tm_error" }, messageText(t, error)) : null
|
|
326
406
|
)
|
|
327
407
|
);
|
|
328
408
|
}
|
|
329
409
|
|
|
330
|
-
function valueToDraft(value) {
|
|
331
|
-
var out = {};
|
|
332
|
-
for (var i = 0; i < FIELDS.length; i += 1) {
|
|
333
|
-
var f = FIELDS[i];
|
|
334
|
-
out[f.key] = f.secret ? "" : f.type === "checkbox" ? Boolean(getPath(value, f.path)) : String(getPath(value, f.path) ?? "");
|
|
335
|
-
}
|
|
336
|
-
return out;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
410
|
// ── plugin ────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
// Follow the host language without remounting the form or losing drafts.
|
|
413
|
+
function useLocale(locale) {
|
|
414
|
+
var refresh = react.useState(0)[1];
|
|
415
|
+
react.useEffect(function () {
|
|
416
|
+
if (!locale || typeof locale.subscribe !== "function") return;
|
|
417
|
+
return locale.subscribe(function () { refresh(function (revision) { return revision + 1; }); });
|
|
418
|
+
}, [locale]);
|
|
419
|
+
}
|
|
420
|
+
// Keep translation keys in state so feedback follows later language changes.
|
|
421
|
+
function messageText(t, message) {
|
|
422
|
+
if (!message) return "";
|
|
423
|
+
return t(message.key) + (message.detailKey ? ": " + t(message.detailKey) : message.detail ? ": " + message.detail : "");
|
|
424
|
+
}
|
|
425
|
+
|
|
340
426
|
function apply(ctx) {
|
|
341
427
|
var t = ctx.locale.bind(NS);
|
|
342
428
|
ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tdai-memory: dictionaries");
|
|
@@ -347,7 +433,7 @@ window.__ModuleLoader__.load({
|
|
|
347
433
|
key: "dsh-tdai-memory",
|
|
348
434
|
locale: NS
|
|
349
435
|
}, function (props) {
|
|
350
|
-
return h(MemorySection, Object.assign({}, props, { scope: scope, t: t }));
|
|
436
|
+
return h(MemorySection, Object.assign({}, props, { scope: scope, t: t, locale: ctx.locale }));
|
|
351
437
|
});
|
|
352
438
|
});
|
|
353
439
|
}
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
|
24
24
|
import { TdaiCore } from "./vendor/tdai/core/tdai-core.js";
|
|
25
25
|
import { parseConfig } from "./vendor/tdai/config.js";
|
|
26
26
|
import { StandaloneHostAdapter } from "./vendor/tdai/adapters/standalone/host-adapter.js";
|
|
27
|
+
import { createRecallCache, recallWithTimeout } from "./recall-runtime.js";
|
|
27
28
|
|
|
28
29
|
/** Cordis plugin name. */
|
|
29
30
|
const name = "tdai-memory";
|
|
@@ -37,7 +38,7 @@ const Config = z.object({
|
|
|
37
38
|
/** TDAI data directory; default: ~/.memory-tencentdb/memory-tdai. */
|
|
38
39
|
dataDir: z.string().default(""),
|
|
39
40
|
/** LLM used for L1/L2/L3 extraction (OpenAI-compatible). Falls back to TDAI_LLM_* env. */
|
|
40
|
-
llm: z.object({
|
|
41
|
+
llm: z.object({
|
|
41
42
|
baseUrl: z.string().default(""),
|
|
42
43
|
apiKey: z.string().default("").role("secret"),
|
|
43
44
|
model: z.string().default(""),
|
|
@@ -52,34 +53,34 @@ const Config = z.object({
|
|
|
52
53
|
sessionHeaderName: z.string().default("x-opencode-session"),
|
|
53
54
|
/** Fixed session id for LLM requests; empty = persistent auto id. */
|
|
54
55
|
sessionId: z.string().default(""),
|
|
55
|
-
}).default({}),
|
|
56
|
+
}).default({}),
|
|
56
57
|
/** Embedding endpoint (OpenAI-compatible /v1/embeddings). */
|
|
57
|
-
embedding: z.object({
|
|
58
|
+
embedding: z.object({
|
|
58
59
|
baseUrl: z.string().default("http://127.0.0.1:8088/v1"),
|
|
59
60
|
apiKey: z.string().default("").role("secret"),
|
|
60
61
|
model: z.string().default("Qwen3-Embedding-0.6B"),
|
|
61
62
|
dimensions: z.number().default(1024),
|
|
62
63
|
sendDimensions: z.boolean().default(false),
|
|
63
|
-
}).default({}),
|
|
64
|
+
}).default({}),
|
|
64
65
|
/** Capture conversations into L0. */
|
|
65
66
|
captureEnabled: z.boolean().default(true),
|
|
66
67
|
/** L1 extraction pipeline (facts from conversations). */
|
|
67
|
-
extraction: z.object({
|
|
68
|
+
extraction: z.object({
|
|
68
69
|
enabled: z.boolean().default(true),
|
|
69
70
|
/** Conflict detection before storing; extra LLM call, disable if flaky.
|
|
70
71
|
* Off by default: dedup LLM output parsing is flaky (README matches). */
|
|
71
72
|
enableDedup: z.boolean().default(false),
|
|
72
|
-
}).default({}),
|
|
73
|
+
}).default({}),
|
|
73
74
|
/** Recall injection settings. */
|
|
74
|
-
recall: z.object({
|
|
75
|
+
recall: z.object({
|
|
75
76
|
enabled: z.boolean().default(true),
|
|
76
77
|
maxResults: z.number().default(5),
|
|
77
78
|
scoreThreshold: z.number().default(0.3),
|
|
78
79
|
timeoutMs: z.number().default(3000),
|
|
79
|
-
}).default({}),
|
|
80
|
+
}).default({}),
|
|
80
81
|
/** Register the two search tools. */
|
|
81
82
|
toolsEnabled: z.boolean().default(true),
|
|
82
|
-
}).volatile();
|
|
83
|
+
}).volatile();
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* Whether a derived message is real human input.
|
|
@@ -106,18 +107,31 @@ function extractText(content) {
|
|
|
106
107
|
.trim();
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the config value handed to `apply`.
|
|
112
|
+
*
|
|
113
|
+
* The row schema ends in `.volatile()` (hot-reloadable config). Config
|
|
114
|
+
* validation runs through the standard-schema path, which returns the
|
|
115
|
+
* validated object wrapped in a cosmokit `Volatile`: a frozen `{ get() }`
|
|
116
|
+
* reference whose OWN properties are always `undefined`. Reading
|
|
117
|
+
* `config.toolsEnabled` off it can therefore never be true — the tools guard
|
|
118
|
+
* did exactly that and silently stopped registering the search tools in
|
|
119
|
+
* v0.3.4 (issue #3). Read config through this helper only.
|
|
120
|
+
*
|
|
121
|
+
* The wrapper is a live reference (`updateVolatile` swaps its inner value), so
|
|
122
|
+
* calling this at use time — not once at startup — sees volatile edits.
|
|
123
|
+
*/
|
|
124
|
+
function runtimeConfigOf(config) {
|
|
125
|
+
return typeof config?.get === "function" ? config.get() : config;
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
function apply(ctx, config) {
|
|
110
129
|
// ── build-once state (rebuildable) ───────────────────────────────────────
|
|
111
|
-
// TdaiCore is built from the
|
|
112
|
-
// base
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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.
|
|
130
|
+
// TdaiCore is built once from the resolved row config: the composition entry
|
|
131
|
+
// as base with the Plugins-page values merged in by the host (settings.yaml is
|
|
132
|
+
// no longer a config source since v0.4.0 — see client.js `configForms`). The
|
|
133
|
+
// build is deferred past the 0ms window so web/headless boots are never
|
|
134
|
+
// blocked on TdaiCore's stores and embedding client.
|
|
121
135
|
let core = null;
|
|
122
136
|
let tdaiConfig = null;
|
|
123
137
|
let dataDir = "";
|
|
@@ -132,8 +146,7 @@ function apply(ctx, config) {
|
|
|
132
146
|
/** session keys seen this process (for final L1 flush on teardown). */
|
|
133
147
|
const seenSessions = new Set();
|
|
134
148
|
/** recall cache per session: same user text within TTL reuses the result. */
|
|
135
|
-
const recallCache =
|
|
136
|
-
const RECALL_CACHE_TTL_MS = 30_000;
|
|
149
|
+
const recallCache = createRecallCache();
|
|
137
150
|
|
|
138
151
|
// One-shot mode (dsh --profile headless): the process exits right after
|
|
139
152
|
// flush, so L1 must finish inside the flush listener. In web mode the
|
|
@@ -218,6 +231,7 @@ function apply(ctx, config) {
|
|
|
218
231
|
// new core; initialize() only sets up stores/pipeline.
|
|
219
232
|
tdaiConfig = nextConfig;
|
|
220
233
|
core = nextCore;
|
|
234
|
+
recallCache.clear();
|
|
221
235
|
await nextCore.initialize();
|
|
222
236
|
const key = nextConfig.llm.apiKey || "";
|
|
223
237
|
log("info", `initialized (dataDir=${dataDir}, llm=${nextConfig.llm.model}, apiKey=${key ? `${key.slice(0, 6)}…` : "<empty>"}, embedding=${nextConfig.embedding.model})`);
|
|
@@ -308,16 +322,15 @@ function apply(ctx, config) {
|
|
|
308
322
|
if (!lastUser) return assembly;
|
|
309
323
|
const text = extractText(lastUser.content);
|
|
310
324
|
if (!text) return assembly;
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return injectRecall(assembly, cached.result);
|
|
325
|
+
const cached = recallCache.get(session.id, text);
|
|
326
|
+
if (cached !== undefined) {
|
|
327
|
+
return injectRecall(assembly, cached);
|
|
315
328
|
}
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
recallCache.set(session.id,
|
|
329
|
+
const recallCore = core;
|
|
330
|
+
const result = await recallWithTimeout(
|
|
331
|
+
() => recallCore.handleBeforeRecall(text, session.id), tdaiConfig.recall.timeoutMs + 1000,
|
|
332
|
+
);
|
|
333
|
+
if (core === recallCore) recallCache.set(session.id, text, result);
|
|
321
334
|
return injectRecall(assembly, result ?? {});
|
|
322
335
|
} catch (error) {
|
|
323
336
|
ctx.logger.warn(`[tdai-memory] recall failed: ${String(error)}`);
|
|
@@ -339,69 +352,137 @@ function apply(ctx, config) {
|
|
|
339
352
|
}
|
|
340
353
|
|
|
341
354
|
// ── tools ────────────────────────────────────────────────────────────────
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
355
|
+
// `toolsEnabled` defaults to true (schema `.default(true)`), so only an
|
|
356
|
+
// explicit `false` turns the two search tools off. Read through
|
|
357
|
+
// `runtimeConfigOf` — the volatile row config has no plain properties (#3).
|
|
358
|
+
let toolsRegistered = false;
|
|
359
|
+
let toolsDeferred = false;
|
|
360
|
+
let toolsDisposers = [];
|
|
361
|
+
|
|
362
|
+
/** The core, or a clear error while the first build is still in flight. */
|
|
363
|
+
function requireCore() {
|
|
364
|
+
if (!core) throw new Error("tdai-memory is still initializing; retry this call in a moment");
|
|
365
|
+
return core;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function registerTools(tools) {
|
|
369
|
+
if (toolsRegistered) return;
|
|
370
|
+
if (typeof tools?.register !== "function") {
|
|
371
|
+
ctx.logger.warn("[tdai-memory] tools service does not expose register(); search tools stay unavailable");
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
toolsRegistered = true;
|
|
375
|
+
toolsDisposers = [
|
|
376
|
+
tools.register(defineTool({
|
|
377
|
+
name: "tdai_memory_search",
|
|
378
|
+
description:
|
|
379
|
+
"Search structured long-term memories (facts, preferences, and experiences extracted from past conversations). " +
|
|
380
|
+
"Use this to recall what the user told you or what happened in earlier sessions.",
|
|
381
|
+
parameters: {
|
|
382
|
+
query: { type: "string", required: true, description: "The memory to search for." },
|
|
383
|
+
limit: { type: "integer", description: "Maximum results (default 5)." },
|
|
384
|
+
},
|
|
385
|
+
output: {
|
|
386
|
+
schema: { type: "string" },
|
|
387
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
388
|
+
},
|
|
389
|
+
async execute(args) {
|
|
390
|
+
const result = await requireCore().searchMemories({ query: args.query, limit: args.limit ?? 5 });
|
|
391
|
+
return result.text;
|
|
392
|
+
},
|
|
393
|
+
})),
|
|
394
|
+
tools.register(defineTool({
|
|
395
|
+
name: "tdai_conversation_search",
|
|
396
|
+
description:
|
|
397
|
+
"Search raw past conversation logs (L0) by keyword. Use this to look up exact quotes or details " +
|
|
398
|
+
"that structured memory search does not cover.",
|
|
399
|
+
parameters: {
|
|
400
|
+
query: { type: "string", required: true, description: "Text to find in past conversations." },
|
|
401
|
+
limit: { type: "integer", description: "Maximum results (default 5)." },
|
|
402
|
+
},
|
|
403
|
+
output: {
|
|
404
|
+
schema: { type: "string" },
|
|
405
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
406
|
+
},
|
|
407
|
+
async execute(args) {
|
|
408
|
+
const result = await requireCore().searchConversations({ query: args.query, limit: args.limit ?? 5 });
|
|
409
|
+
return result.text;
|
|
410
|
+
},
|
|
411
|
+
})),
|
|
412
|
+
];
|
|
413
|
+
ctx.logger.info("[tdai-memory] tools registered: tdai_memory_search, tdai_conversation_search");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function unregisterTools() {
|
|
417
|
+
for (const dispose of toolsDisposers.splice(0)) {
|
|
418
|
+
try {
|
|
419
|
+
dispose();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
ctx.logger.warn(`[tdai-memory] tool dispose failed: ${String(error)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
toolsRegistered = false;
|
|
425
|
+
ctx.logger.info("[tdai-memory] tools unregistered (toolsEnabled=false)");
|
|
379
426
|
}
|
|
380
427
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
428
|
+
/** Align tool registration with the live `toolsEnabled` value. */
|
|
429
|
+
function syncTools() {
|
|
430
|
+
if (runtimeConfigOf(config)?.toolsEnabled === false) {
|
|
431
|
+
if (toolsRegistered) unregisterTools();
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (toolsRegistered || toolsDeferred) return;
|
|
435
|
+
// `tools` is in this plugin's `inject`, so the service is normally live
|
|
436
|
+
// here; `ctx.inject` is the fallback for hosts that mount it later. The old
|
|
437
|
+
// one-shot `ctx.tools` property read registered nothing and said nothing.
|
|
438
|
+
const tools = ctx.tools ?? ctx.get?.("tools");
|
|
439
|
+
if (tools === undefined) {
|
|
440
|
+
toolsDeferred = true;
|
|
441
|
+
ctx.logger.warn("[tdai-memory] tools service not ready; deferring search-tool registration");
|
|
442
|
+
ctx.inject(["tools"], (toolCtx) => {
|
|
443
|
+
toolsDeferred = false;
|
|
444
|
+
const deferred = toolCtx.tools ?? toolCtx.get?.("tools");
|
|
445
|
+
if (deferred === undefined) {
|
|
446
|
+
ctx.logger.warn("[tdai-memory] tools service still unavailable; search tools stay unavailable");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
registerTools(deferred);
|
|
450
|
+
});
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
registerTools(tools);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// `toolsEnabled` is a volatile config path, so flipping it (Plugins page)
|
|
457
|
+
// updates the running fiber in place instead of remounting this plugin: the
|
|
458
|
+
// loader reports that as `loader/volatile-update`, and this keeps the
|
|
459
|
+
// registration in sync with the new value.
|
|
460
|
+
ctx.on("loader/volatile-update", () => {
|
|
461
|
+
try {
|
|
462
|
+
syncTools();
|
|
463
|
+
} catch (error) {
|
|
464
|
+
ctx.logger.warn(`[tdai-memory] tool sync failed: ${String(error)}`);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
syncTools();
|
|
469
|
+
|
|
470
|
+
// ── configuration changes ─────────────────────────────────────────────────
|
|
471
|
+
// Configuration lives in the Plugins page (client.js → configForms), i.e. in
|
|
472
|
+
// this row's own config. Because the row schema is `.volatile()`, an edit is
|
|
473
|
+
// applied to the running fiber in place — the loader reports it as
|
|
474
|
+
// `loader/volatile-update` (the tools above re-sync on it) — while TdaiCore
|
|
475
|
+
// is built at startup, so an edit that must reach the core needs a plugin
|
|
476
|
+
// reload. Legacy settings documents are only reported here.
|
|
477
|
+
ctx.on("settings/document-updated", (id) => {
|
|
478
|
+
if (id === NS) ctx.logger.warn("[tdai-memory] settings updated; restart to apply (TdaiCore is built at startup)");
|
|
479
|
+
});
|
|
396
480
|
|
|
397
|
-
//
|
|
398
|
-
// config. One-shot — if the settings mount arrives later, its build
|
|
399
|
-
// supersedes this one. Delayed past the 0ms window so headless/web boots
|
|
400
|
-
// are never blocked on this timer.
|
|
481
|
+
// Deferred first build (see the note at the top of apply).
|
|
401
482
|
setTimeout(() => {
|
|
402
483
|
if (!builtOnce) {
|
|
403
484
|
builtOnce = true;
|
|
404
|
-
void build(
|
|
485
|
+
void build(runtimeConfigOf(config));
|
|
405
486
|
}
|
|
406
487
|
}, 500);
|
|
407
488
|
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,97 +1,100 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-tdai-memory",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "DeepSeek Harness 的 TencentDB Agent Memory 移植:L0 对话捕获 → L1 结构化记忆提取 → L2 场景/L3 画像,自动召回注入 + 记忆/对话搜索工具;复用现有 ~/.memory-tencentdb/memory-tdai 数据;附 Web UI 设置栏。",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./index.js",
|
|
9
|
-
"./client": "./client.js",
|
|
10
|
-
"./package.json": "./package.json"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
"0.1.5-rc.
|
|
24
|
-
"0.1.
|
|
25
|
-
"0.1.6-alpha.
|
|
26
|
-
"0.1.
|
|
27
|
-
"0.1.7-alpha.
|
|
28
|
-
"0.1.7-
|
|
29
|
-
"0.1.7-rc.
|
|
30
|
-
"0.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
"url": "https://github.com/Scorp1o117/dsh-tdai-memory
|
|
72
|
-
},
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"@
|
|
78
|
-
"ai": "^
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"node-llama-cpp":
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-tdai-memory",
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "DeepSeek Harness 的 TencentDB Agent Memory 移植:L0 对话捕获 → L1 结构化记忆提取 → L2 场景/L3 画像,自动召回注入 + 记忆/对话搜索工具;复用现有 ~/.memory-tencentdb/memory-tdai 数据;附 Web UI 设置栏。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json"
|
|
12
|
+
},
|
|
13
|
+
"dsh": {
|
|
14
|
+
"bundle": {
|
|
15
|
+
"patch": "./cordis.patch.yml"
|
|
16
|
+
},
|
|
17
|
+
"client": {
|
|
18
|
+
"platform": "web"
|
|
19
|
+
},
|
|
20
|
+
"compatibility": {
|
|
21
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0",
|
|
22
|
+
"dshReleases": {
|
|
23
|
+
"0.1.5-rc.2": "incompatible",
|
|
24
|
+
"0.1.5-rc.3": "incompatible",
|
|
25
|
+
"0.1.6-alpha.1": "unknown",
|
|
26
|
+
"0.1.6-alpha.2": "unknown",
|
|
27
|
+
"0.1.7-alpha.1": "unknown",
|
|
28
|
+
"0.1.7-alpha.2": "unknown",
|
|
29
|
+
"0.1.7-rc.1": "incompatible",
|
|
30
|
+
"0.1.7-rc.2": "incompatible",
|
|
31
|
+
"0.2.0-rc.2": "compatible"
|
|
32
|
+
},
|
|
33
|
+
"node": ">=22.18",
|
|
34
|
+
"profiles": [
|
|
35
|
+
"web",
|
|
36
|
+
"desktop"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/Scorp1o117/dsh-tdai-memory",
|
|
41
|
+
"files": [
|
|
42
|
+
"locale",
|
|
43
|
+
"index.js",
|
|
44
|
+
"recall-inject.js",
|
|
45
|
+
"recall-runtime.js",
|
|
46
|
+
"client.js",
|
|
47
|
+
"cordis.patch.yml",
|
|
48
|
+
"vendor",
|
|
49
|
+
"README.md",
|
|
50
|
+
"README.zh.md",
|
|
51
|
+
"LICENSE",
|
|
52
|
+
"THIRD-PARTY-NOTICES.md"
|
|
53
|
+
],
|
|
54
|
+
"keywords": [
|
|
55
|
+
"dsh",
|
|
56
|
+
"dsh-plugin",
|
|
57
|
+
"deepseek-harness",
|
|
58
|
+
"memory",
|
|
59
|
+
"tencentdb",
|
|
60
|
+
"agent-memory",
|
|
61
|
+
"rag",
|
|
62
|
+
"vector-search",
|
|
63
|
+
"persona"
|
|
64
|
+
],
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=22.18"
|
|
68
|
+
},
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/Scorp1o117/dsh-tdai-memory.git"
|
|
72
|
+
},
|
|
73
|
+
"bugs": {
|
|
74
|
+
"url": "https://github.com/Scorp1o117/dsh-tdai-memory/issues"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@ai-sdk/openai": "^3.0.53",
|
|
78
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
79
|
+
"@node-rs/jieba": "^2.0.1",
|
|
80
|
+
"@tencentdb-agent-memory/tcvdb-text": "^0.1.1",
|
|
81
|
+
"ai": "^6.0.164",
|
|
82
|
+
"json5": "^2.2.3",
|
|
83
|
+
"sqlite-vec": "0.1.7-alpha.2",
|
|
84
|
+
"undici": "^8.1.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependencies": {
|
|
87
|
+
"react": "^18.2.0",
|
|
88
|
+
"@deepseek-ai/dsh-tools": ">=0.2.0-rc.2 <0.3.0",
|
|
89
|
+
"@deepseek-ai/dsh-settings": ">=0.2.0-rc.2 <0.3.0",
|
|
90
|
+
"node-llama-cpp": "^3.19.1"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"node-llama-cpp": {
|
|
94
|
+
"optional": true
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"scripts": {
|
|
98
|
+
"test": "node tests/settings-scope-compat.test.mjs && node tests/install-contract.test.mjs && node --test tests/plugin-page.test.mjs tests/client-write-flow.test.mjs tests/tools-registration.test.mjs tests/recall-runtime.test.mjs && node --test tests/locale.test.mjs"
|
|
99
|
+
}
|
|
100
|
+
}
|
package/recall-inject.js
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* - tdai:recall — relevant L1 memories for the current user message
|
|
12
12
|
* - tdai:profile — stable persona / scene navigation context
|
|
13
13
|
*
|
|
14
|
-
* The recall call is bounded by `
|
|
14
|
+
* The recall call is bounded by this row's `timeoutMs`; failures degrade to no
|
|
15
15
|
* injection and never break the assembly.
|
|
16
16
|
*/
|
|
17
17
|
import z from "@deepseek-ai/schemastery";
|
|
18
|
+
import { createRecallCache, recallWithTimeout } from "./recall-runtime.js";
|
|
18
19
|
|
|
19
20
|
/** Cordis plugin name. */
|
|
20
21
|
const name = "tdai-recall-inject";
|
|
@@ -27,10 +28,12 @@ const Config = z.object({
|
|
|
27
28
|
minUserTextChars: z.number().default(1),
|
|
28
29
|
/** Per-session cache TTL for identical user text (ms). */
|
|
29
30
|
cacheTtlMs: z.number().default(30_000),
|
|
31
|
+
/** Assembly deadline even when the memory service does not settle. */
|
|
32
|
+
timeoutMs: z.number().default(4000),
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
function apply(ctx, config) {
|
|
33
|
-
const cache =
|
|
36
|
+
const cache = createRecallCache(config.cacheTtlMs ?? 30_000);
|
|
34
37
|
|
|
35
38
|
ctx.on("system-prompt/assemble", async (assembly, context) => {
|
|
36
39
|
try {
|
|
@@ -49,15 +52,14 @@ function apply(ctx, config) {
|
|
|
49
52
|
const text = extractText(lastUser.content);
|
|
50
53
|
if (!text || text.length < config.minUserTextChars) return assembly;
|
|
51
54
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return injectRecall(assembly, cached.result);
|
|
55
|
+
const cached = cache.get(session.id, text);
|
|
56
|
+
if (cached !== undefined) {
|
|
57
|
+
return injectRecall(assembly, cached);
|
|
56
58
|
}
|
|
57
|
-
const result = await
|
|
58
|
-
.handleBeforeRecall(text, session.id)
|
|
59
|
-
|
|
60
|
-
cache.set(session.id,
|
|
59
|
+
const result = await recallWithTimeout(
|
|
60
|
+
() => ctx.tdaiMemory.handleBeforeRecall(text, session.id), config.timeoutMs,
|
|
61
|
+
);
|
|
62
|
+
cache.set(session.id, text, result);
|
|
61
63
|
return injectRecall(assembly, result ?? {});
|
|
62
64
|
} catch {
|
|
63
65
|
return assembly;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Bound recall latency without leaving a timer behind after a fast result.
|
|
2
|
+
* The underlying operation may continue; late failures remain handled. */
|
|
3
|
+
export async function recallWithTimeout(operation, timeoutMs = 4000) {
|
|
4
|
+
const delay = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 4000;
|
|
5
|
+
let timer;
|
|
6
|
+
try {
|
|
7
|
+
return await Promise.race([
|
|
8
|
+
Promise.resolve().then(operation),
|
|
9
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(null), delay); }),
|
|
10
|
+
]);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
} finally {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expiring, capacity-limited per-session cache for long-running hosts. */
|
|
19
|
+
export function createRecallCache(ttlMs = 30_000, capacity = 128, now = Date.now) {
|
|
20
|
+
const entries = new Map();
|
|
21
|
+
function prune() {
|
|
22
|
+
const time = now();
|
|
23
|
+
for (const [key, entry] of entries) {
|
|
24
|
+
if (time - entry.ts >= ttlMs) entries.delete(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
get(sessionId, text) {
|
|
29
|
+
prune();
|
|
30
|
+
const entry = entries.get(sessionId);
|
|
31
|
+
return entry?.text === text ? entry.result : undefined;
|
|
32
|
+
},
|
|
33
|
+
set(sessionId, text, result) {
|
|
34
|
+
prune();
|
|
35
|
+
if (ttlMs <= 0 || capacity <= 0 || result == null) return;
|
|
36
|
+
entries.delete(sessionId);
|
|
37
|
+
entries.set(sessionId, { text, result, ts: now() });
|
|
38
|
+
while (entries.size > capacity) entries.delete(entries.keys().next().value);
|
|
39
|
+
},
|
|
40
|
+
clear() { entries.clear(); },
|
|
41
|
+
};
|
|
42
|
+
}
|