@modusensus/dsh-mneme 0.1.4 → 0.1.6

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
4
4
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
5
  [![dsh-plugin](https://img.shields.io/badge/dsh-plugin-awesome-orange)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
6
- [![tests](https://img.shields.io/badge/tests-129%20passed-success)](https://github.com/modusensus/dsh-mneme)
6
+ [![tests](https://img.shields.io/badge/tests-136%20passed-success)](https://github.com/modusensus/dsh-mneme)
7
7
 
8
8
  > 给 DeepSeek Harness 的跨会话记忆插件:让 Agent 记住你、记住项目、自动整理记忆。**Mneme**(Μνήμη)——希腊记忆女神 Mnemosyne 之名,掌管记忆与梦境,正如 autoDream 在后台巩固记忆。
9
9
 
@@ -22,7 +22,7 @@
22
22
  | 工具 | 功能 |
23
23
  |------|------|
24
24
  | `memory_save` | 记录一条记忆(自动按标题去重合并) |
25
- | `memory_search` | 全文搜索(中文子串友好) |
25
+ | `memory_search` | 全文搜索(中文子串友好,可启用向量语义搜索) |
26
26
  | `memory_list` | 按类型分页列出 |
27
27
  | `memory_update` | 修改已有记忆 |
28
28
  | `memory_delete` | 删除记忆 |
@@ -45,11 +45,11 @@
45
45
 
46
46
  ### Web 记忆面板
47
47
 
48
- 侧边栏"记忆"按钮模态面板:按类型浏览、全文搜索、查看详情。
48
+ 官方设置面板「记忆库设置」→「记忆」标签:按类型浏览、全文搜索;启用向量搜索后可用「语义」切换做向量召回。
49
49
 
50
50
  ### 用户设置(画像 / 规则)与自定义指令 ⚙️
51
51
 
52
- 侧边栏"设置"按钮设置面板:
52
+ 官方设置面板「记忆库设置」标签:
53
53
 
54
54
  - **用户画像**:一段自由文本描述用户自己(角色、背景、偏好),**每轮注入**到系统提示,让 Agent 始终遵循
55
55
  - **规则**:Agent 必须遵守的行为规则列表(如"回答先给结论"),同样每轮注入
@@ -57,6 +57,24 @@
57
57
 
58
58
  > 画像与规则通过独立的 `[用户设置]` 注入区块(优先级高于记忆库),即使记忆为空也会注入。
59
59
 
60
+ ### 向量搜索(语义搜索)🔎
61
+
62
+ 可选能力:接入 OpenAI 兼容的 embeddings API,让搜索能命中**字面不同但语义相近**的记忆。
63
+
64
+ **配置**:官方设置 → 「记忆库设置」→ 滚动到底部「向量搜索」区块:
65
+
66
+ | 字段 | 说明 |
67
+ |------|------|
68
+ | `启用向量搜索` | 总开关;开启后记忆面板出现「语义」切换 |
69
+ | `API 地址 (Base URL)` | OpenAI 兼容端点,如 `https://api.openai.com/v1`;也支持 SiliconFlow、智谱、本地 Ollama 等 |
70
+ | `API Key` | 对应服务的密钥 |
71
+ | `模型名` | embedding 模型,如 `text-embedding-3-small`、`text-embedding-v3`、`bge-m3` 等 |
72
+
73
+ 保存配置后点「重建索引」,为已有记忆批量补建向量(新写入的记忆会自动嵌入)。之后在记忆面板输入查询并点「语义」,即可用向量召回语义相关结果;向量服务不可用时自动回退全文搜索。
74
+
75
+ > ⚠️ 密钥仅保存在本机 `~/.dsh/memory/memory.db` 的 `user_settings` 表,不会上传,也不会写入代码仓库。
76
+ > 需要 embedding 而非 rerank 模型:如阿里云 `text-embedding-v3` 可用,`qwen3-vl-rerank` 是 rerank 模型(不走 `/embeddings`)。
77
+
60
78
  ## 📦 安装
61
79
 
62
80
  ### 前置条件
@@ -138,7 +156,7 @@ dsh web
138
156
  │ autoDream:阈值调度 → LLM 决策清单 │
139
157
  │ → 校验(fail-safe)→ 应用 → 摘要 │
140
158
  ├─────────────────────────────────────────────────┤
141
- │ Web 面板:侧边栏入口 + 浏览/搜索/详情
159
+ │ Web 面板:设置面板内嵌 + 浏览/搜索(含向量)
142
160
  └─────────────────────────────────────────────────┘
143
161
  ```
144
162
 
@@ -155,12 +173,13 @@ src/
155
173
  ├── summarize.js # 会话结束 LLM 摘要
156
174
  ├── dream.js # autoDream 调度 + runDream(LLM 决策 + 摘要)
157
175
  ├── dream/decisions.js# 决策校验(fail-safe)+ 决策应用
176
+ ├── embedding.js # OpenAI 兼容 embeddings 客户端 + 向量检索
158
177
  ├── api.js # HTTP 路由(Web 面板数据通道)
159
178
  └── index.js # 插件接线
160
179
  lib/
161
180
  ├── client.js # Web 面板(手写 ModuleLoader bundle)
162
181
  └── *.js # src 的同步分发产物
163
- test/ # 129 个 node:test 测试
182
+ test/ # 136 个 node:test 测试
164
183
  ```
165
184
 
166
185
  ## 🧪 开发
@@ -168,7 +187,7 @@ test/ # 129 个 node:test 测试
168
187
  ```bash
169
188
  cd dsh-mneme
170
189
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
171
- npm test # 运行 129 个测试(--test-isolation=none 用于受限沙箱,禁止子进程 spawn)
190
+ npm test # 运行 136 个测试(--test-isolation=none 用于受限沙箱,禁止子进程 spawn)
172
191
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
173
192
  ```
174
193
 
package/lib/api.js CHANGED
@@ -23,7 +23,7 @@ function parseBody(text) {
23
23
  }
24
24
  }
25
25
 
26
- export function createApi(ctx, service, settings, commands) {
26
+ export function createApi(ctx, service, settings, commands, embedder) {
27
27
  const disposers = [];
28
28
 
29
29
  const register = (route) => {
@@ -64,8 +64,49 @@ export function createApi(ctx, service, settings, commands) {
64
64
  const url = new URL(req.url, "http://localhost");
65
65
  const q = url.searchParams.get("q") ?? "";
66
66
  const limit = Number(url.searchParams.get("limit") ?? 20);
67
- const items = service.toApiList(service.search(q, { limit }));
68
- sendJson(res, 200, { items });
67
+ // mode: auto (default) | keyword | vector
68
+ const mode = url.searchParams.get("mode") ?? "auto";
69
+ const query = q.trim();
70
+ if (!query) {
71
+ sendJson(res, 200, { items: [], mode: "keyword" });
72
+ return;
73
+ }
74
+ // Keyword results (existing behavior) always computed; used as a
75
+ // fallback and as the primary ranking when vector is unavailable.
76
+ const keyword = service.toApiList(service.search(query, { limit }));
77
+ if (mode === "keyword" || !embedder) {
78
+ sendJson(res, 200, { items: keyword, mode: "keyword" });
79
+ return;
80
+ }
81
+ const cfg = settings.getVectorConfig();
82
+ if (mode === "vector" && !cfg?.enabled) {
83
+ sendJson(res, 200, { items: keyword, mode: "keyword", error: "vector-disabled" });
84
+ return;
85
+ }
86
+ // Try vector search; on any failure fall back to keyword results.
87
+ return embedder.embed(query).then(async (vector) => {
88
+ let items = keyword;
89
+ let used = "keyword";
90
+ if (vector) {
91
+ const scored = service.toApiList(service.searchVector(vector, { limit }));
92
+ // Merge: keyword exact hits first (they are the user's literal
93
+ // words), then vector results fill the remaining slots, deduped.
94
+ const seen = new Set(keyword.map((m) => m.id));
95
+ const merged = [...keyword];
96
+ for (const m of scored) {
97
+ if (merged.length >= limit) break;
98
+ if (!seen.has(m.id)) {
99
+ seen.add(m.id);
100
+ merged.push(m);
101
+ }
102
+ }
103
+ items = merged;
104
+ used = "vector";
105
+ }
106
+ sendJson(res, 200, { items, mode: used });
107
+ }).catch(() => {
108
+ sendJson(res, 200, { items: keyword, mode: "keyword" });
109
+ });
69
110
  } catch {
70
111
  sendJson(res, 500, { error: "internal" });
71
112
  }
@@ -112,6 +153,54 @@ export function createApi(ctx, service, settings, commands) {
112
153
  }
113
154
  });
114
155
 
156
+ // --- vector search config ---
157
+ register({
158
+ kind: "exact",
159
+ path: "/api/dsh-mneme/vector-config",
160
+ handler(req, res) {
161
+ try {
162
+ if (req.method === "PUT" || req.method === "POST") {
163
+ return readBody(req).then((text) => {
164
+ const body = parseBody(text);
165
+ const cfg = settings.setVectorConfig({
166
+ enabled: body.enabled,
167
+ baseUrl: body.baseUrl,
168
+ apiKey: body.apiKey,
169
+ model: body.model
170
+ });
171
+ sendJson(res, 200, { config: cfg });
172
+ });
173
+ }
174
+ sendJson(res, 200, { config: settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" } });
175
+ } catch {
176
+ sendJson(res, 500, { error: "internal" });
177
+ }
178
+ }
179
+ });
180
+
181
+ // --- vector re-index (backfill embeddings for rows missing them) ---
182
+ register({
183
+ kind: "exact",
184
+ path: "/api/dsh-mneme/vector-reindex",
185
+ handler(req, res) {
186
+ try {
187
+ if (!embedder) {
188
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
189
+ return;
190
+ }
191
+ const url = new URL(req.url, "http://localhost");
192
+ const limit = Number(url.searchParams.get("limit") ?? 100);
193
+ embedder.reindexMissing(limit).then((result) => {
194
+ sendJson(res, 200, result);
195
+ }).catch(() => {
196
+ sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
197
+ });
198
+ } catch {
199
+ sendJson(res, 500, { error: "internal" });
200
+ }
201
+ }
202
+ });
203
+
115
204
  // --- custom commands ---
116
205
  register({
117
206
  kind: "exact",
package/lib/client.js CHANGED
@@ -41,7 +41,19 @@ window.__ModuleLoader__.load({
41
41
  "memory.settings.cmdInstruction": "指令内容",
42
42
  "memory.settings.cmdAdd": "添加命令",
43
43
  "memory.settings.cmdDelete": "删除",
44
- "memory.settings.empty": "暂无内容"
44
+ "memory.settings.empty": "暂无内容",
45
+ "memory.panel.semantic": "语义",
46
+ "memory.settings.vectorTitle": "向量搜索",
47
+ "memory.settings.vectorHint": "接入 OpenAI 兼容的 embeddings API 做语义搜索,可匹配字面不同但语义相近的记忆",
48
+ "memory.settings.vectorEnabled": "启用向量搜索",
49
+ "memory.settings.vectorBaseUrl": "API 地址 (Base URL)",
50
+ "memory.settings.vectorApiKey": "API Key",
51
+ "memory.settings.vectorModel": "模型名",
52
+ "memory.settings.vectorSave": "保存配置",
53
+ "memory.settings.vectorSaved": "配置已保存",
54
+ "memory.settings.vectorReindex": "重建索引",
55
+ "memory.settings.vectorReindexing": "索引中…",
56
+ "memory.settings.vectorReindexDone": "已索引 {n} 条"
45
57
  },
46
58
  en: {
47
59
  "memory.panel.title": "Memory",
@@ -70,7 +82,19 @@ window.__ModuleLoader__.load({
70
82
  "memory.settings.cmdInstruction": "Instruction",
71
83
  "memory.settings.cmdAdd": "Add Command",
72
84
  "memory.settings.cmdDelete": "Delete",
73
- "memory.settings.empty": "Nothing yet"
85
+ "memory.settings.empty": "Nothing yet",
86
+ "memory.panel.semantic": "Semantic",
87
+ "memory.settings.vectorTitle": "Vector Search",
88
+ "memory.settings.vectorHint": "Connect an OpenAI-compatible embeddings API for semantic search by meaning, not just keywords",
89
+ "memory.settings.vectorEnabled": "Enable vector search",
90
+ "memory.settings.vectorBaseUrl": "Base URL",
91
+ "memory.settings.vectorApiKey": "API Key",
92
+ "memory.settings.vectorModel": "Model",
93
+ "memory.settings.vectorSave": "Save Config",
94
+ "memory.settings.vectorSaved": "Config saved",
95
+ "memory.settings.vectorReindex": "Reindex",
96
+ "memory.settings.vectorReindexing": "Indexing…",
97
+ "memory.settings.vectorReindexDone": "Indexed {n} items"
74
98
  }
75
99
  };
76
100
 
@@ -86,13 +110,22 @@ window.__ModuleLoader__.load({
86
110
  return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
87
111
  }
88
112
 
89
- function MemoryPanel({ t, onClose }) {
113
+ function MemoryPanel({ t, onClose, embedded }) {
90
114
  const [tab, setTab] = useState("all");
91
115
  const [query, setQuery] = useState("");
116
+ const [semantic, setSemantic] = useState(false);
117
+ const [vecEnabled, setVecEnabled] = useState(false);
92
118
  const [items, setItems] = useState([]);
93
119
  const [loading, setLoading] = useState(false);
94
120
  const abortRef = useRef(null);
95
121
 
122
+ useEffect(() => {
123
+ fetch("/api/dsh-mneme/vector-config")
124
+ .then((res) => res.json())
125
+ .then((d) => setVecEnabled(!!d.config?.enabled))
126
+ .catch(() => {});
127
+ }, []);
128
+
96
129
  const load = useCallback(async () => {
97
130
  abortRef.current?.abort();
98
131
  const controller = new AbortController();
@@ -102,7 +135,7 @@ window.__ModuleLoader__.load({
102
135
  const params = new URLSearchParams();
103
136
  if (tab !== "all") params.set("type", tab);
104
137
  const url = query.trim()
105
- ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}`
138
+ ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}&mode=${semantic ? "vector" : "auto"}`
106
139
  : `/api/dsh-mneme/list?${params.toString()}`;
107
140
  const res = await fetch(url, { signal: controller.signal });
108
141
  const data = await res.json();
@@ -113,7 +146,7 @@ window.__ModuleLoader__.load({
113
146
  } finally {
114
147
  setLoading(false);
115
148
  }
116
- }, [tab, query]);
149
+ }, [tab, query, semantic]);
117
150
 
118
151
  useEffect(() => {
119
152
  load();
@@ -122,19 +155,24 @@ window.__ModuleLoader__.load({
122
155
 
123
156
  const tabs = ["all", "preference", "project", "decision", "history"];
124
157
 
125
- return createPortal(
126
- react.createElement("div", { style: styles.overlay },
127
- react.createElement("div", { style: styles.panel },
128
- react.createElement("div", { style: styles.header },
129
- react.createElement("span", { style: styles.title }, t("memory.panel.title")),
130
- react.createElement("button", { style: styles.close, onClick: onClose }, "×")
158
+ const body = react.createElement("div", { style: embedded ? { ...styles.panel, width: "100%", maxWidth: "100%", maxHeight: "none" } : styles.panel },
159
+ !embedded && react.createElement("div", { style: styles.header },
160
+ react.createElement("span", { style: styles.title }, t("memory.panel.title")),
161
+ react.createElement("button", { style: styles.close, onClick: onClose }, "×")
162
+ ),
163
+ react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", marginBottom: 12 } },
164
+ react.createElement("input", {
165
+ style: { ...styles.search, flex: 1, marginBottom: 0 },
166
+ placeholder: t("memory.panel.search"),
167
+ value: query,
168
+ onChange: (e) => setQuery(e.target.value)
169
+ }),
170
+ vecEnabled && react.createElement("button", {
171
+ style: { ...styles.tab, ...(semantic ? styles.tabActive : {}) },
172
+ title: t("memory.settings.vectorTitle"),
173
+ onClick: () => setSemantic(!semantic)
174
+ }, t("memory.panel.semantic"))
131
175
  ),
132
- react.createElement("input", {
133
- style: styles.search,
134
- placeholder: t("memory.panel.search"),
135
- value: query,
136
- onChange: (e) => setQuery(e.target.value)
137
- }),
138
176
  react.createElement("div", { style: styles.tabs },
139
177
  tabs.map((key) =>
140
178
  react.createElement("button", {
@@ -164,16 +202,17 @@ window.__ModuleLoader__.load({
164
202
  )
165
203
  )
166
204
  )
167
- )
168
- ),
169
- document.body
170
205
  );
206
+ return embedded
207
+ ? body
208
+ : createPortal(react.createElement("div", { style: styles.overlay }, body), document.body);
171
209
  }
172
210
 
173
211
  const h = react.createElement;
174
212
 
175
213
  // --- Settings panel: user profile, rules, custom commands ---
176
- function SettingsPanel({ t, onClose }) {
214
+ function SettingsPanel({ t, onClose, embedded }) {
215
+ const [sectionTab, setSectionTab] = react.useState("settings");
177
216
  const [profile, setProfile] = react.useState("");
178
217
  const [rules, setRules] = react.useState([]);
179
218
  const [commands, setCommands] = react.useState([]);
@@ -181,17 +220,23 @@ window.__ModuleLoader__.load({
181
220
  const [newCmd, setNewCmd] = react.useState({ name: "", description: "", instruction: "" });
182
221
  const [saved, setSaved] = react.useState(false);
183
222
  const [cmdError, setCmdError] = react.useState("");
223
+ const [vector, setVector] = react.useState({ enabled: false, baseUrl: "", apiKey: "", model: "" });
224
+ const [vectorSaved, setVectorSaved] = react.useState(false);
225
+ const [reindexing, setReindexing] = react.useState(false);
226
+ const [reindexMsg, setReindexMsg] = react.useState("");
184
227
 
185
228
  const load = react.useCallback(async () => {
186
229
  try {
187
- const [p, r, c] = await Promise.all([
230
+ const [p, r, c, v] = await Promise.all([
188
231
  fetch("/api/dsh-mneme/profile").then((res) => res.json()),
189
232
  fetch("/api/dsh-mneme/rules").then((res) => res.json()),
190
- fetch("/api/dsh-mneme/commands").then((res) => res.json())
233
+ fetch("/api/dsh-mneme/commands").then((res) => res.json()),
234
+ fetch("/api/dsh-mneme/vector-config").then((res) => res.json())
191
235
  ]);
192
236
  setProfile(p.profile || "");
193
237
  setRules(Array.isArray(r.rules) ? r.rules : []);
194
238
  setCommands(Array.isArray(c.commands) ? c.commands : []);
239
+ setVector(v.config || { enabled: false, baseUrl: "", apiKey: "", model: "" });
195
240
  } catch { /* ignore */ }
196
241
  }, []);
197
242
 
@@ -255,24 +300,52 @@ window.__ModuleLoader__.load({
255
300
  setCommands(commands.filter((c) => c.id !== id));
256
301
  }
257
302
 
303
+ async function saveVector() {
304
+ try {
305
+ await fetch("/api/dsh-mneme/vector-config", {
306
+ method: "PUT",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(vector)
309
+ });
310
+ setVectorSaved(true);
311
+ setTimeout(() => setVectorSaved(false), 1500);
312
+ } catch { /* ignore */ }
313
+ }
314
+
315
+ async function reindex() {
316
+ setReindexing(true);
317
+ setReindexMsg("");
318
+ try {
319
+ const res = await fetch("/api/dsh-mneme/vector-reindex");
320
+ const data = await res.json();
321
+ const n = data.indexed ?? 0;
322
+ setReindexMsg(t("memory.settings.vectorReindexDone").replace("{n}", String(n)));
323
+ } catch { setReindexMsg(""); }
324
+ setReindexing(false);
325
+ }
326
+
258
327
  const inputStyle = { ...styles.search, marginBottom: 8 };
259
328
  const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px" };
260
329
  const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
261
330
  const rowStyle = { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l1, #eee)" };
262
331
 
263
- return createPortal(
264
- h("div", { style: styles.overlay },
265
- h("div", { style: styles.panel },
266
- h("div", { style: styles.header },
267
- h("span", { style: styles.title }, t("memory.settings.title")),
268
- h("button", { style: styles.close, onClick: onClose }, "×")
269
- ),
270
- h("div", { style: { overflowY: "auto" } },
332
+ const body = h("div", { style: embedded ? { ...styles.panel, width: "100%", maxWidth: "100%", maxHeight: "none" } : styles.panel },
333
+ !embedded && h("div", { style: styles.header },
334
+ h("span", { style: styles.title }, t("memory.settings.title")),
335
+ h("button", { style: styles.close, onClick: onClose }, "×")
336
+ ),
337
+ h("div", { style: styles.tabs },
338
+ h("button", { style: { ...styles.tab, ...(sectionTab === "memory" ? styles.tabActive : {}) }, onClick: () => setSectionTab("memory") }, t("memory.panel.open")),
339
+ h("button", { style: { ...styles.tab, ...(sectionTab === "settings" ? styles.tabActive : {}) }, onClick: () => setSectionTab("settings") }, t("memory.settings.title"))
340
+ ),
341
+ sectionTab === "memory"
342
+ ? h(MemoryPanel, { t, embedded: true })
343
+ : h("div", { style: { overflowY: "auto" } },
271
344
  // profile
272
345
  h("div", { style: labelStyle }, t("memory.settings.profile")),
273
346
  h("div", { style: hintStyle }, t("memory.settings.profileHint")),
274
347
  h("textarea", {
275
- style: { ...inputStyle, minHeight: 72, resize: "vertical", fontFamily: "inherit" },
348
+ style: { ...inputStyle, minHeight: 72, resize: "both", fontFamily: "inherit" },
276
349
  value: profile,
277
350
  placeholder: t("memory.settings.profile"),
278
351
  onChange: (e) => setProfile(e.target.value)
@@ -316,15 +389,31 @@ window.__ModuleLoader__.load({
316
389
  h("div", { style: { display: "grid", gap: 6, marginTop: 6 } },
317
390
  h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.name, placeholder: t("memory.settings.cmdName"), onChange: (e) => setNewCmd({ ...newCmd, name: e.target.value }) }),
318
391
  h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.description, placeholder: t("memory.settings.cmdDesc"), onChange: (e) => setNewCmd({ ...newCmd, description: e.target.value }) }),
319
- h("textarea", { style: { ...inputStyle, marginBottom: 0, minHeight: 48, resize: "vertical", fontFamily: "inherit" }, value: newCmd.instruction, placeholder: t("memory.settings.cmdInstruction"), onChange: (e) => setNewCmd({ ...newCmd, instruction: e.target.value }) }),
392
+ h("textarea", { style: { ...inputStyle, marginBottom: 0, minHeight: 48, resize: "both", fontFamily: "inherit" }, value: newCmd.instruction, placeholder: t("memory.settings.cmdInstruction"), onChange: (e) => setNewCmd({ ...newCmd, instruction: e.target.value }) }),
320
393
  h("button", { style: styles.footerButton, onClick: addCommand }, t("memory.settings.cmdAdd")),
321
394
  cmdError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error, #c33)" } }, cmdError)
395
+ ),
396
+ // vector search
397
+ h("div", { style: { ...labelStyle, marginTop: 20 } }, t("memory.settings.vectorTitle")),
398
+ h("div", { style: hintStyle }, t("memory.settings.vectorHint")),
399
+ h("label", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 8, fontSize: 13 } },
400
+ h("input", { type: "checkbox", checked: !!vector.enabled, onChange: (e) => setVector({ ...vector, enabled: e.target.checked }) }),
401
+ h("span", null, t("memory.settings.vectorEnabled"))
402
+ ),
403
+ h("input", { style: inputStyle, value: vector.baseUrl, placeholder: t("memory.settings.vectorBaseUrl"), onChange: (e) => setVector({ ...vector, baseUrl: e.target.value }) }),
404
+ h("input", { style: inputStyle, type: "password", value: vector.apiKey, placeholder: t("memory.settings.vectorApiKey"), onChange: (e) => setVector({ ...vector, apiKey: e.target.value }) }),
405
+ h("input", { style: inputStyle, value: vector.model, placeholder: t("memory.settings.vectorModel"), onChange: (e) => setVector({ ...vector, model: e.target.value }) }),
406
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" } },
407
+ h("button", { style: styles.footerButton, onClick: saveVector }, t("memory.settings.vectorSave")),
408
+ vectorSaved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.vectorSaved")),
409
+ h("button", { style: styles.footerButton, onClick: reindex, disabled: reindexing }, reindexing ? t("memory.settings.vectorReindexing") : t("memory.settings.vectorReindex")),
410
+ reindexMsg && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #666)" } }, reindexMsg)
322
411
  )
323
412
  )
324
- )
325
- ),
326
- document.body
327
413
  );
414
+ return embedded
415
+ ? body
416
+ : createPortal(h("div", { style: styles.overlay }, body), document.body);
328
417
  }
329
418
 
330
419
  const styles = {
@@ -351,32 +440,18 @@ window.__ModuleLoader__.load({
351
440
  function apply(ctx) {
352
441
  ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
353
442
 
443
+ // Register a custom section inside the DSH settings panel (nav label +
444
+ // embedded memory/settings content). Access is via the official settings
445
+ // panel only; no sidebar buttons are registered.
354
446
  ctx.effect(() => {
355
447
  const t = ctx.locale.bind(NS);
356
- return ctx.slots.inject("sidebar.footer.action", () =>
357
- ctx.slots.register({
358
- name: "sidebar.footer.action",
359
- id: "memory",
360
- locale: NS,
361
- inject: () => ({})
362
- }, () => {
363
- const [open, setOpen] = react.useState(false);
364
- const [openSettings, setOpenSettings] = react.useState(false);
365
- return react.createElement(react.Fragment, null,
366
- react.createElement("button", {
367
- onClick: () => setOpen(true),
368
- style: { ...styles.footerButton, ...(open ? styles.footerButtonActive : {}) }
369
- }, t("memory.panel.open")),
370
- react.createElement("button", {
371
- onClick: () => setOpenSettings(true),
372
- style: { ...styles.footerButton, ...(openSettings ? styles.footerButtonActive : {}) }
373
- }, t("memory.settings.open")),
374
- open && react.createElement(MemoryPanel, { t, onClose: () => setOpen(false) }),
375
- openSettings && react.createElement(SettingsPanel, { t, onClose: () => setOpenSettings(false) })
376
- );
377
- })
378
- );
379
- }, "dsh-mneme: sidebar action");
448
+ return ctx.slots.register({
449
+ name: "settings.section",
450
+ id: "dsh-mneme",
451
+ label: () => t("memory.settings.title"),
452
+ inject: () => ({})
453
+ }, () => react.createElement(SettingsPanel, { t, embedded: true, onClose: () => {} }));
454
+ }, "dsh-mneme: settings section");
380
455
  }
381
456
 
382
457
  exports.apply = apply;
@@ -0,0 +1,97 @@
1
+ // OpenAI-compatible embedding client for vector search. DSH's LLM service is
2
+ // chat-only, so dsh-mneme calls an external `/embeddings` endpoint itself.
3
+ // Works with OpenAI, SiliconFlow, Zhipu, local Ollama (via OpenAI-compatible
4
+ // proxy) and any provider exposing the standard embeddings API.
5
+ const DEFAULT_TIMEOUT_MS = 15000;
6
+
7
+ /** Normalize a configured baseUrl into the full embeddings endpoint URL. */
8
+ function embeddingsUrl(baseUrl) {
9
+ const base = String(baseUrl ?? "").trim().replace(/\/+$/, "");
10
+ if (!base) return "";
11
+ // Accept both "https://host/v1" and a full path ending in /embeddings.
12
+ if (/\/embeddings$/i.test(base)) return base;
13
+ return `${base}/embeddings`;
14
+ }
15
+
16
+ /**
17
+ * Call the embeddings API for one text. Resolves to a Float64 array, or null
18
+ * when the provider is not configured, the call fails, or the response is
19
+ * unusable. Never throws: failures degrade to keyword search.
20
+ */
21
+ export async function embedText({ baseUrl, apiKey, model }, text) {
22
+ const url = embeddingsUrl(baseUrl);
23
+ if (!url || !apiKey || !model || !text) return null;
24
+ let res;
25
+ try {
26
+ res = await fetch(url, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ "Authorization": `Bearer ${apiKey}`
31
+ },
32
+ body: JSON.stringify({ model, input: String(text).slice(0, 8000) }),
33
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS)
34
+ });
35
+ } catch {
36
+ return null;
37
+ }
38
+ if (!res.ok) return null;
39
+ let body;
40
+ try {
41
+ body = await res.json();
42
+ } catch {
43
+ return null;
44
+ }
45
+ const vec = body?.data?.[0]?.embedding;
46
+ return Array.isArray(vec) && vec.length ? Array.from(vec) : null;
47
+ }
48
+
49
+ /**
50
+ * Embedder bound to the current settings + store: on each write it re-embeds
51
+ * the row's title+content and stores the vector. Failures are swallowed so a
52
+ * flaky embedding endpoint never breaks memory writes.
53
+ */
54
+ export function createEmbedder({ store, settings, logger }) {
55
+ async function embedFor(id, title, content) {
56
+ const cfg = settings.getVectorConfig();
57
+ if (!cfg?.enabled || !cfg.baseUrl || !cfg.apiKey || !cfg.model) return;
58
+ const text = [title, content].filter(Boolean).join("\n");
59
+ const vector = await embedText(cfg, text);
60
+ if (vector) {
61
+ store.setEmbedding(id, vector);
62
+ logger?.info?.(`[dsh-mneme] embedded memory ${id} (dim=${vector.length})`);
63
+ }
64
+ }
65
+
66
+ return {
67
+ /** Fire-and-forget re-embed of a memory after any write. */
68
+ schedule(memory) {
69
+ if (!memory?.id) return;
70
+ embedFor(memory.id, memory.title, memory.content).catch(() => {});
71
+ },
72
+
73
+ /** Embed one text and return its vector (null on failure/disabled). */
74
+ async embed(query) {
75
+ const cfg = settings.getVectorConfig();
76
+ if (!cfg?.enabled || !cfg.baseUrl || !cfg.apiKey || !cfg.model) return null;
77
+ return embedText(cfg, query);
78
+ },
79
+
80
+ /** Batch re-index rows still missing an embedding. */
81
+ async reindexMissing(limit = 50) {
82
+ const cfg = settings.getVectorConfig();
83
+ if (!cfg?.enabled) return { indexed: 0, skipped: 0 };
84
+ const rows = store.needsEmbedding(limit);
85
+ let indexed = 0;
86
+ for (const row of rows) {
87
+ const text = [row.title, row.content].filter(Boolean).join("\n");
88
+ const vector = await embedText(cfg, text);
89
+ if (vector) {
90
+ store.setEmbedding(row.id, vector);
91
+ indexed++;
92
+ }
93
+ }
94
+ return { indexed, skipped: rows.length - indexed };
95
+ }
96
+ };
97
+ }
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ import { createDreamScheduler } from "./dream.js";
8
8
  import { createApi } from "./api.js";
9
9
  import { createSettings } from "./settings.js";
10
10
  import { createCommandManager } from "./commands.js";
11
+ import { createEmbedder } from "./embedding.js";
11
12
  import { Config } from "./config.js";
12
13
  import { mkdirSync } from "node:fs";
13
14
  import { join } from "node:path";
@@ -39,6 +40,11 @@ export const apply = (ctx, config) => {
39
40
  // same SQLite file but live in dedicated tables, isolated from memories.
40
41
  const settings = createSettings(store.db);
41
42
 
43
+ // Vector search: embedder calls the configured OpenAI-compatible embeddings
44
+ // endpoint on writes and for queries. service re-embeds after each write.
45
+ const embedder = createEmbedder({ store, settings, logger: ctx.logger });
46
+ service.setEmbedder(embedder);
47
+
42
48
  // Custom commands: register persisted commands into the DSH command registry
43
49
  // on boot; add/remove re-register live through the API.
44
50
  let commands = null;
@@ -85,7 +91,7 @@ export const apply = (ctx, config) => {
85
91
  });
86
92
 
87
93
  ctx.inject(["tools"], (toolsCtx) => {
88
- disposers.push(createTools(toolsCtx, service, cfg));
94
+ disposers.push(createTools(toolsCtx, service, cfg, embedder));
89
95
  });
90
96
 
91
97
  const summarizer = createSummarizer(ctx, service, cfg);
@@ -96,7 +102,7 @@ export const apply = (ctx, config) => {
96
102
  add: () => { throw new Error("commands unavailable"); },
97
103
  remove: () => false,
98
104
  list: () => []
99
- });
105
+ }, embedder);
100
106
  disposers.push(api.dispose);
101
107
  }
102
108