@modusensus/dsh-mneme 0.1.3 → 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/client.js CHANGED
@@ -23,7 +23,37 @@ window.__ModuleLoader__.load({
23
23
  "memory.tab.preference": "偏好",
24
24
  "memory.tab.project": "项目",
25
25
  "memory.tab.decision": "决策",
26
- "memory.tab.history": "历史"
26
+ "memory.tab.history": "历史",
27
+ "memory.settings.open": "设置",
28
+ "memory.settings.title": "记忆库设置",
29
+ "memory.settings.profile": "用户画像",
30
+ "memory.settings.profileHint": "描述你自己(角色、背景、偏好),Agent 会在每轮遵循",
31
+ "memory.settings.profileSave": "保存画像",
32
+ "memory.settings.profileSaved": "画像已保存",
33
+ "memory.settings.rules": "规则",
34
+ "memory.settings.rulesHint": "Agent 必须遵守的行为规则,每轮注入",
35
+ "memory.settings.ruleAdd": "添加规则",
36
+ "memory.settings.rulePlaceholder": "例如:回答时总是先给结论",
37
+ "memory.settings.commands": "自定义指令",
38
+ "memory.settings.commandsHint": "注册斜杠命令(/名称),触发时把指令内容交给 Agent",
39
+ "memory.settings.cmdName": "命令名",
40
+ "memory.settings.cmdDesc": "描述",
41
+ "memory.settings.cmdInstruction": "指令内容",
42
+ "memory.settings.cmdAdd": "添加命令",
43
+ "memory.settings.cmdDelete": "删除",
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} 条"
27
57
  },
28
58
  en: {
29
59
  "memory.panel.title": "Memory",
@@ -34,7 +64,37 @@ window.__ModuleLoader__.load({
34
64
  "memory.tab.preference": "Preferences",
35
65
  "memory.tab.project": "Projects",
36
66
  "memory.tab.decision": "Decisions",
37
- "memory.tab.history": "History"
67
+ "memory.tab.history": "History",
68
+ "memory.settings.open": "Settings",
69
+ "memory.settings.title": "Memory Settings",
70
+ "memory.settings.profile": "User Profile",
71
+ "memory.settings.profileHint": "Describe yourself — the agent follows this every turn",
72
+ "memory.settings.profileSave": "Save Profile",
73
+ "memory.settings.profileSaved": "Profile saved",
74
+ "memory.settings.rules": "Rules",
75
+ "memory.settings.rulesHint": "Behavior rules the agent must follow every turn",
76
+ "memory.settings.ruleAdd": "Add Rule",
77
+ "memory.settings.rulePlaceholder": "e.g. Always lead with a conclusion",
78
+ "memory.settings.commands": "Custom Commands",
79
+ "memory.settings.commandsHint": "Register slash commands (/name) whose instruction is handed to the agent",
80
+ "memory.settings.cmdName": "Name",
81
+ "memory.settings.cmdDesc": "Description",
82
+ "memory.settings.cmdInstruction": "Instruction",
83
+ "memory.settings.cmdAdd": "Add Command",
84
+ "memory.settings.cmdDelete": "Delete",
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"
38
98
  }
39
99
  };
40
100
 
@@ -50,13 +110,22 @@ window.__ModuleLoader__.load({
50
110
  return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString();
51
111
  }
52
112
 
53
- function MemoryPanel({ t, onClose }) {
113
+ function MemoryPanel({ t, onClose, embedded }) {
54
114
  const [tab, setTab] = useState("all");
55
115
  const [query, setQuery] = useState("");
116
+ const [semantic, setSemantic] = useState(false);
117
+ const [vecEnabled, setVecEnabled] = useState(false);
56
118
  const [items, setItems] = useState([]);
57
119
  const [loading, setLoading] = useState(false);
58
120
  const abortRef = useRef(null);
59
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
+
60
129
  const load = useCallback(async () => {
61
130
  abortRef.current?.abort();
62
131
  const controller = new AbortController();
@@ -66,7 +135,7 @@ window.__ModuleLoader__.load({
66
135
  const params = new URLSearchParams();
67
136
  if (tab !== "all") params.set("type", tab);
68
137
  const url = query.trim()
69
- ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}`
138
+ ? `/api/dsh-mneme/search?q=${encodeURIComponent(query.trim())}&mode=${semantic ? "vector" : "auto"}`
70
139
  : `/api/dsh-mneme/list?${params.toString()}`;
71
140
  const res = await fetch(url, { signal: controller.signal });
72
141
  const data = await res.json();
@@ -77,7 +146,7 @@ window.__ModuleLoader__.load({
77
146
  } finally {
78
147
  setLoading(false);
79
148
  }
80
- }, [tab, query]);
149
+ }, [tab, query, semantic]);
81
150
 
82
151
  useEffect(() => {
83
152
  load();
@@ -86,19 +155,24 @@ window.__ModuleLoader__.load({
86
155
 
87
156
  const tabs = ["all", "preference", "project", "decision", "history"];
88
157
 
89
- return createPortal(
90
- react.createElement("div", { style: styles.overlay },
91
- react.createElement("div", { style: styles.panel },
92
- react.createElement("div", { style: styles.header },
93
- react.createElement("span", { style: styles.title }, t("memory.panel.title")),
94
- 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"))
95
175
  ),
96
- react.createElement("input", {
97
- style: styles.search,
98
- placeholder: t("memory.panel.search"),
99
- value: query,
100
- onChange: (e) => setQuery(e.target.value)
101
- }),
102
176
  react.createElement("div", { style: styles.tabs },
103
177
  tabs.map((key) =>
104
178
  react.createElement("button", {
@@ -128,10 +202,218 @@ window.__ModuleLoader__.load({
128
202
  )
129
203
  )
130
204
  )
131
- )
205
+ );
206
+ return embedded
207
+ ? body
208
+ : createPortal(react.createElement("div", { style: styles.overlay }, body), document.body);
209
+ }
210
+
211
+ const h = react.createElement;
212
+
213
+ // --- Settings panel: user profile, rules, custom commands ---
214
+ function SettingsPanel({ t, onClose, embedded }) {
215
+ const [sectionTab, setSectionTab] = react.useState("settings");
216
+ const [profile, setProfile] = react.useState("");
217
+ const [rules, setRules] = react.useState([]);
218
+ const [commands, setCommands] = react.useState([]);
219
+ const [newRule, setNewRule] = react.useState("");
220
+ const [newCmd, setNewCmd] = react.useState({ name: "", description: "", instruction: "" });
221
+ const [saved, setSaved] = react.useState(false);
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("");
227
+
228
+ const load = react.useCallback(async () => {
229
+ try {
230
+ const [p, r, c, v] = await Promise.all([
231
+ fetch("/api/dsh-mneme/profile").then((res) => res.json()),
232
+ fetch("/api/dsh-mneme/rules").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())
235
+ ]);
236
+ setProfile(p.profile || "");
237
+ setRules(Array.isArray(r.rules) ? r.rules : []);
238
+ setCommands(Array.isArray(c.commands) ? c.commands : []);
239
+ setVector(v.config || { enabled: false, baseUrl: "", apiKey: "", model: "" });
240
+ } catch { /* ignore */ }
241
+ }, []);
242
+
243
+ react.useEffect(() => { load(); }, [load]);
244
+
245
+ async function saveProfile() {
246
+ try {
247
+ await fetch("/api/dsh-mneme/profile", {
248
+ method: "PUT",
249
+ headers: { "Content-Type": "application/json" },
250
+ body: JSON.stringify({ profile })
251
+ });
252
+ setSaved(true);
253
+ setTimeout(() => setSaved(false), 1500);
254
+ } catch { /* ignore */ }
255
+ }
256
+
257
+ async function putRules(next) {
258
+ await fetch("/api/dsh-mneme/rules", {
259
+ method: "PUT",
260
+ headers: { "Content-Type": "application/json" },
261
+ body: JSON.stringify({ rules: next })
262
+ });
263
+ }
264
+
265
+ async function addRule() {
266
+ const text = newRule.trim();
267
+ if (!text) return;
268
+ const next = [...rules, text];
269
+ await putRules(next);
270
+ setRules(next);
271
+ setNewRule("");
272
+ }
273
+
274
+ async function removeRule(index) {
275
+ const next = rules.filter((_, i) => i !== index);
276
+ await putRules(next);
277
+ setRules(next);
278
+ }
279
+
280
+ async function addCommand() {
281
+ const name = newCmd.name.trim();
282
+ const instruction = newCmd.instruction.trim();
283
+ if (!name || !instruction) return;
284
+ try {
285
+ const res = await fetch("/api/dsh-mneme/commands", {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: JSON.stringify({ name, description: newCmd.description, instruction })
289
+ });
290
+ const data = await res.json();
291
+ if (!res.ok) { setCmdError(data.error || "failed"); return; }
292
+ setCmdError("");
293
+ setCommands([...commands, data.command]);
294
+ setNewCmd({ name: "", description: "", instruction: "" });
295
+ } catch { setCmdError("failed"); }
296
+ }
297
+
298
+ async function removeCommand(id) {
299
+ await fetch(`/api/dsh-mneme/commands?id=${encodeURIComponent(id)}`, { method: "DELETE" });
300
+ setCommands(commands.filter((c) => c.id !== id));
301
+ }
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
+
327
+ const inputStyle = { ...styles.search, marginBottom: 8 };
328
+ const labelStyle = { fontSize: 12, fontWeight: 600, margin: "12px 0 4px" };
329
+ const hintStyle = { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)", marginBottom: 8 };
330
+ const rowStyle = { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l1, #eee)" };
331
+
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"))
132
340
  ),
133
- document.body
341
+ sectionTab === "memory"
342
+ ? h(MemoryPanel, { t, embedded: true })
343
+ : h("div", { style: { overflowY: "auto" } },
344
+ // profile
345
+ h("div", { style: labelStyle }, t("memory.settings.profile")),
346
+ h("div", { style: hintStyle }, t("memory.settings.profileHint")),
347
+ h("textarea", {
348
+ style: { ...inputStyle, minHeight: 72, resize: "both", fontFamily: "inherit" },
349
+ value: profile,
350
+ placeholder: t("memory.settings.profile"),
351
+ onChange: (e) => setProfile(e.target.value)
352
+ }),
353
+ h("div", null,
354
+ h("button", { style: styles.footerButton, onClick: saveProfile }, t("memory.settings.profileSave")),
355
+ saved && h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-success, #2a7)" } }, t("memory.settings.profileSaved"))
356
+ ),
357
+ // rules
358
+ h("div", { style: labelStyle }, t("memory.settings.rules")),
359
+ h("div", { style: hintStyle }, t("memory.settings.rulesHint")),
360
+ rules.length === 0 && h("div", { style: styles.hint }, t("memory.settings.empty")),
361
+ rules.map((rule, i) =>
362
+ h("div", { key: i, style: rowStyle },
363
+ h("span", { style: { fontSize: 13, flex: 1 } }, rule),
364
+ h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeRule(i) }, "×")
365
+ )
366
+ ),
367
+ h("div", { style: { display: "flex", gap: 6 } },
368
+ h("input", {
369
+ style: { ...inputStyle, flex: 1, marginBottom: 0 },
370
+ value: newRule,
371
+ placeholder: t("memory.settings.rulePlaceholder"),
372
+ onChange: (e) => setNewRule(e.target.value)
373
+ }),
374
+ h("button", { style: styles.footerButton, onClick: addRule }, t("memory.settings.ruleAdd"))
375
+ ),
376
+ // custom commands
377
+ h("div", { style: labelStyle }, t("memory.settings.commands")),
378
+ h("div", { style: hintStyle }, t("memory.settings.commandsHint")),
379
+ commands.length === 0 && h("div", { style: styles.hint }, t("memory.settings.empty")),
380
+ commands.map((cmd) =>
381
+ h("div", { key: cmd.id, style: rowStyle },
382
+ h("div", { style: { flex: 1 } },
383
+ h("div", { style: { fontSize: 13, fontWeight: 600 } }, `/${cmd.name}`),
384
+ h("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #999)" } }, cmd.description || cmd.instruction)
385
+ ),
386
+ h("button", { style: { ...styles.footerButton, color: "var(--dsw-alias-state-error, #c33)" }, onClick: () => removeCommand(cmd.id) }, t("memory.settings.cmdDelete"))
387
+ )
388
+ ),
389
+ h("div", { style: { display: "grid", gap: 6, marginTop: 6 } },
390
+ h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.name, placeholder: t("memory.settings.cmdName"), onChange: (e) => setNewCmd({ ...newCmd, name: e.target.value }) }),
391
+ h("input", { style: { ...inputStyle, marginBottom: 0 }, value: newCmd.description, placeholder: t("memory.settings.cmdDesc"), onChange: (e) => setNewCmd({ ...newCmd, description: 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 }) }),
393
+ h("button", { style: styles.footerButton, onClick: addCommand }, t("memory.settings.cmdAdd")),
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)
411
+ )
412
+ )
134
413
  );
414
+ return embedded
415
+ ? body
416
+ : createPortal(h("div", { style: styles.overlay }, body), document.body);
135
417
  }
136
418
 
137
419
  const styles = {
@@ -158,26 +440,18 @@ window.__ModuleLoader__.load({
158
440
  function apply(ctx) {
159
441
  ctx.effect(() => ctx.locale.register(NS, dictionaries), "dsh-mneme: dictionaries");
160
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.
161
446
  ctx.effect(() => {
162
447
  const t = ctx.locale.bind(NS);
163
- return ctx.slots.inject("sidebar.footer.action", () =>
164
- ctx.slots.register({
165
- name: "sidebar.footer.action",
166
- id: "memory",
167
- locale: NS,
168
- inject: () => ({})
169
- }, () => {
170
- const [open, setOpen] = react.useState(false);
171
- return react.createElement(react.Fragment, null,
172
- react.createElement("button", {
173
- onClick: () => setOpen(true),
174
- style: { ...styles.footerButton, ...(open ? styles.footerButtonActive : {}) }
175
- }, t("memory.panel.open")),
176
- open && react.createElement(MemoryPanel, { t, onClose: () => setOpen(false) })
177
- );
178
- })
179
- );
180
- }, "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");
181
455
  }
182
456
 
183
457
  exports.apply = apply;
@@ -0,0 +1,64 @@
1
+ // Custom slash-command manager: keeps the DSH command registry in sync with
2
+ // user-defined commands persisted in SQLite. Commands are registered on boot
3
+ // and (re)registered on add/remove through the API.
4
+ //
5
+ // Each custom command's handler returns the user-authored instruction as a
6
+ // success result; the DSH UI surfaces it as a model-directed instruction.
7
+ export function createCommandManager({ ctx, settings, logger }) {
8
+ const registered = new Map(); // name -> disposer
9
+
10
+ function registerOne(command) {
11
+ if (registered.has(command.name)) return;
12
+ let dispose;
13
+ try {
14
+ dispose = ctx.commands.register({
15
+ name: command.name,
16
+ description: command.description || `自定义指令 ${command.name}`,
17
+ handler: () => ({ kind: "success", text: command.instruction })
18
+ });
19
+ } catch (error) {
20
+ logger?.warn?.(`dsh-mneme: failed to register command /${command.name}: ${String(error)}`);
21
+ return;
22
+ }
23
+ registered.set(command.name, dispose);
24
+ }
25
+
26
+ function unregisterOne(name) {
27
+ const dispose = registered.get(name);
28
+ if (dispose) {
29
+ try {
30
+ dispose();
31
+ } catch {
32
+ /* ignore double-dispose */
33
+ }
34
+ registered.delete(name);
35
+ }
36
+ }
37
+
38
+ /** Register every stored command (boot-time sync). */
39
+ function sync() {
40
+ for (const command of settings.listCommands()) registerOne(command);
41
+ }
42
+
43
+ /** Add (or replace) a command and register it live. */
44
+ function add({ name, description, instruction }) {
45
+ const command = settings.addCommand({ name, description, instruction });
46
+ registerOne(command);
47
+ return command;
48
+ }
49
+
50
+ /** Remove a command by id and unregister it live. */
51
+ function remove(id) {
52
+ const existing = settings.listCommands().find((c) => c.id === id);
53
+ if (!existing) return false;
54
+ if (!settings.removeCommand(id)) return false;
55
+ unregisterOne(existing.name);
56
+ return true;
57
+ }
58
+
59
+ function dispose() {
60
+ for (const name of [...registered.keys()]) unregisterOne(name);
61
+ }
62
+
63
+ return { sync, add, remove, list: () => settings.listCommands(), dispose };
64
+ }
@@ -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
@@ -6,13 +6,16 @@ import { createInjector } from "./inject.js";
6
6
  import { createSummarizer } from "./summarize.js";
7
7
  import { createDreamScheduler } from "./dream.js";
8
8
  import { createApi } from "./api.js";
9
+ import { createSettings } from "./settings.js";
10
+ import { createCommandManager } from "./commands.js";
11
+ import { createEmbedder } from "./embedding.js";
9
12
  import { Config } from "./config.js";
10
13
  import { mkdirSync } from "node:fs";
11
14
  import { join } from "node:path";
12
15
  import { homedir } from "node:os";
13
16
 
14
17
  export const name = "dsh-mneme";
15
- export const inject = ["tools", "systemPrompt", "webServer", "llm", "agentDefaultModel"];
18
+ export const inject = ["tools", "systemPrompt", "webServer", "llm", "agentDefaultModel", "commands"];
16
19
  export { Config };
17
20
 
18
21
  // Arrow (not function declaration): cordis 4 treats any apply with a
@@ -33,6 +36,23 @@ export const apply = (ctx, config) => {
33
36
  const mirror = createMirror(memoryDir);
34
37
  const service = createService({ store, mirror, config: cfg });
35
38
 
39
+ // User-configurable settings (profile, rules) and custom commands share the
40
+ // same SQLite file but live in dedicated tables, isolated from memories.
41
+ const settings = createSettings(store.db);
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
+
48
+ // Custom commands: register persisted commands into the DSH command registry
49
+ // on boot; add/remove re-register live through the API.
50
+ let commands = null;
51
+ if (ctx.commands) {
52
+ commands = createCommandManager({ ctx, settings, logger: ctx.logger });
53
+ commands.sync();
54
+ }
55
+
36
56
  // Human edits in mirror files win on every sync; merge them back first.
37
57
  // TYPE_FILE maps each memory type to its mirror filename. Read every type's
38
58
  // edits up front: mergeHumanEdits re-renders ALL mirror files on success, so
@@ -67,7 +87,7 @@ export const apply = (ctx, config) => {
67
87
  const disposers = [];
68
88
 
69
89
  ctx.inject(["systemPrompt"], (promptCtx) => {
70
- if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, cfg));
90
+ if (cfg.autoInject) disposers.push(createInjector(promptCtx, service, settings, cfg));
71
91
  });
72
92
 
73
93
  ctx.inject(["tools"], (toolsCtx) => {
@@ -78,7 +98,11 @@ export const apply = (ctx, config) => {
78
98
  disposers.push(summarizer.dispose);
79
99
 
80
100
  if (ctx.webServer) {
81
- const api = createApi(ctx, service);
101
+ const api = createApi(ctx, service, settings, commands ?? {
102
+ add: () => { throw new Error("commands unavailable"); },
103
+ remove: () => false,
104
+ list: () => []
105
+ }, embedder);
82
106
  disposers.push(api.dispose);
83
107
  }
84
108
 
@@ -89,6 +113,7 @@ export const apply = (ctx, config) => {
89
113
  for (const dispose of disposers) {
90
114
  if (typeof dispose === "function") dispose();
91
115
  }
116
+ commands?.dispose();
92
117
  if (dream) await dream.dispose();
93
118
  store.close();
94
119
  };