@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/src/api.js CHANGED
@@ -5,19 +5,41 @@ function sendJson(res, status, payload) {
5
5
  res.end(JSON.stringify(payload));
6
6
  }
7
7
 
8
- export function createApi(ctx, service) {
8
+ /** Collect the request body as text (tolerant of empty/invalid bodies). */
9
+ function readBody(req) {
10
+ return new Promise((resolve) => {
11
+ let body = "";
12
+ req.on("data", (chunk) => { body += chunk; });
13
+ req.on("end", () => resolve(body));
14
+ req.on("error", () => resolve(""));
15
+ });
16
+ }
17
+
18
+ function parseBody(text) {
19
+ try {
20
+ return JSON.parse(text || "{}");
21
+ } catch {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ export function createApi(ctx, service, settings, commands, embedder) {
9
27
  const disposers = [];
10
28
 
29
+ const register = (route) => {
30
+ disposers.push(ctx.webServer.register(route));
31
+ };
32
+
11
33
  // /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
12
- disposers.push(ctx.webServer.register({
34
+ register({
13
35
  kind: "prefix",
14
36
  path: "/api/dsh-mneme",
15
37
  handler(req, res) {
16
38
  sendJson(res, 404, { error: "not-found" });
17
39
  }
18
- }));
40
+ });
19
41
 
20
- disposers.push(ctx.webServer.register({
42
+ register({
21
43
  kind: "exact",
22
44
  path: "/api/dsh-mneme/list",
23
45
  handler(req, res) {
@@ -32,9 +54,9 @@ export function createApi(ctx, service) {
32
54
  sendJson(res, 500, { error: "internal" });
33
55
  }
34
56
  }
35
- }));
57
+ });
36
58
 
37
- disposers.push(ctx.webServer.register({
59
+ register({
38
60
  kind: "exact",
39
61
  path: "/api/dsh-mneme/search",
40
62
  handler(req, res) {
@@ -42,16 +64,180 @@ export function createApi(ctx, service) {
42
64
  const url = new URL(req.url, "http://localhost");
43
65
  const q = url.searchParams.get("q") ?? "";
44
66
  const limit = Number(url.searchParams.get("limit") ?? 20);
45
- const items = service.toApiList(service.search(q, { limit }));
46
- 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
+ });
110
+ } catch {
111
+ sendJson(res, 500, { error: "internal" });
112
+ }
113
+ }
114
+ });
115
+
116
+ // --- user profile ---
117
+ register({
118
+ kind: "exact",
119
+ path: "/api/dsh-mneme/profile",
120
+ handler(req, res) {
121
+ try {
122
+ if (req.method === "PUT" || req.method === "POST") {
123
+ return readBody(req).then((text) => {
124
+ const body = parseBody(text);
125
+ settings.setProfile(typeof body.profile === "string" ? body.profile : "");
126
+ sendJson(res, 200, { profile: settings.getProfile() });
127
+ });
128
+ }
129
+ sendJson(res, 200, { profile: settings.getProfile() });
130
+ } catch {
131
+ sendJson(res, 500, { error: "internal" });
132
+ }
133
+ }
134
+ });
135
+
136
+ // --- rules ---
137
+ register({
138
+ kind: "exact",
139
+ path: "/api/dsh-mneme/rules",
140
+ handler(req, res) {
141
+ try {
142
+ if (req.method === "PUT" || req.method === "POST") {
143
+ return readBody(req).then((text) => {
144
+ const body = parseBody(text);
145
+ settings.setRules(Array.isArray(body.rules) ? body.rules : []);
146
+ sendJson(res, 200, { rules: settings.getRules() });
147
+ });
148
+ }
149
+ sendJson(res, 200, { rules: settings.getRules() });
150
+ } catch {
151
+ sendJson(res, 500, { error: "internal" });
152
+ }
153
+ }
154
+ });
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
+
204
+ // --- custom commands ---
205
+ register({
206
+ kind: "exact",
207
+ path: "/api/dsh-mneme/commands",
208
+ handler(req, res) {
209
+ try {
210
+ if (req.method === "POST") {
211
+ return readBody(req).then((text) => {
212
+ const body = parseBody(text);
213
+ try {
214
+ const command = commands.add({
215
+ name: body.name,
216
+ description: body.description,
217
+ instruction: body.instruction
218
+ });
219
+ sendJson(res, 200, { command });
220
+ } catch (error) {
221
+ sendJson(res, 400, { error: error.message });
222
+ }
223
+ });
224
+ }
225
+ if (req.method === "DELETE") {
226
+ const url = new URL(req.url, "http://localhost");
227
+ const id = url.searchParams.get("id");
228
+ const removed = id ? commands.remove(id) : false;
229
+ sendJson(res, 200, { removed });
230
+ return;
231
+ }
232
+ sendJson(res, 200, { commands: commands.list() });
47
233
  } catch {
48
234
  sendJson(res, 500, { error: "internal" });
49
235
  }
50
236
  }
51
- }));
237
+ });
52
238
 
53
239
  return {
54
- routes: 3,
240
+ routes: 6,
55
241
  dispose: () => {
56
242
  for (const dispose of disposers) dispose();
57
243
  }
@@ -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/src/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
  };
package/src/inject.js CHANGED
@@ -1,4 +1,4 @@
1
- export function createInjector(ctx, service, config) {
1
+ export function createInjector(ctx, service, settings, config) {
2
2
  const maxItems = config.maxInjectedItems ?? 5;
3
3
  const threshold = config.importanceThreshold ?? 3;
4
4
 
@@ -11,12 +11,37 @@ export function createInjector(ctx, service, config) {
11
11
  return lines.join("\n");
12
12
  }
13
13
 
14
- return ctx.systemPrompt.context({
15
- name: "memory",
16
- order: 90,
17
- text: () => {
18
- const candidates = service.injectCandidates({ maxItems, threshold });
19
- return render(candidates);
14
+ // User profile + rules: injected ahead of the memory block because they are
15
+ // always-relevant instructions the agent should follow every turn.
16
+ function renderUserSettings() {
17
+ const profile = settings.getProfile().trim();
18
+ const rules = settings.getRules();
19
+ if (!profile && !rules.length) return "";
20
+ const lines = ["[用户设置] 来自 dsh-mneme 的用户画像与规则:"];
21
+ if (profile) lines.push(`- 用户画像:${profile}`);
22
+ for (const rule of rules) lines.push(`- 规则:${rule}`);
23
+ return lines.join("\n");
24
+ }
25
+
26
+ const disposers = [
27
+ ctx.systemPrompt.context({
28
+ name: "memory",
29
+ order: 90,
30
+ text: () => {
31
+ const candidates = service.injectCandidates({ maxItems, threshold });
32
+ return render(candidates);
33
+ }
34
+ }),
35
+ ctx.systemPrompt.context({
36
+ name: "user-settings",
37
+ order: 85,
38
+ text: renderUserSettings
39
+ })
40
+ ];
41
+
42
+ return () => {
43
+ for (const dispose of disposers) {
44
+ if (typeof dispose === "function") dispose();
20
45
  }
21
- });
46
+ };
22
47
  }
package/src/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) => {