@modusensus/dsh-mneme 0.1.4 → 0.1.5
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/lib/api.js +92 -3
- package/lib/client.js +135 -60
- package/lib/embedding.js +97 -0
- package/lib/index.js +7 -1
- package/lib/service.js +17 -0
- package/lib/settings.js +22 -0
- package/lib/store.js +84 -5
- package/package.json +58 -58
- package/src/api.js +92 -3
- package/src/embedding.js +97 -0
- package/src/index.js +7 -1
- package/src/service.js +17 -0
- package/src/settings.js +22 -0
- package/src/store.js +84 -5
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
126
|
-
react.createElement("div", { style: styles.
|
|
127
|
-
react.createElement("
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
264
|
-
h("div", { style: styles.
|
|
265
|
-
h("
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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: "
|
|
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: "
|
|
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.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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;
|
package/lib/embedding.js
ADDED
|
@@ -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;
|
|
@@ -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
|
|
package/lib/service.js
CHANGED
|
@@ -6,6 +6,17 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
6
6
|
// passed in the constructor). Fired on the same write events as onWrite.
|
|
7
7
|
let dreamHook = null;
|
|
8
8
|
|
|
9
|
+
// Optional vector embedder, installed via setEmbedder after creation. After
|
|
10
|
+
// any content write it fire-and-forgets a re-embed of the row so vector
|
|
11
|
+
// search stays in sync; failures are swallowed inside the embedder.
|
|
12
|
+
let embedder = null;
|
|
13
|
+
|
|
14
|
+
function scheduleEmbed(memory) {
|
|
15
|
+
if (embedder && memory?.id) {
|
|
16
|
+
try { embedder.schedule(memory); } catch { /* ignore */ }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
/**
|
|
10
21
|
* Fire-and-forget write notification; errors are swallowed to keep write
|
|
11
22
|
* paths clean. The store mutation has already committed, so a throwing
|
|
@@ -38,6 +49,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
38
49
|
});
|
|
39
50
|
syncMirror();
|
|
40
51
|
notifyWrite();
|
|
52
|
+
scheduleEmbed(merged);
|
|
41
53
|
return { action: "merged", memory: merged };
|
|
42
54
|
}
|
|
43
55
|
const created = store.save({
|
|
@@ -50,6 +62,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
50
62
|
});
|
|
51
63
|
syncMirror();
|
|
52
64
|
notifyWrite();
|
|
65
|
+
scheduleEmbed(created);
|
|
53
66
|
return { action: "created", memory: created };
|
|
54
67
|
}
|
|
55
68
|
|
|
@@ -126,8 +139,11 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
126
139
|
mergeHumanEdits,
|
|
127
140
|
toApiList,
|
|
128
141
|
setDreamHook(fn) { dreamHook = fn; },
|
|
142
|
+
setEmbedder(emb) { embedder = emb; },
|
|
129
143
|
// passthroughs used by tools and api layers; mutations keep the mirror in sync
|
|
130
144
|
search: (q, o) => store.search(q, o),
|
|
145
|
+
searchVector: (v, o) => store.searchVector(v, o),
|
|
146
|
+
embeddedCount: () => store.embeddedCount(),
|
|
131
147
|
list: (o) => store.list(o),
|
|
132
148
|
all: () => store.all(),
|
|
133
149
|
count: (type) => store.count(type),
|
|
@@ -141,6 +157,7 @@ export function createService({ store, mirror, config, onWrite }) {
|
|
|
141
157
|
const updated = store.update(id, p);
|
|
142
158
|
syncMirror();
|
|
143
159
|
notifyWrite();
|
|
160
|
+
scheduleEmbed(updated);
|
|
144
161
|
return updated;
|
|
145
162
|
},
|
|
146
163
|
setForget: (id, f) => {
|
package/lib/settings.js
CHANGED
|
@@ -115,6 +115,28 @@ export function createSettings(db) {
|
|
|
115
115
|
removeCommand(id) {
|
|
116
116
|
const result = db.prepare("DELETE FROM custom_commands WHERE id = ?").run(id);
|
|
117
117
|
return result.changes > 0;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/** Vector-search provider config (OpenAI-compatible embeddings endpoint). */
|
|
121
|
+
getVectorConfig() {
|
|
122
|
+
const raw = getSetting("vector");
|
|
123
|
+
if (!raw) return undefined;
|
|
124
|
+
try {
|
|
125
|
+
const cfg = JSON.parse(raw);
|
|
126
|
+
return typeof cfg === "object" && cfg !== null ? cfg : undefined;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
setVectorConfig({ enabled, baseUrl, apiKey, model }) {
|
|
132
|
+
const cfg = {
|
|
133
|
+
enabled: enabled === true || enabled === 1,
|
|
134
|
+
baseUrl: String(baseUrl ?? "").trim().replace(/\/+$/, ""),
|
|
135
|
+
apiKey: String(apiKey ?? "").trim(),
|
|
136
|
+
model: String(model ?? "").trim()
|
|
137
|
+
};
|
|
138
|
+
setSetting("vector", JSON.stringify(cfg));
|
|
139
|
+
return cfg;
|
|
118
140
|
}
|
|
119
141
|
};
|
|
120
142
|
}
|